From e2408ec9de08b76e9361989b48ffdf7b40f2ee57 Mon Sep 17 00:00:00 2001 From: Roger Mauvois Date: Mon, 16 Feb 2026 14:02:56 +0100 Subject: [PATCH 1/8] =?UTF-8?q?modifs=20compatibilit=C3=A9=20Logre=20dev.b?= =?UTF-8?q?eta.2.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- graphly-pr-guide.md | 181 +++++++++++++++++++++++++++++++++ graphly/models/shacl.py | 91 ++++++++++++----- graphly/schema/graph.py | 97 +++++++++++------- graphly/sparql/allegrograph.py | 101 ++++++++++++------ 4 files changed, 372 insertions(+), 98 deletions(-) create mode 100644 graphly-pr-guide.md diff --git a/graphly-pr-guide.md b/graphly-pr-guide.md new file mode 100644 index 0000000..57b9f9a --- /dev/null +++ b/graphly-pr-guide.md @@ -0,0 +1,181 @@ +# Guide PR Graphly (Logre fixes) + +Ce document decrit, de maniere detaillee, les modifications a appliquer dans Graphly pour preparer le PR. Il est destine a l agent de codage charge d implementer les corrections. + +Objectif general +- Aligner Graphly upstream avec les correctifs necessaires a Logre. +- Supprimer la dependance a une copie locale vendoree dans Logre. +- Garantir un comportement stable pour SHACL, export Turtle et AllegroGraph. + +Perimetre +- Fichiers cibles dans Graphly: + - `graphly/models/shacl.py` + - `graphly/schema/graph.py` + - `graphly/sparql/allegrograph.py` +- Aucun changement d API publique, uniquement robustesse et comportement correct. + +Pre-requis +- Repo Graphly clone localement. +- Branche dediee (ex: `logre-compat-fixes`). + +--- + +1) SHACL: separation range_class_uri / range_datatype + +Contexte +- Dans Logre, certaines shapes utilisent `sh:datatype` pour definir un range literal. +- L implementation actuelle fusionne `datatype` dans `range_class_uri` ce qui perturbe la resolution des classes. + +Objectif +- Conserver la valeur `range_class_uri` pour les classes, et gerer `range_datatype` a part. +- Choisir `range_target` comme `range_uri` (classe) ou `range_datatype` (datatype) si present. + +Fichier cible +- `graphly/models/shacl.py` + +Modifications a faire +1. Dans la requete SPARQL de `get_properties`, ajouter un champ `range_datatype`: + +Avant +```python +(COALESCE(?range_class_uri_, ?datatype_, '') as ?range_class_uri) +``` + +Apres +```python +(COALESCE(?range_class_uri_, '') as ?range_class_uri) +(COALESCE(?datatype_, '') as ?range_datatype) +``` + +2. Dans la boucle Python, recuperer `range_datatype` et ajuster le calcul du range: + +Avant +```python +range_uri = resp.get('range_class_uri') +range = self.find_class(range_uri) if range_uri else None +``` + +Apres +```python +range_uri = resp.get('range_class_uri') +range_datatype = resp.get('range_datatype') +range_target = range_uri or range_datatype +range = self.find_class(range_target) if range_target else None +``` + +Remarque +- On ne change pas la signature des classes ni la structure des objets `Property`. + +--- + +2) Graph: dump_turtle plus robuste + +Contexte +- Certains endpoints renvoient des objets non str (ou des objets with __str__). +- `dump_turtle` doit proteger contre ces variations sans casser les blank nodes. + +Objectif +- Forcer `str()` pour `s`, `p`, `o` avant de passer dans `prepare`. +- Conserver la logique des blank nodes. + +Fichier cible +- `graphly/schema/graph.py` + +Modifications a faire +Dans `dump_turtle`, remplacer la construction de `s`, `p`, `o` par la version suivante: + +```python +subj = str(triple['s']) +obj = triple['o'] + +if subj.startswith('_:'): + s = subj +elif triple['s_is_blank'] == 'true': + s = f"_:{subj}" +else: + s = prepare(subj, prefixes.shorts()) + +p = prepare(str(triple['p']), prefixes.shorts()) + +obj_str = str(obj) +if obj_str.startswith('_:'): + o = obj_str +elif triple['o_is_blank'] == 'true': + o = f"_:{obj_str}" +else: + o = prepare(obj, prefixes.shorts()) +``` + +Remarque +- Ne pas changer `dump_nquad` (la logique reste ok). + +--- + +3) AllegroGraph: prefixes et mutation + +Contexte +- AllegroGraph requiert les prefixes `franz` + `franzOption_defaultDatasetBehavior`. +- L implementation actuelle mute `Prefixes` en place, ce qui peut avoir des effets de bord. +- `insert`/`delete` ne transmettent pas `prefixes`. + +Objectif +- Toujours ajouter les prefixes requis sans muter l objet partage. +- Ajouter le prefix `franz` (namespace complet) en plus du prefix option. +- Propager `prefixes` dans `insert` et `delete`. + +Fichier cible +- `graphly/sparql/allegrograph.py` + +Modifications a faire +1. Definir les prefixes requis: + +```python +franz_prefix = Prefix('franz', 'http://franz.com/ns/allegrograph/7.0/') +additional_prefix = Prefix('franzOption_defaultDatasetBehavior', 'franz:rdf') +``` + +2. Mettre a jour `run` pour travailler sur une copie: + +```python +required_prefixes = [self.franz_prefix, self.additional_prefix] +if prefixes is None: + local_prefixes = Prefixes(required_prefixes.copy()) +else: + local_prefixes = Prefixes(prefixes.prefix_list.copy()) + for prefix in required_prefixes: + if not local_prefixes.has(prefix.short): + local_prefixes.add(prefix) +return super().run(text, local_prefixes) +``` + +3. Mettre a jour `insert` pour propager `prefixes`: + +```python +def insert(self, triples, graph_uri=None, prefixes=None): + self.delete(triples, graph_uri, prefixes) + super().insert(triples, graph_uri, prefixes) +``` + +Remarques +- Laisser `delete` tel quel si sa signature accepte `prefixes` dans la classe parente. +- Conserver `technology_name = 'Allegrograph'`. + +--- + +Verification minimale +- Lint basique (si present) ou au moins import des modules modifies. +- Pas de tests automatise requis, mais verifier que les imports restent valides. + +Message de commit propose +- `Fix SHACL range datatype handling and AllegroGraph prefixes` + +PR description (synthese) +- Stabilise SHACL range handling by separating datatype and class range. +- Harden Graph.dump_turtle string casting for blank nodes. +- Ensure AllegroGraph required prefixes without mutating shared Prefixes. + +--- + +Notes +- Aucun changement dans Logre ici; ce PR vise uniquement Graphly. +- Ces correctifs doivent ensuite etre pinnees dans Logre (requirements) jusqu au merge upstream. diff --git a/graphly/models/shacl.py b/graphly/models/shacl.py index 5566703..9a5b9fc 100644 --- a/graphly/models/shacl.py +++ b/graphly/models/shacl.py @@ -25,13 +25,20 @@ class SHACL(Model): get_classes: Retrieves SHACL-defined classes from a given RDF graph. get_properties: Retrieves SHACL-defined properties from a given RDF graph. """ - - def __init__(self, sparql: Sparql, uri: str = None, prefixes: Prefixes = None, type_property: str = 'rdf:type', label_property: str = "rdfs:label", comment_property: str = "rdfs:comment") -> None: + def __init__( + self, + sparql: Sparql, + uri: str = None, + prefixes: Prefixes = None, + type_property: str = "rdf:type", + label_property: str = "rdfs:label", + comment_property: str = "rdfs:comment", + ) -> None: """ Initialize a SHACL-based Model instance with default or custom property identifiers. - This constructor sets the framework name to "SHACL" and delegates the + This constructor sets the framework name to "SHACL" and delegates the initialization of type, label, and comment properties to the base Model class. Args: @@ -43,22 +50,23 @@ def __init__(self, sparql: Sparql, uri: str = None, prefixes: Prefixes = None, t comment_property (str, optional): The property used to define entity comments or descriptions. Defaults to 'rdfs:comment'. """ self.framework_name = "SHACL" - super().__init__(sparql, uri, prefixes, type_property, label_property, comment_property) + super().__init__( + sparql, uri, prefixes, type_property, label_property, comment_property + ) - def get_classes(self) -> List[Resource]: """ Retrieve SHACL-defined classes from the given RDF graph. - Constructs and executes a SPARQL query to find all `sh:NodeShape` nodes - and their associated target classes, extracting both the class URI and + Constructs and executes a SPARQL query to find all `sh:NodeShape` nodes + and their associated target classes, extracting both the class URI and optional label. The results are returned as a list of `Resource` instances. Args: Returns: - List[Resource]: A list of `Resource` objects representing the SHACL-defined - classes, each with a `class_uri` of "owl:Class". Returns an empty list if + List[Resource]: A list of `Resource` objects representing the SHACL-defined + classes, each with a `class_uri` of "owl:Class". Returns an empty list if no classes are found. """ # Prepare the query @@ -80,28 +88,31 @@ def get_classes(self) -> List[Resource]: response = self.run(query) # Transform into a list of Resource instances, or an empty list - classes = [Resource.from_dict({**obj, "class_uri": "owl:Class"}) for obj in response] if response else [] + classes = ( + [Resource.from_dict({**obj, "class_uri": "owl:Class"}) for obj in response] + if response + else [] + ) # Add Value classes classes += super().get_value_classes() return classes - def get_properties(self) -> List[Property]: """ Retrieve SHACL-defined properties from the given RDF graph. - Constructs and executes a SPARQL query to extract properties defined - via SHACL shapes, including their target classes, labels, order, - minimum and maximum counts, domain, and range. The results are returned + Constructs and executes a SPARQL query to extract properties defined + via SHACL shapes, including their target classes, labels, order, + minimum and maximum counts, domain, and range. The results are returned as a list of `Property` instances. Args: Returns: - List[Property]: A list of `Property` objects representing the SHACL-defined - properties, including domain and range class information. Returns an empty + List[Property]: A list of `Property` objects representing the SHACL-defined + properties, including domain and range class information. Returns an empty list if no properties are found. """ # Prepare the query @@ -115,7 +126,8 @@ def get_properties(self) -> List[Property]: (COALESCE(?max_count_, '') as ?max_count) (COALESCE(?domain_class_uri_, '') as ?domain_class_uri) ?uri - (COALESCE(?range_class_uri_, ?datatype_, '') as ?range_class_uri) + (COALESCE(?range_class_uri_, '') as ?range_class_uri) + (COALESCE(?datatype_, '') as ?range_datatype) WHERE {{ {self.sparql_begin} ?shape sh:property ?node . @@ -138,24 +150,49 @@ def get_properties(self) -> List[Property]: # Execute the query response = self.run(query) - + # Transform into a list of Property instances, or an empty list properties = [] for resp in response: # Get domain and range try: - domain = self.find_class(resp['domain_class_uri']) - range = self.find_class(resp['range_class_uri']) + domain = self.find_class(resp["domain_class_uri"]) + range_uri = resp.get("range_class_uri") + range_datatype = resp.get("range_datatype") + range_target = range_uri or range_datatype + range = self.find_class(range_target) if range_target else None except: - raise Exception('Graphly was not able to get domain class or range class for the following property: ' + resp['uri'] + ' - ' + resp['label']) - + raise Exception( + "Graphly was not able to get domain class or range class for the following property: " + + resp["uri"] + + " - " + + resp["label"] + ) + # Get the class from which the property belongs try: - card_of = self.find_class(resp['card_of_class_uri']) + card_of = self.find_class(resp["card_of_class_uri"]) except: - raise Exception('Graphly was not able to get concerned class (card of class URI) for the following property: ' + resp['uri'] + ' - ' + resp['label']) + raise Exception( + "Graphly was not able to get concerned class (card of class URI) for the following property: " + + resp["uri"] + + " - " + + resp["label"] + ) # Create and add a new property - properties.append(Property(resp['uri'], resp['label'], "", domain, range, card_of, order=resp['order'], min_count=resp['min_count'], max_count=resp['max_count'])) - - return properties \ No newline at end of file + properties.append( + Property( + resp["uri"], + resp["label"], + "", + domain, + range, + card_of, + order=resp["order"], + min_count=resp["min_count"], + max_count=resp["max_count"], + ) + ) + + return properties diff --git a/graphly/schema/graph.py b/graphly/schema/graph.py index e07ce84..e08f024 100644 --- a/graphly/schema/graph.py +++ b/graphly/schema/graph.py @@ -34,8 +34,9 @@ class Graph: sparql_begin: str sparql_end: str - - def __init__(self, sparql: Sparql, uri: str = None, prefixes: Prefixes = None) -> None: + def __init__( + self, sparql: Sparql, uri: str = None, prefixes: Prefixes = None + ) -> None: """ Initialize a Graph instance with optional URI and prefixes for SPARQL queries. @@ -54,13 +55,16 @@ def __init__(self, sparql: Sparql, uri: str = None, prefixes: Prefixes = None) - self.sparql = sparql self.uri = uri self.prefixes = prefixes - if prefixes: self.uri_long = prefixes.lengthen(uri) - else: self.uri_long = uri - - self.sparql_begin = "GRAPH " + prepare(self.uri, prefixes.shorts()) + " {" if self.uri else "" + if prefixes: + self.uri_long = prefixes.lengthen(uri) + else: + self.uri_long = uri + + self.sparql_begin = ( + "GRAPH " + prepare(self.uri, prefixes.shorts()) + " {" if self.uri else "" + ) self.sparql_end = "}" if self.uri else "" - def run(self, text: str) -> List[Dict]: """ Executes a SPARQL query on this graph using the associated SPARQL endpoint. @@ -73,8 +77,9 @@ def run(self, text: str) -> List[Dict]: """ return self.sparql.run(text, self.prefixes) - - def insert(self, triples: List[tuple[str, str, str]] | tuple[str, str, str]) -> None: + def insert( + self, triples: List[tuple[str, str, str]] | tuple[str, str, str] + ) -> None: """ Inserts one or more RDF triples into this graph using the associated SPARQL endpoint. @@ -87,8 +92,9 @@ def insert(self, triples: List[tuple[str, str, str]] | tuple[str, str, str]) -> if len(triples) != 0: self.sparql.insert(triples, self.uri, self.prefixes) - - def delete(self, triples: List[tuple[str, str, str]] | tuple[str, str, str]) -> None: + def delete( + self, triples: List[tuple[str, str, str]] | tuple[str, str, str] + ) -> None: """ Deletes one or more RDF triples from this graph using the associated SPARQL endpoint. @@ -101,7 +107,6 @@ def delete(self, triples: List[tuple[str, str, str]] | tuple[str, str, str]) -> if len(triples) != 0: self.sparql.delete(triples, self.uri, self.prefixes) - def dump_dict(self) -> list[dict]: """ Dumps all triples from this graph as a list of dictionaries. @@ -134,18 +139,17 @@ def dump_dict(self) -> list[dict]: # Extract triples as long as they are coming while True: - query_ = query + f" OFFSET {offset}" # Append the offset - local_result = self.run(query_) # Run the query + query_ = query + f" OFFSET {offset}" # Append the offset + local_result = self.run(query_) # Run the query # If there are results, add them, and prepare next request, otherwise, everything is extracted if len(local_result) > 0: result += local_result offset += step - else: + else: break return result - def dump_turtle(self) -> str: """ @@ -160,27 +164,38 @@ def dump_turtle(self) -> str: triples = self.dump_dict() # Format prefixes for turtle file - content = '\n'.join(list(map(lambda prefix: prefix.to_turtle(), self.prefixes))) + '\n\n' + content = ( + "\n".join(list(map(lambda prefix: prefix.to_turtle(), self.prefixes))) + + "\n\n" + ) # Build the output: add all triples for triple in triples: # Need to save blank nodes correctly - if str(triple['s']).startswith('_:'): s = triple['s'] - elif triple['s_is_blank'] == 'true': s = f"_:{triple['s']}" - else: s = prepare(triple['s'], self.prefixes.shorts()) - - p = prepare(triple['p'], self.prefixes.shorts()) - - # Need to save blank nodes correctly - if str(triple['o']).startswith('_:'): o = triple['o'] - elif triple['o_is_blank'] == 'true': o = f"_:{triple['o']}" - else: o = prepare(triple['o'], self.prefixes.shorts()) + subj = str(triple["s"]) + obj = triple["o"] + + if subj.startswith("_:"): + s = subj + elif triple["s_is_blank"] == "true": + s = f"_:{subj}" + else: + s = prepare(subj, self.prefixes.shorts()) + + p = prepare(str(triple["p"]), self.prefixes.shorts()) + + obj_str = str(obj) + if obj_str.startswith("_:"): + o = obj_str + elif triple["o_is_blank"] == "true": + o = f"_:{obj_str}" + else: + o = prepare(obj, self.prefixes.shorts()) content += f"{s} {p} {o} .\n" return content - def dump_nquad(self) -> str: """ Dumps all triples from this graph in N-Quads serialization format. @@ -194,25 +209,31 @@ def dump_nquad(self) -> str: triples = self.dump_dict() # Build the output: add all quads - graph_uri = prepare(self.prefixes.lengthen(self.uri)) + ' ' if self.uri else '' + graph_uri = prepare(self.prefixes.lengthen(self.uri)) + " " if self.uri else "" content = "" for triple in triples: # Need to save blank nodes correctly - if str(triple['s']).startswith('_:'): s = triple['s'] - elif triple['s_is_blank'] == 'true': s = f"_:{triple['s']}" - else: s = prepare(self.prefixes.lengthen(triple['s'])) + subj = str(triple["s"]) + if subj.startswith("_:"): + s = subj + elif triple["s_is_blank"] == "true": + s = f"_:{subj}" + else: + s = prepare(self.prefixes.lengthen(subj)) - p = prepare(self.prefixes.lengthen(triple['p'])) + p = prepare(self.prefixes.lengthen(triple["p"])) # Need to save blank nodes correctly - if str(triple['o']).startswith('_:'): o = triple['o'] - elif triple['o_is_blank'] == 'true': o = f"_:{triple['o']}" - else: o = prepare(self.prefixes.lengthen(triple['o'])) + if str(triple["o"]).startswith("_:"): + o = triple["o"] + elif triple["o_is_blank"] == "true": + o = f"_:{triple['o']}" + else: + o = prepare(self.prefixes.lengthen(triple["o"])) content += f"{s} {p} {o} {graph_uri}.\n" return content - def upload_turtle(self, turtle_content: str) -> None: """ @@ -224,4 +245,4 @@ def upload_turtle(self, turtle_content: str) -> None: Returns: None """ - return self.sparql.upload_turtle(turtle_content, self.uri_long) \ No newline at end of file + return self.sparql.upload_turtle(turtle_content, self.uri_long) diff --git a/graphly/sparql/allegrograph.py b/graphly/sparql/allegrograph.py index de8f556..4469471 100644 --- a/graphly/sparql/allegrograph.py +++ b/graphly/sparql/allegrograph.py @@ -25,10 +25,23 @@ class Allegrograph(Sparql): technology_name (str): Set to 'Allegrograph' to indicate the SPARQL technology. """ - additional_prefix = Prefix('franzOption_defaultDatasetBehavior', 'franz:rdf') - - - def __init__(self, url: str, username: str, password: str, name: str = None) -> None: + franz_prefix = Prefix("franz", "http://franz.com/ns/allegrograph/7.0/") + additional_prefix = Prefix("franzOption_defaultDatasetBehavior", "franz:rdf") + + def _with_required_prefixes(self, prefixes: Prefixes | None) -> Prefixes: + required_prefixes = [self.franz_prefix, self.additional_prefix] + if prefixes is None: + return Prefixes(required_prefixes.copy()) + + local_prefixes = Prefixes(prefixes.prefix_list.copy()) + for prefix in required_prefixes: + if not local_prefixes.has(prefix.short): + local_prefixes.add(prefix) + return local_prefixes + + def __init__( + self, url: str, username: str, password: str, name: str = None + ) -> None: """ Initializes an AllegroGraph SPARQL wrapper instance. @@ -39,12 +52,18 @@ def __init__(self, url: str, username: str, password: str, name: str = None) -> name (str): The name given to the Sparql endpoint. """ super().__init__(url, username, password, name) - self.technology_name = 'Allegrograph' - - - def run(self, text: str, prefixes: Prefixes = None) -> None | list[dict]: + self.technology_name = "Allegrograph" + + def run( + self, + text: str, + prefixes: Prefixes = None, + query_param: str = "query", + url_appendix: str = "", + parse_response: bool = True, + ) -> None | list[dict]: """ - Executes a SPARQL query against the AllegroGraph endpoint, automatically + Executes a SPARQL query against the AllegroGraph endpoint, automatically including an additional prefix required by AllegroGraph. Args: @@ -54,13 +73,21 @@ def run(self, text: str, prefixes: Prefixes = None) -> None | list[dict]: Returns: None | list[dict]: The parsed query results for SELECT/ASK queries, or None for update operations. """ - if not prefixes.has(self.additional_prefix.short): - if not Prefixes: prefixes = Prefixes() - prefixes.add(self.additional_prefix) - return super().run(text, prefixes) - - - def insert(self, triples: List[tuple] | tuple, graph_uri: str | None = None, prefixes: Prefixes = None) -> None: + local_prefixes = self._with_required_prefixes(prefixes) + return super().run( + text, + local_prefixes, + query_param=query_param, + url_appendix=url_appendix, + parse_response=parse_response, + ) + + def insert( + self, + triples: List[tuple] | tuple, + graph_uri: str | None = None, + prefixes: Prefixes = None, + ) -> None: """ Inserts one or more RDF triples into the AllegroGraph endpoint, ensuring uniqueness. @@ -74,15 +101,12 @@ def insert(self, triples: List[tuple] | tuple, graph_uri: str | None = None, pre Returns: None """ - if not prefixes.has(self.additional_prefix.short): - if not Prefixes: prefixes = Prefixes() - prefixes.add(self.additional_prefix) - - # Because we can not be sure user has set the option, - # Triples need to be deleted before inserting so that we make sure of unicity - self.delete(triples, graph_uri, prefixes) - super().insert(triples, graph_uri, prefixes) + local_prefixes = self._with_required_prefixes(prefixes) + # Because we can not be sure user has set the option, + # Triples need to be deleted before inserting so that we make sure of unicity + self.delete(triples, graph_uri, local_prefixes) + super().insert(triples, graph_uri, local_prefixes) def upload_nquads_chunk(self, nquad_content: str) -> None: """ @@ -95,7 +119,11 @@ def upload_nquads_chunk(self, nquad_content: str) -> None: requests.HTTPError: If the HTTP request to the endpoint fails. """ # Prepare query - url = self.url if not self.url.endswith('/sparql') else self.url.replace('/sparql', '') + url = ( + self.url + if not self.url.endswith("/sparql") + else self.url.replace("/sparql", "") + ) url = f"{url}/statements" headers = {"Content-Type": "application/n-quads"} auth = (self.username, self.password) @@ -104,23 +132,31 @@ def upload_nquads_chunk(self, nquad_content: str) -> None: response = requests.post(url, data=nquad_content, headers=headers, auth=auth) response.raise_for_status() - - def upload_turtle_chunk(self, turtle_content: str, named_graph_uri: str = None) -> None: + def upload_turtle_chunk( + self, turtle_content: str, named_graph_uri: str = None + ) -> None: """ Uploads a chunk of RDF data in Turtle format to the AllegroGraph endpoint. Args: turtle_content (str): A chunk of RDF data serialized in Turtle format. - named_graph_uri (str, optional): The URI of the named graph where the data + named_graph_uri (str, optional): The URI of the named graph where the data should be uploaded. If not provided, data is uploaded to the default graph. Raises: requests.HTTPError: If the HTTP request to the endpoint fails. """ # Prepare query - url = self.url if not self.url.endswith('/sparql') else self.url.replace('/sparql', '') + url = ( + self.url + if not self.url.endswith("/sparql") + else self.url.replace("/sparql", "") + ) url = f"{url}/statements" - if named_graph_uri: url += "?context=" + prepare(named_graph_uri).replace(':', '%3A').replace('/', '%2F') + if named_graph_uri: + url += "?context=" + prepare(named_graph_uri).replace(":", "%3A").replace( + "/", "%2F" + ) headers = {"Content-Type": "text/turtle"} auth = (self.username, self.password) @@ -128,9 +164,8 @@ def upload_turtle_chunk(self, turtle_content: str, named_graph_uri: str = None) response = requests.post(url, data=turtle_content, headers=headers, auth=auth) response.raise_for_status() # Raise error for bad responses - @staticmethod - def from_dict(obj: dict[str, str]) -> 'Sparql': + def from_dict(obj: dict[str, str]) -> "Sparql": """ Creates a Sparql (Allegrograph) instance from a dictionary representation. @@ -140,4 +175,4 @@ def from_dict(obj: dict[str, str]) -> 'Sparql': Returns: Allegrograph: An instance of the Sparql class with attributes populated from the dictionary. """ - return Allegrograph(obj['url'], obj['username'], obj['password'], obj['name']) \ No newline at end of file + return Allegrograph(obj["url"], obj["username"], obj["password"], obj["name"]) From 10161c1bd266d0e917202c6492eeb1d37d9e0f34 Mon Sep 17 00:00:00 2001 From: Roger Mauvois Date: Mon, 16 Feb 2026 14:28:37 +0100 Subject: [PATCH 2/8] Fix legacy Model constructor compatibility --- graphly/models/shacl.py | 2 +- graphly/schema/model.py | 183 +++++++++++++++++++++++++--------------- 2 files changed, 117 insertions(+), 68 deletions(-) diff --git a/graphly/models/shacl.py b/graphly/models/shacl.py index 9a5b9fc..9da6a0c 100644 --- a/graphly/models/shacl.py +++ b/graphly/models/shacl.py @@ -28,7 +28,7 @@ class SHACL(Model): def __init__( self, - sparql: Sparql, + sparql: Sparql | None = None, uri: str = None, prefixes: Prefixes = None, type_property: str = "rdf:type", diff --git a/graphly/schema/model.py b/graphly/schema/model.py index 34b457c..963f117 100644 --- a/graphly/schema/model.py +++ b/graphly/schema/model.py @@ -38,7 +38,15 @@ class Model(Graph): classes: List[Resource] properties: List[Property] - def __init__(self, sparql: Sparql, uri: str = None, prefixes: Prefixes = None, type_property: str = 'rdf:type', label_property: str = "rdfs:label", comment_property: str = "rdfs:comment") -> None: + def __init__( + self, + sparql: Sparql | str | None = None, + uri: str | None = None, + prefixes: Prefixes | str | None = None, + type_property: str = "rdf:type", + label_property: str = "rdfs:label", + comment_property: str = "rdfs:comment", + ) -> None: """ Initialize a Model instance with default or custom property identifiers. @@ -50,10 +58,30 @@ def __init__(self, sparql: Sparql, uri: str = None, prefixes: Prefixes = None, t label_property (str, optional): The property used to define entity labels. Defaults to 'rdfs:label'. comment_property (str, optional): The property used to define entity comments or descriptions. Defaults to 'rdfs:comment'. """ - super().__init__(sparql, uri, prefixes) + legacy_mode = isinstance(sparql, str) or isinstance(prefixes, str) + if legacy_mode: + if isinstance(sparql, str): + type_property = sparql + if isinstance(uri, str): + label_property = uri + if isinstance(prefixes, str): + comment_property = prefixes + + self.sparql = None + self.uri = None + self.prefixes = Prefixes([]) + self.uri_long = None + self.sparql_begin = "" + self.sparql_end = "" + else: + super().__init__(sparql, uri, prefixes) # Set attributes - self.framework_name = "No Framework" if not hasattr(self, 'framework_name') else self.framework_name + self.framework_name = ( + "No Framework" + if not hasattr(self, "framework_name") + else self.framework_name + ) self.type_property = type_property self.label_property = label_property self.comment_property = comment_property @@ -62,7 +90,6 @@ def __init__(self, sparql: Sparql, uri: str = None, prefixes: Prefixes = None, t self.classes = [] self.properties = [] - def update(self) -> None: """ Update the Model by refreshing its classes and properties. @@ -74,20 +101,19 @@ def update(self) -> None: self.classes = self.get_classes() self.properties = self.get_properties() - def get_classes(self) -> List[Resource]: """ Retrieve all distinct classes from the given RDF graph. - Constructs and executes a SPARQL query to identify unique class URIs - based on the Model's `type_property`. Optionally retrieves class labels - using the `label_property`. The results are returned as a list of + Constructs and executes a SPARQL query to identify unique class URIs + based on the Model's `type_property`. Optionally retrieves class labels + using the `label_property`. The results are returned as a list of `Resource` instances, each enriched with a `class_uri` of "owl:Class". Args: Returns: - List[Resource]: A list of `Resource` objects representing the classes + List[Resource]: A list of `Resource` objects representing the classes found in the graph. Returns an empty list if no classes are found. """ # Prepare the query @@ -108,29 +134,32 @@ def get_classes(self) -> List[Resource]: response = self.run(query) # Transform into a list of Resource instances, or an empty list - classes = [Resource.from_dict({**obj, "class_uri": "owl:Class"}) for obj in response] if response else [] + classes = ( + [Resource.from_dict({**obj, "class_uri": "owl:Class"}) for obj in response] + if response + else [] + ) # Add Value classes classes += self.get_value_classes() return classes - def get_properties(self) -> List[Property]: """ Retrieve all distinct properties from the given RDF graph with accurate range types. Constructs and executes a SPARQL query to identify property URIs used in the graph, - excluding the Model's `type_property`, `label_property`, and `comment_property`. - For each property, the method retrieves its label, domain class, and range class. + excluding the Model's `type_property`, `label_property`, and `comment_property`. + For each property, the method retrieves its label, domain class, and range class. If the object of a triple is an IRI, the range is set to the corresponding class URI; if it is a literal, the range is set to the literal's datatype. Args: Returns: - List[Property]: A list of `Property` objects, each containing the property - resource, its domain class (if any), and its range class (if any). + List[Property]: A list of `Property` objects, each containing the property + resource, its domain class (if any), and its range class (if any). Returns an empty list if no properties are found. """ # Prepare the query @@ -154,8 +183,8 @@ def get_properties(self) -> List[Property]: }} """ - if not self.prefixes.has('xsd'): - self.prefixes.add(Prefix('xsd', 'http://www.w3.org/2001/XMLSchema#')) + if not self.prefixes.has("xsd"): + self.prefixes.add(Prefix("xsd", "http://www.w3.org/2001/XMLSchema#")) # Execute the query response = self.run(query) @@ -163,18 +192,17 @@ def get_properties(self) -> List[Property]: # Transform into a list of Property instances, or an empty list properties = [] for resp in response: - domain = self.find_class(resp['domain_class_uri']) - range = self.find_class(resp['range_class_uri']) - properties.append(Property(resp['uri'], resp['label'], "", domain, range)) + domain = self.find_class(resp["domain_class_uri"]) + range = self.find_class(resp["range_class_uri"]) + properties.append(Property(resp["uri"], resp["label"], "", domain, range)) return properties - def find_class(self, class_uri: str) -> Resource | None: """ Find a class in the Model by its URI. - Searches through the Model's `classes` attribute for a class whose + Searches through the Model's `classes` attribute for a class whose `uri` matches the given `class_uri`. Args: @@ -183,10 +211,14 @@ def find_class(self, class_uri: str) -> Resource | None: Returns: Resource | None: The matching `Resource` object if found, otherwise None. """ - return next((klass for klass in self.classes if klass.uri == class_uri), Resource(class_uri)) - - - def find_properties(self, prop_uri: str, domain_class_uri: str = None, range_class_uri: str = None) -> List[Property]: + return next( + (klass for klass in self.classes if klass.uri == class_uri), + Resource(class_uri), + ) + + def find_properties( + self, prop_uri: str, domain_class_uri: str = None, range_class_uri: str = None + ) -> List[Property]: """ Find properties matching the given URI, optionally filtered by domain and/or range. @@ -196,34 +228,43 @@ def find_properties(self, prop_uri: str, domain_class_uri: str = None, range_cla range_class_uri (str, optional): The URI of the range class to filter by. Defaults to None. Returns: - List[Property]: A list of matching properties. If none are found, + List[Property]: A list of matching properties. If none are found, a new Property with the given URI is returned in a list. """ # Narrow down the properties if domain and/or range is provided filtered = self.properties if domain_class_uri: - filtered = [prop for prop in filtered if prop.domain and prop.domain.uri == domain_class_uri] + filtered = [ + prop + for prop in filtered + if prop.domain and prop.domain.uri == domain_class_uri + ] if range_class_uri: - filtered = [prop for prop in filtered if prop.range and prop.range.uri == range_class_uri] - + filtered = [ + prop + for prop in filtered + if prop.range and prop.range.uri == range_class_uri + ] + # Find all properties satisfying the conditions # They can be multiple because some times a class has mutiple times the same property # but with different ranges target = [prop for prop in filtered if prop.uri == prop_uri] - if len(target) == 0: + if len(target) == 0: return [Property(prop_uri)] - else: + else: return target - - def is_prop_mandatory(self, prop_uri: str, card_of_uri: str = None) -> Property | None: + def is_prop_mandatory( + self, prop_uri: str, card_of_uri: str = None + ) -> Property | None: """ Check if a property is mandatory in the Model. Searches the Model's `properties` for a property matching the given URI. - If `card_of_uri` is provided, only considers properties associated with - that specific card. Raises an exception if multiple matching properties + If `card_of_uri` is provided, only considers properties associated with + that specific card. Raises an exception if multiple matching properties are found (should not). Args: @@ -234,48 +275,56 @@ def is_prop_mandatory(self, prop_uri: str, card_of_uri: str = None) -> Property Property | None: The matching `Property` object if found, otherwise None. """ # Select only right properties, and if case of a card_of, select only the one with the right card - selection = [p for p in self.properties if p.uri == prop_uri and (card_of_uri is not None or p.card_of.uri ==card_of_uri)] + selection = [ + p + for p in self.properties + if p.uri == prop_uri + and (card_of_uri is not None or p.card_of.uri == card_of_uri) + ] + + if len(selection) > 1: + raise Exception( + f"Too much properties retrieved for prop_uri = {prop_uri}, and card_or_uri = {card_of_uri}" + ) - if len(selection) > 1: - raise Exception(f'Too much properties retrieved for prop_uri = {prop_uri}, and card_or_uri = {card_of_uri}') - return selection[0] if len(selection) > 0 else None - @staticmethod def get_value_classes() -> List[Resource]: """ Return a predefined list of common XSD and RDF datatype resources. - This static method provides `Resource` instances representing standard - value types, including strings, numbers, booleans, dates, durations, + This static method provides `Resource` instances representing standard + value types, including strings, numbers, booleans, dates, durations, and binary or language types, with their corresponding URIs and labels. Returns: List[Resource]: A list of `Resource` objects for common RDF/XSD datatypes. """ return [ - Resource('xsd:string', 'String', '', 'rdfs:Datatype'), - Resource('xsd:integer', 'Integer', '', 'rdfs:Datatype'), - Resource('xsd:decimal', 'Decimal', '', 'rdfs:Datatype'), - Resource('xsd:float', 'Float', '', 'rdfs:Datatype'), - Resource('xsd:double', 'Double', '', 'rdfs:Datatype'), - Resource('xsd:boolean', 'Boolean', '', 'rdfs:Datatype'), - Resource('xsd:dateTime', 'dateTime', '', 'rdfs:Datatype'), - Resource('xsd:date', 'Date', '', 'rdfs:Datatype'), - Resource('xsd:time', 'Time', '', 'rdfs:Datatype'), - Resource('xsd:gYear', 'G Year', '', 'rdfs:Datatype'), - Resource('xsd:gMonth', 'G Month', '', 'rdfs:Datatype'), - Resource('xsd:gDay', 'G Day', '', 'rdfs:Datatype'), - Resource('xsd:gYearMonth', 'G Year Month', '', 'rdfs:Datatype'), - Resource('xsd:gMonthDay', 'G Month Day', '', 'rdfs:Datatype'), - Resource('xsd:duration', 'Duration', '', 'rdfs:Datatype'), - Resource('xsd:dayTimeDuration', 'Day Time Duration', '', 'rdfs:Datatype'), - Resource('xsd:yearMonthDuration', 'Year Month Duration', '', 'rdfs:Datatype'), - Resource('xsd:hexBinary', 'Hexadecimal Binary', '', 'rdfs:Datatype'), - Resource('xsd:base64Binary', 'Base64 Binary', '', 'rdfs:Datatype'), - Resource('xsd:anyURI', '', 'Any URI', 'rdfs:Datatype'), - Resource('xsd:language', 'Language', '', 'rdfs:Datatype'), - Resource('xsd:langString', 'Language String', '', 'rdfs:Datatype'), - Resource('rdf:HTML', 'HTML', '', 'rdfs:Datatype'), - ] \ No newline at end of file + Resource("xsd:string", "String", "", "rdfs:Datatype"), + Resource("xsd:integer", "Integer", "", "rdfs:Datatype"), + Resource("xsd:decimal", "Decimal", "", "rdfs:Datatype"), + Resource("xsd:float", "Float", "", "rdfs:Datatype"), + Resource("xsd:double", "Double", "", "rdfs:Datatype"), + Resource("xsd:boolean", "Boolean", "", "rdfs:Datatype"), + Resource("xsd:dateTime", "dateTime", "", "rdfs:Datatype"), + Resource("xsd:date", "Date", "", "rdfs:Datatype"), + Resource("xsd:time", "Time", "", "rdfs:Datatype"), + Resource("xsd:gYear", "G Year", "", "rdfs:Datatype"), + Resource("xsd:gMonth", "G Month", "", "rdfs:Datatype"), + Resource("xsd:gDay", "G Day", "", "rdfs:Datatype"), + Resource("xsd:gYearMonth", "G Year Month", "", "rdfs:Datatype"), + Resource("xsd:gMonthDay", "G Month Day", "", "rdfs:Datatype"), + Resource("xsd:duration", "Duration", "", "rdfs:Datatype"), + Resource("xsd:dayTimeDuration", "Day Time Duration", "", "rdfs:Datatype"), + Resource( + "xsd:yearMonthDuration", "Year Month Duration", "", "rdfs:Datatype" + ), + Resource("xsd:hexBinary", "Hexadecimal Binary", "", "rdfs:Datatype"), + Resource("xsd:base64Binary", "Base64 Binary", "", "rdfs:Datatype"), + Resource("xsd:anyURI", "", "Any URI", "rdfs:Datatype"), + Resource("xsd:language", "Language", "", "rdfs:Datatype"), + Resource("xsd:langString", "Language String", "", "rdfs:Datatype"), + Resource("rdf:HTML", "HTML", "", "rdfs:Datatype"), + ] From 5969f17599c4617888426dac8d9d64f1beea4a4d Mon Sep 17 00:00:00 2001 From: Roger Mauvois Date: Mon, 16 Feb 2026 14:54:24 +0100 Subject: [PATCH 3/8] Fix Model.update compatibility with graph/prefixes arguments --- graphly/schema/model.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/graphly/schema/model.py b/graphly/schema/model.py index 963f117..8164a15 100644 --- a/graphly/schema/model.py +++ b/graphly/schema/model.py @@ -90,7 +90,7 @@ def __init__( self.classes = [] self.properties = [] - def update(self) -> None: + def update(self, graph=None, prefixes=None) -> None: """ Update the Model by refreshing its classes and properties. @@ -98,6 +98,27 @@ def update(self) -> None: `get_classes()` and `get_properties()`, and updates the corresponding attributes of the Model. """ + if graph is not None: + if hasattr(graph, "sparql"): + self.sparql = graph.sparql + if hasattr(graph, "uri"): + self.uri = graph.uri + if hasattr(graph, "prefixes"): + self.prefixes = graph.prefixes + if hasattr(graph, "uri_long"): + self.uri_long = graph.uri_long + if hasattr(graph, "sparql_begin"): + self.sparql_begin = graph.sparql_begin + if hasattr(graph, "sparql_end"): + self.sparql_end = graph.sparql_end + if prefixes is not None: + self.prefixes = prefixes + + if getattr(self, "sparql", None) is None: + self.classes = [] + self.properties = [] + return + self.classes = self.get_classes() self.properties = self.get_properties() From dddd4e5705dd0e57688418a9ff68e19188664dd5 Mon Sep 17 00:00:00 2001 From: Roger Mauvois Date: Mon, 16 Feb 2026 15:17:25 +0100 Subject: [PATCH 4/8] Fix Graph.run signature to accept prefixes --- graphly/schema/graph.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphly/schema/graph.py b/graphly/schema/graph.py index e08f024..d568ba4 100644 --- a/graphly/schema/graph.py +++ b/graphly/schema/graph.py @@ -65,7 +65,7 @@ def __init__( ) self.sparql_end = "}" if self.uri else "" - def run(self, text: str) -> List[Dict]: + def run(self, text: str, prefixes: Prefixes = None) -> List[Dict]: """ Executes a SPARQL query on this graph using the associated SPARQL endpoint. @@ -75,7 +75,7 @@ def run(self, text: str) -> List[Dict]: Returns: List[Dict]: The parsed results of the query as a list of dictionaries. """ - return self.sparql.run(text, self.prefixes) + return self.sparql.run(text, self.prefixes if prefixes is None else prefixes) def insert( self, triples: List[tuple[str, str, str]] | tuple[str, str, str] From fb952c39c183acea5b70d941708c58009cffa5f3 Mon Sep 17 00:00:00 2001 From: Roger Mauvois Date: Mon, 16 Feb 2026 15:21:49 +0100 Subject: [PATCH 5/8] Fix Graph.delete to accept prefixes argument --- graphly/schema/graph.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/graphly/schema/graph.py b/graphly/schema/graph.py index d568ba4..4b3564c 100644 --- a/graphly/schema/graph.py +++ b/graphly/schema/graph.py @@ -93,7 +93,9 @@ def insert( self.sparql.insert(triples, self.uri, self.prefixes) def delete( - self, triples: List[tuple[str, str, str]] | tuple[str, str, str] + self, + triples: List[tuple[str, str, str]] | tuple[str, str, str], + prefixes: Prefixes = None, ) -> None: """ Deletes one or more RDF triples from this graph using the associated SPARQL endpoint. @@ -105,7 +107,9 @@ def delete( None """ if len(triples) != 0: - self.sparql.delete(triples, self.uri, self.prefixes) + self.sparql.delete( + triples, self.uri, self.prefixes if prefixes is None else prefixes + ) def dump_dict(self) -> list[dict]: """ From 07aa16e47541dca04e0a8fac82a329bd79937e17 Mon Sep 17 00:00:00 2001 From: Roger Mauvois Date: Mon, 16 Feb 2026 15:28:15 +0100 Subject: [PATCH 6/8] Restore Logre API compatibility (Model/Graph signatures) --- graphly/schema/graph.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/graphly/schema/graph.py b/graphly/schema/graph.py index 4b3564c..7aa182f 100644 --- a/graphly/schema/graph.py +++ b/graphly/schema/graph.py @@ -78,7 +78,9 @@ def run(self, text: str, prefixes: Prefixes = None) -> List[Dict]: return self.sparql.run(text, self.prefixes if prefixes is None else prefixes) def insert( - self, triples: List[tuple[str, str, str]] | tuple[str, str, str] + self, + triples: List[tuple[str, str, str]] | tuple[str, str, str], + prefixes: Prefixes = None, ) -> None: """ Inserts one or more RDF triples into this graph using the associated SPARQL endpoint. @@ -90,7 +92,9 @@ def insert( None """ if len(triples) != 0: - self.sparql.insert(triples, self.uri, self.prefixes) + self.sparql.insert( + triples, self.uri, self.prefixes if prefixes is None else prefixes + ) def delete( self, From 80494abf55d71cde408f68f45bb115c893f072b3 Mon Sep 17 00:00:00 2001 From: Roger Mauvois Date: Mon, 9 Mar 2026 10:26:09 +0100 Subject: [PATCH 7/8] fix(sparql): prevent invalid URI shortening to malformed prefixed names --- graphly/models/shacl.py | 44 +++++++---------- graphly/schema/prefix.py | 63 ++++++++++++++++-------- tests/test_prefix_shorten.py | 37 ++++++++++++++ tests/test_sparql_response_shortening.py | 38 ++++++++++++++ 4 files changed, 135 insertions(+), 47 deletions(-) create mode 100644 tests/test_prefix_shorten.py create mode 100644 tests/test_sparql_response_shortening.py diff --git a/graphly/models/shacl.py b/graphly/models/shacl.py index 9da6a0c..3f73e06 100644 --- a/graphly/models/shacl.py +++ b/graphly/models/shacl.py @@ -154,37 +154,29 @@ def get_properties(self) -> List[Property]: # Transform into a list of Property instances, or an empty list properties = [] for resp in response: - # Get domain and range - try: - domain = self.find_class(resp["domain_class_uri"]) - range_uri = resp.get("range_class_uri") - range_datatype = resp.get("range_datatype") - range_target = range_uri or range_datatype - range = self.find_class(range_target) if range_target else None - except: - raise Exception( - "Graphly was not able to get domain class or range class for the following property: " - + resp["uri"] - + " - " - + resp["label"] - ) + uri = resp.get("uri") + if not uri: + continue - # Get the class from which the property belongs - try: - card_of = self.find_class(resp["card_of_class_uri"]) - except: - raise Exception( - "Graphly was not able to get concerned class (card of class URI) for the following property: " - + resp["uri"] - + " - " - + resp["label"] - ) + domain_class_uri = resp.get("domain_class_uri") or "" + range_class_uri = resp.get("range_class_uri") or "" + range_datatype = resp.get("range_datatype") or "" + range_target = range_class_uri or range_datatype + + domain = self.find_class(domain_class_uri) if domain_class_uri else None + range = self.find_class(range_target) if range_target else None + + card_of_class_uri = resp.get("card_of_class_uri") or "" + card_of = ( + self.find_class(card_of_class_uri) if card_of_class_uri else domain + ) # Create and add a new property + label = resp.get("label") or uri properties.append( Property( - resp["uri"], - resp["label"], + uri, + label, "", domain, range, diff --git a/graphly/schema/prefix.py b/graphly/schema/prefix.py index 14868a0..c4193df 100644 --- a/graphly/schema/prefix.py +++ b/graphly/schema/prefix.py @@ -1,3 +1,22 @@ +import re + + +_PNAME_LOCAL_SAFE_PATTERN = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]*$") + + +def is_valid_sparql_pname_local(local: str) -> bool: + """ + Validate a conservative safe subset of SPARQL prefixed-name local parts. + + This intentionally rejects characters that commonly break query parsing + (for example `/`), and only accepts ASCII alphanumerics plus `_`, `-`, `.`. + """ + if not local: + return False + if "/" in local: + return False + return _PNAME_LOCAL_SAFE_PATTERN.fullmatch(local) is not None + class Prefix: """ @@ -27,7 +46,6 @@ class Prefix: short: str long: str - def __init__(self, short: str, url: str) -> None: """ Initializes a Prefix instance that maps a short prefix to a full URL. @@ -39,7 +57,6 @@ def __init__(self, short: str, url: str) -> None: self.short = short self.long = url - def to_sparql(self) -> str: """ Generates the SPARQL representation of the prefix. @@ -48,7 +65,6 @@ def to_sparql(self) -> str: str: A string in the format 'PREFIX short: '. """ return f"PREFIX {self.short}: <{self.long}>" - def to_turtle(self) -> str: """ @@ -59,23 +75,33 @@ def to_turtle(self) -> str: """ return f"@prefix {self.short}: <{self.long}> ." - def shorten(self, uri: str) -> str: """ - Shortens a full URI using the prefix, if the URI starts with the prefix's long URL. + Shortens a full URI using the prefix when it is SPARQL-safe. + + The URI is shortened only when: + - the URI starts with this prefix long value, and + - the resulting local part matches a conservative SPARQL-safe subset. + + If not, the full URI is returned unchanged. Parameters: uri (str): The full URI to be shortened. Returns: - str: The URI with the prefix replaced by its short form, or the original URI if the prefix does not match. + str: A prefixed name (`prefix:local`) or the original full URI. """ - if self.long in uri: - if uri.startswith('<'): uri = uri[1:] - if uri.endswith('>'): uri = uri[:-1] - return uri.replace(self.long, self.short + ':') - return uri - + if uri.startswith("<") and uri.endswith(">"): + uri = uri[1:-1] + + if not uri.startswith(self.long): + return uri + + local = uri[len(self.long) :] + if not is_valid_sparql_pname_local(local): + return uri + + return f"{self.short}:{local}" def lengthen(self, short: str) -> str: """ @@ -87,8 +113,7 @@ def lengthen(self, short: str) -> str: Returns: str: The full URI with the short prefix replaced by the long URL. """ - return str(short).replace(self.short + ':', self.long) - + return str(short).replace(self.short + ":", self.long) def to_dict(self) -> dict[str, str]: """ @@ -97,14 +122,10 @@ def to_dict(self) -> dict[str, str]: Returns: dict[str, str]: A dictionary with keys 'short' and 'long' representing the prefix abbreviation and full URL. """ - return { - "short": self.short, - "long": self.long - } - + return {"short": self.short, "long": self.long} @staticmethod - def from_dict(obj: dict[str, str]) -> 'Prefix': + def from_dict(obj: dict[str, str]) -> "Prefix": """ Creates a Prefix instance from a dictionary representation. @@ -114,4 +135,4 @@ def from_dict(obj: dict[str, str]) -> 'Prefix': Returns: Prefix: An instance of the Prefix class with attributes populated from the dictionary. """ - return Prefix(obj['short'], obj['long']) \ No newline at end of file + return Prefix(obj["short"], obj["long"]) diff --git a/tests/test_prefix_shorten.py b/tests/test_prefix_shorten.py new file mode 100644 index 0000000..2d273e8 --- /dev/null +++ b/tests/test_prefix_shorten.py @@ -0,0 +1,37 @@ +import unittest + +from graphly.schema.prefix import Prefix + + +class TestPrefixShorten(unittest.TestCase): + def test_shorten_valid_local_part(self): + prefix = Prefix("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#") + self.assertEqual( + prefix.shorten("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + "rdf:type", + ) + + def test_shorten_invalid_local_part_with_slash_keeps_full_uri(self): + prefix = Prefix("base", "https://lod4hss.org/resource/") + uri = "https://lod4hss.org/resource/type/quantifiable-quality/capacite-lits" + self.assertEqual(prefix.shorten(uri), uri) + + def test_shorten_bracketed_uri_still_shortens_when_valid(self): + prefix = Prefix("xsd", "http://www.w3.org/2001/XMLSchema#") + self.assertEqual( + prefix.shorten(""), + "xsd:string", + ) + + def test_shorten_does_not_replace_when_prefix_is_only_contained(self): + prefix = Prefix("base", "https://lod4hss.org/resource/") + uri = "https://example.org/redirect?target=https://lod4hss.org/resource/type" + self.assertEqual(prefix.shorten(uri), uri) + + def test_shorten_prefixed_value_stays_stable(self): + prefix = Prefix("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#") + self.assertEqual(prefix.shorten("rdf:type"), "rdf:type") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sparql_response_shortening.py b/tests/test_sparql_response_shortening.py new file mode 100644 index 0000000..929ae0b --- /dev/null +++ b/tests/test_sparql_response_shortening.py @@ -0,0 +1,38 @@ +import unittest + +from graphly.schema.prefix import Prefix +from graphly.schema.prefixes import Prefixes +from graphly.schema.sparql import parse_sparql_json_response +from graphly.tools.uri import prepare + + +class TestSparqlResponseShortening(unittest.TestCase): + def test_parse_response_keeps_full_uri_when_local_part_is_not_sparql_safe(self): + prefixes = Prefixes([Prefix("base", "https://lod4hss.org/resource/")]) + response = { + "results": { + "bindings": [ + { + "uri": { + "type": "uri", + "value": "https://lod4hss.org/resource/type/quantifiable-quality/capacite-lits", + } + } + ] + } + } + + parsed = parse_sparql_json_response(response, prefixes) + + self.assertEqual( + parsed[0]["uri"], + "https://lod4hss.org/resource/type/quantifiable-quality/capacite-lits", + ) + self.assertEqual( + prepare(parsed[0]["uri"], prefixes.shorts()), + "", + ) + + +if __name__ == "__main__": + unittest.main() From 27c80c7c423a55c323f1ed0f158d17333a12d69e Mon Sep 17 00:00:00 2001 From: Roger Mauvois Date: Fri, 13 Mar 2026 14:29:23 +0100 Subject: [PATCH 8/8] robustify SPARQL PREFIX prologue parsing and isolate Prefixes instance state --- graphly/schema/prefixes.py | 29 ++++++------- graphly/tools/query.py | 57 ++++++++++++++++++------- tests/test_prefixes_isolation.py | 28 +++++++++++++ tests/test_query_type_routing.py | 71 ++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 33 deletions(-) create mode 100644 tests/test_prefixes_isolation.py create mode 100644 tests/test_query_type_routing.py diff --git a/graphly/schema/prefixes.py b/graphly/schema/prefixes.py index 310279e..47b9dcf 100644 --- a/graphly/schema/prefixes.py +++ b/graphly/schema/prefixes.py @@ -1,4 +1,4 @@ -from typing import Iterator, List +from typing import Iterator, List, Optional from graphly.schema.prefix import Prefix @@ -32,16 +32,14 @@ class Prefixes: prefix_list: List[Prefix] - - def __init__(self, prefix_list: List[Prefix] = []) -> None: + def __init__(self, prefix_list: Optional[List[Prefix]] = None) -> None: """ Initializes a Prefixes container holding a list of Prefix instances. Parameters: - prefix_list (List[Prefix], optional): A list of Prefix objects to initialize the container. Defaults to an empty list. + prefix_list (Optional[List[Prefix]], optional): A list of Prefix objects to initialize the container. """ - self.prefix_list = prefix_list - + self.prefix_list = list(prefix_list) if prefix_list is not None else [] def has(self, short: str) -> bool: """ @@ -58,7 +56,6 @@ def has(self, short: str) -> bool: return True return False - def shorten(self, uri: str) -> str: """ Shortens a full URI using the right prefix in the container. @@ -73,8 +70,7 @@ def shorten(self, uri: str) -> str: for p in self.prefix_list: to_return = p.shorten(to_return) return to_return - - + def lengthen(self, uri: str) -> str: """ Expands a shortened URI using the right prefix in the container to its full forms. @@ -90,7 +86,6 @@ def lengthen(self, uri: str) -> str: to_return = p.lengthen(to_return) return to_return - def add(self, prefix: Prefix) -> None: """ Adds a new Prefix instance to the container. @@ -100,7 +95,6 @@ def add(self, prefix: Prefix) -> None: """ self.prefix_list.append(prefix) - def remove(self, prefix: Prefix) -> None: """ Remove a prefix from the prefix list by its attributes. @@ -112,8 +106,11 @@ def remove(self, prefix: Prefix) -> None: Returns: None """ - self.prefix_list = [p for p in self.prefix_list if p.short != prefix.short and p.long != prefix.long] - + self.prefix_list = [ + p + for p in self.prefix_list + if p.short != prefix.short and p.long != prefix.long + ] def find(self, short: str) -> Prefix | None: """ @@ -129,11 +126,10 @@ def find(self, short: str) -> Prefix | None: if p.short == short: return p return None - + def shorts(self) -> List[str]: return [p.short for p in self.prefix_list] - def __len__(self) -> int: """ Returns the number of Prefix instances in the container. @@ -143,7 +139,6 @@ def __len__(self) -> int: """ return len(self.prefix_list) - def __iter__(self) -> Iterator[Prefix]: """ Returns an iterator over the Prefix instances in the container. @@ -151,4 +146,4 @@ def __iter__(self) -> Iterator[Prefix]: Returns: Iterator[Prefix]: An iterator for traversing the list of Prefix objects. """ - return iter(self.prefix_list) \ No newline at end of file + return iter(self.prefix_list) diff --git a/graphly/tools/query.py b/graphly/tools/query.py index f352c37..c33e83f 100644 --- a/graphly/tools/query.py +++ b/graphly/tools/query.py @@ -1,8 +1,17 @@ -from typing import Literal +from typing import Literal, cast import re -def get_sparql_type(query: str) -> Literal['SELECT', 'CONSTRUCT', 'INSERT', 'DELETE', 'CLEAR', 'OTHER']: +_SUPPORTED_QUERY_TYPES = {"SELECT", "CONSTRUCT", "INSERT", "DELETE", "CLEAR"} +_PREFIX_DECLARATION_PATTERN = re.compile( + r"^PREFIX\s+(?:[-\w.]+)?\s*:\s*<[^>]*>\s*", + flags=re.IGNORECASE, +) + + +def get_sparql_type( + query: str, +) -> Literal["SELECT", "CONSTRUCT", "INSERT", "DELETE", "CLEAR", "OTHER"]: """ Determine the type of a SPARQL query. @@ -23,20 +32,36 @@ def get_sparql_type(query: str) -> Literal['SELECT', 'CONSTRUCT', 'INSERT', 'DEL Returns: str: The type of the SPARQL query ("SELECT", "INSERT", "DELETE", "CLEAR", or "OTHER"). """ - # Remove leading whitespace - q = query.lstrip() - - # Remove comments - q = '\n'.join([line for line in q.split('\n') if not line.strip().startswith('#')]) - - # Skip PREFIX declarations - q = re.sub(r'^(?:\s*PREFIX\s+\w*:\s*<[^>]*>\s*)*', '', q, flags=re.IGNORECASE) - + q = query + + # Skip prologue pieces until the first operation keyword: + # - whitespace / empty lines + # - line comments + # - PREFIX declarations + while True: + q = q.lstrip() + if not q: + return "OTHER" + + if q.startswith("#"): + newline_index = q.find("\n") + if newline_index == -1: + return "OTHER" + q = q[newline_index + 1 :] + continue + + prefix_match = _PREFIX_DECLARATION_PATTERN.match(q) + if prefix_match: + q = q[prefix_match.end() :] + continue + + break + # Get the first keyword - first_word_match = re.match(r'^\s*(\w+)', q, flags=re.IGNORECASE) + first_word_match = re.match(r"^([A-Za-z]+)", q, flags=re.IGNORECASE) if first_word_match: kw = first_word_match.group(1).upper() - if kw in ["SELECT", "CONSTRUCT", "INSERT", "DELETE", "CLEAR"]: - return kw - - return "OTHER" \ No newline at end of file + if kw in _SUPPORTED_QUERY_TYPES: + return cast(Literal["SELECT", "CONSTRUCT", "INSERT", "DELETE", "CLEAR"], kw) + + return "OTHER" diff --git a/tests/test_prefixes_isolation.py b/tests/test_prefixes_isolation.py new file mode 100644 index 0000000..02e76a5 --- /dev/null +++ b/tests/test_prefixes_isolation.py @@ -0,0 +1,28 @@ +import unittest + +from graphly.schema.prefix import Prefix +from graphly.schema.prefixes import Prefixes + + +class TestPrefixesIsolation(unittest.TestCase): + def test_default_constructor_does_not_share_state(self): + first = Prefixes() + second = Prefixes() + + first.add(Prefix("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#")) + + self.assertEqual(len(first), 1) + self.assertEqual(len(second), 0) + self.assertIsNot(first.prefix_list, second.prefix_list) + + def test_constructor_copies_input_list(self): + source = [Prefix("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#")] + prefixes = Prefixes(source) + + source.append(Prefix("xsd", "http://www.w3.org/2001/XMLSchema#")) + + self.assertEqual(len(prefixes), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_query_type_routing.py b/tests/test_query_type_routing.py new file mode 100644 index 0000000..0de4571 --- /dev/null +++ b/tests/test_query_type_routing.py @@ -0,0 +1,71 @@ +import unittest +from unittest.mock import patch + +from graphly.sparql.fuseki import Fuseki +from graphly.sparql.graphdb import GraphDB +from graphly.tools.query import get_sparql_type + + +class TestGetSparqlType(unittest.TestCase): + def test_select_without_prefix(self): + query = "SELECT * WHERE { ?s ?p ?o }" + self.assertEqual(get_sparql_type(query), "SELECT") + + def test_select_with_standard_prefix(self): + query = ( + "PREFIX rdf: \n" + "SELECT * WHERE { ?s ?p ?o }" + ) + self.assertEqual(get_sparql_type(query), "SELECT") + + def test_select_with_hyphenated_prefix(self): + query = ( + "PREFIX crm-sup: \n" + "SELECT * WHERE { ?s ?p ?o }" + ) + self.assertEqual(get_sparql_type(query), "SELECT") + + def test_select_with_comment_prefix_spacing_and_case(self): + query = ( + " # leading comment\n" + "\n" + " prefix crm-sup: \n" + "\tSeLeCt * WHERE { ?s ?p ?o }" + ) + self.assertEqual(get_sparql_type(query), "SELECT") + + +class TestEndpointRouting(unittest.TestCase): + def test_graphdb_routes_select_with_hyphenated_prefix_to_query(self): + endpoint = GraphDB("http://example.org/sparql", "", "") + query = ( + "PREFIX crm-sup: \n" + "SELECT * WHERE { ?s ?p ?o }" + ) + + with patch("graphly.schema.sparql.Sparql.run", return_value=[]) as mock_run: + endpoint.run(query) + + self.assertEqual(mock_run.call_count, 1) + self.assertEqual(mock_run.call_args[0][2], "query") + self.assertEqual(mock_run.call_args[0][3], "") + self.assertTrue(mock_run.call_args[0][4]) + + def test_fuseki_routes_select_with_hyphenated_prefix_to_query(self): + endpoint = Fuseki("http://example.org/sparql", "", "") + query = ( + "PREFIX crm-sup: \n" + "SELECT * WHERE { ?s ?p ?o }" + ) + + with patch("graphly.schema.sparql.Sparql.run", return_value=[]) as mock_run: + endpoint.run(query) + + self.assertEqual(mock_run.call_count, 1) + self.assertEqual(mock_run.call_args[0][2], "query") + self.assertEqual(mock_run.call_args[0][3], "") + self.assertTrue(mock_run.call_args.kwargs["parse_response"]) + + +if __name__ == "__main__": + unittest.main()