diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f31a338..c41908c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,9 +13,9 @@ jobs: env: GRAPHDB_URL: http://localhost:7200 - GRAPHDB_REPOSITORY: test-repo GRAPHDB_USERNAME: admin GRAPHDB_PASSWORD: root + GRAPHDB_TEST_REPOSITORY: test-repo services: graphdb: @@ -58,14 +58,14 @@ jobs: - name: Create test repository run: | - cat > repo-config.ttl << 'EOF' + cat > repo-config.ttl << EOF @prefix rep: . @prefix sr: . @prefix sail: . @prefix owlim: . [] a rep:Repository ; - rep:repositoryID "test-repo" ; + rep:repositoryID "${GRAPHDB_TEST_REPOSITORY}" ; rdfs:label "Test Repository" ; rep:repositoryImpl [ rep:repositoryType "graphdb:SailRepository"; diff --git a/graph_db_interface/__init__.py b/graph_db_interface/__init__.py index c44e6fa..97dffdb 100644 --- a/graph_db_interface/__init__.py +++ b/graph_db_interface/__init__.py @@ -1,17 +1,22 @@ from .sparql_query import SPARQLQuery from .graph_db import GraphDB from .utils.graph_db_credentials import GraphDBCredentials +from .utils.iri import IRI from .utils.utils import to_literal from .utils.processing import process_bindings_select from .utils.pretty_print import format_result from .kafka.kafka_manager import KafkaManager +from .utils.xsd_typemap import XSDToPythonTypes, XSDToPythonMapper __all__ = [ "GraphDB", "GraphDBCredentials", + "IRI", "SPARQLQuery", "to_literal", "process_bindings_select", "format_result", "KafkaManager", + "XSDToPythonTypes", + "XSDToPythonMapper", ] diff --git a/graph_db_interface/exceptions.py b/graph_db_interface/exceptions.py index 08d34af..2f3cfa8 100644 --- a/graph_db_interface/exceptions.py +++ b/graph_db_interface/exceptions.py @@ -1,52 +1,105 @@ class GraphDBInterfaceError(Exception): - """Base class for exceptions in this module.""" + """ + Base exception for the GraphDB interface. + + Serves as the common ancestor for all custom exceptions in this + package to allow catching them collectively when desired. + """ pass class InvalidRepositoryError(GraphDBInterfaceError): - """Exception raised for invalid repository.""" + """ + Invalid repository configuration or selection. + + Args: + message (str): Explanation of why the repository is invalid. + """ - def __init__(self, message: str): + def __init__( + self, + message: str, + ): self.message = message super().__init__(self.message) class AuthenticationError(GraphDBInterfaceError): - """Exception raised for authentication errors.""" + """ + Authentication failure when communicating with GraphDB. + + Args: + message (str): Details about the authentication error. + """ - def __init__(self, message: str): + def __init__( + self, + message: str, + ): self.message = message super().__init__(self.message) class InvalidQueryError(GraphDBInterfaceError): - """Exception raised for invalid SPARQL queries.""" + """ + Invalid SPARQL query or update string. - def __init__(self, message: str): + Args: + message (str): Description of why the query is invalid. + """ + + def __init__( + self, + message: str, + ): self.message = message super().__init__(self.message) class InvalidInputError(GraphDBInterfaceError): - """Exception raised for invalid input.""" + """ + Invalid input provided to an interface method. + + Args: + message (str): Explanation of the invalid input condition. + """ - def __init__(self, message: str): + def __init__( + self, + message: str, + ): self.message = message super().__init__(self.message) class InvalidIRIError(GraphDBInterfaceError): - """Exception raised for invalid IRIs.""" + """ + Invalid IRI value or format encountered. - def __init__(self, message: str): + Args: + message (str): Description of the IRI validation failure. + """ + + def __init__( + self, + message: str, + ): self.message = message super().__init__(self.message) - - + + class GraphDbException(GraphDBInterfaceError): - """Exception raised for general GraphDB errors.""" - - def __init__(self, message: str): + """ + General error raised for HTTP or runtime issues with GraphDB. + + Args: + message (str): The error message returned or constructed for the failure. + """ + + def __init__( + self, + message: str, + ): self.message = message super().__init__(self.message) diff --git a/graph_db_interface/graph_db.py b/graph_db_interface/graph_db.py index 5f6f549..aefd197 100644 --- a/graph_db_interface/graph_db.py +++ b/graph_db_interface/graph_db.py @@ -1,28 +1,38 @@ +from __future__ import annotations + from base64 import b64encode from typing import List, Union, Optional, Dict from graph_db_interface.kafka.kafka_manager import KafkaManager import requests import logging -import os from requests import Response -from graph_db_interface.utils import utils from graph_db_interface.utils.graph_db_credentials import GraphDBCredentials +from graph_db_interface.utils.iri import IRI +from graph_db_interface.utils.types import GraphNameLike, GraphName +from graph_db_interface.sparql_query import SPARQLQuery from graph_db_interface.exceptions import ( InvalidRepositoryError, AuthenticationError, GraphDbException, ) +from graph_db_interface.utils.utils import convert_multi_bindings_to_python_type class GraphDB: - """A GraphDB interface that abstracts SPARQL queries and provides a small set of commonly needed queries.""" + """ + High-level client for GraphDB repositories. + + Provides convenience methods for querying and updating data, manages + authentication, repository selection, a default named graph, and a + Kafka connector manager. + """ def __init__( self, credentials: GraphDBCredentials, timeout: int = 60, use_gdb_token: bool = True, - named_graph: Optional[str] = None, + named_graph: Optional[GraphNameLike] = None, logger: Optional[logging.Logger] = None, ): if logger is None: @@ -32,6 +42,7 @@ def __init__( self._credentials = credentials self._timeout = timeout self._auth = None + self._blank_ids = set() if use_gdb_token: self._auth = self._get_authentication_token( @@ -47,12 +58,6 @@ def __init__( self.repository = credentials.repository - self._prefixes = {} - self.add_prefix("owl", "") - self.add_prefix("rdf", "") - self.add_prefix("rdfs", "") - self.add_prefix("onto", "") - self.named_graph = named_graph self.kafka_manager = KafkaManager(db=self) @@ -61,7 +66,16 @@ def __init__( ) @classmethod - def from_env(cls, logger: Optional[logging.Logger] = None) -> "GraphDB": + def from_env( + cls, + logger: Optional[logging.Logger] = None, + ) -> GraphDB: + """ + Construct a client using environment-based credentials. + + Returns: + GraphDB: A configured `GraphDB` instance using `GraphDBCredentials.from_env()`. + """ return cls(credentials=GraphDBCredentials.from_env(), logger=logger) from graph_db_interface.queries.named_graph import ( @@ -81,49 +95,91 @@ def from_env(cls, logger: Optional[logging.Logger] = None) -> "GraphDB": ) from graph_db_interface.queries.triple_multi import ( triples_get, + any_triple_exists, + all_triple_exists, triples_add, triples_delete, triples_update, ) from graph_db_interface.queries.ontology_helpers import ( iri_exists, + new_iri, + new_blank_id, is_subclass, owl_is_named_individual, owl_get_classes_of_individual, ) @property - def repository(self): - """The currently selected respository in the Graph DB instance.""" + def repository(self) -> str: + """ + The currently selected repository identifier. + + Returns: + str: The active repository id. + """ return self._repository @repository.setter - def repository(self, value: str): + def repository( + self, + value: str, + ): self._repository = self._validate_repository(value) @property - def named_graph(self): - """The currently selected named graph in the Graph DB instance.""" + def named_graph(self) -> Optional[GraphName]: + """ + The currently selected default named graph. + + Returns: + Optional[IRI]: The default named graph as an `IRI`, or `None` if unset. + """ return self._named_graph @named_graph.setter - def named_graph(self, value: Optional[str]): - if value is not None: - if utils.strip_angle_brackets(value) not in self.get_list_of_named_graphs(): - self.logger.warning( - f"Passed named graph {value} does not exist in the repository." - ) - self._named_graph = utils.ensure_absolute(value) - else: + def named_graph( + self, + value: Optional[GraphNameLike], + ): + if value is None: self._named_graph = None + return + + value = IRI(value) + + if value not in self.get_list_of_named_graphs(): + self.logger.warning( + f"Passed named graph {value} does not exist in the repository." + ) + self._named_graph = value + + @property + def named_graph_str(self) -> Optional[str]: + """ + The selected default named graph as a string. + + Returns: + Optional[str]: The IRI string of the default named graph, or `None`. + """ + if self._named_graph is None: + return None + return str(self._named_graph) def get_list_of_repositories( - self, only_ids: bool = False + self, + only_ids: Optional[bool] = False, ) -> Union[List[str], List[dict], None]: - """Get a list of all existing repositories on the GraphDB instance. + """ + List repositories available on the GraphDB instance. + + Args: + only_ids (Optional[bool]): When True, return only the repository ids. When False, + return the full repository descriptor objects. Defaults to False. Returns: - Optional[List[str]]: Returns a list of repository ids. + Union[List[str], List[dict], None]: A list of ids if `only_ids=True`, a list of + repository descriptors otherwise, or `None` when the request fails. """ response = self._make_request("get", "rest/repositories") @@ -138,8 +194,22 @@ def get_list_of_repositories( ) return None - def _validate_repository(self, repository: str) -> str: - """Validates if the repository is part of the RepositoryNames enum.""" + def _validate_repository( + self, + repository: str, + ) -> str: + """ + Validate that the repository exists on the server. + + Args: + repository (str): The repository identifier to validate. + + Returns: + str: The validated repository identifier. + + Raises: + InvalidRepositoryError: If the repository is not available. + """ if repository not in self._repositories: raise InvalidRepositoryError( "Invalid repository name. Allowed values are:" @@ -148,8 +218,24 @@ def _validate_repository(self, repository: str) -> str: return repository def _make_request( - self, method: str, endpoint: str, timeout: int = None, **kwargs + self, + method: str, + endpoint: str, + timeout: Optional[int] = None, + **kwargs, ) -> Response: + """ + Perform an authenticated HTTP request to the GraphDB REST API. + + Args: + method (str): The HTTP method (e.g., "get", "post"). + endpoint (str): The REST endpoint path relative to `base_url`. + timeout (Optional[int]): Request timeout in seconds; defaults to the client setting. + **kwargs: Additional arguments forwarded to `requests`. + + Returns: + Response: The `requests.Response` object. + """ timeout = timeout if timeout is not None else self._timeout headers = kwargs.pop("headers", {}) @@ -164,18 +250,23 @@ def _make_request( **kwargs, ) - def _get_authentication_token(self, username: str, password: str) -> str: - """Obtain a GDB authentication token given your username and your password + def _get_authentication_token( + self, + username: str, + password: str, + ) -> str: + """ + Obtain a GraphDB authentication token. Args: - username (str): username of your GraphDB account - password (str): password of your GraphDB account - - Raises: - ValueError: raised when no token could be successfully obtained + username (str): The GraphDB username. + password (str): The GraphDB password. Returns: - str: gdb token + str: The `Authorization` header value (e.g., `Bearer ...`). + + Raises: + AuthenticationError: If a token cannot be obtained with the provided credentials. """ payload = { "username": username, @@ -193,50 +284,37 @@ def _get_authentication_token(self, username: str, password: str) -> str: " Please make sure, that your provided credentials are valid." ) - def _get_prefix_string(self) -> str: - return ( - "\n".join( - f"PREFIX {prefix}: {iri}" for prefix, iri in self._prefixes.items() - ) - + "\n" - ) - - def _named_graph_string(self, named_graph: str = None) -> str: - if named_graph: - return f"GRAPH {named_graph}" - - return "" - - def add_prefix(self, prefix: str, iri: str): - self._prefixes[prefix] = utils.ensure_absolute(iri) - - def remove_prefix(self, prefix: str) -> bool: - if prefix in self._prefixes: - del self._prefixes[prefix] - return True - return False - - def get_prefixes(self) -> Dict[str, str]: - return self._prefixes - def query( self, - query: str, - update: bool = False, + query: Union[SPARQLQuery, str], + update: Optional[bool] = False, + convert_bindings: Optional[bool] = False, ) -> Optional[Union[Dict, bool]]: """ - Executes a SPARQL query or update operation on the GraphDB repository. + Execute a SPARQL query or update against the repository. + Args: - query (str): The SPARQL query or update string to be executed. - update (bool, optional): Indicates whether the query is an update operation. + query (Union[SPARQLQuery, str]): The SPARQL query/update string to execute. + update (Optional[bool]): If True, perform an update; otherwise perform a read query. Defaults to False. + convert_bindings (Optional[bool]): Whether to convert query result bindings to Python types. + Defaults to True. + Returns: - Optional[Union[Dict, bool]]: - - If `update` is False, returns the query result as a dictionary (parsed JSON). - - If `update` is True, returns True if the update was successful. - - Returns None if the query fails and `update` is False. - - Returns False if the update fails and `update` is True. + Optional[Union[Dict, bool]]: If `update` is False, the parsed JSON result dict. + If `update` is True, `True` on success. Returns `None` or `False` only when + failures are handled upstream; otherwise an exception is raised. + + Raises: + TypeError: If the `query` parameter is neither a `SPARQLQuery` nor a string. + InvalidQueryError: If the SPARQL query is malformed. + GraphDbException: If the HTTP request succeeds but the GraphDB API returns an error status. """ + if isinstance(query, SPARQLQuery): + query = query.to_string() + elif not isinstance(query, str): + raise TypeError("Query must be a SPARQLQuery or a string.") + endpoint = f"repositories/{self._repository}" headers = { "Content-Type": "application/sparql-query", @@ -257,4 +335,22 @@ def query( f"Error while querying GraphDB ({status_code}) - {response.text}" ) - return True if update else response.json() + self.logger.debug( + f'Query\n"""\n{query}\n"""\nReturned\n{"Update successful (200)" if update else response.json()}' + ) + + if update: + return True + + response = response.json() + + if ( + convert_bindings + and "results" in response + and "bindings" in response["results"] + ): + bindings = response["results"]["bindings"] + converted_bindings = convert_multi_bindings_to_python_type(bindings) + response["results"]["bindings"] = converted_bindings + + return response diff --git a/graph_db_interface/kafka/kafka_manager.py b/graph_db_interface/kafka/kafka_manager.py index d1f5be1..7662571 100644 --- a/graph_db_interface/kafka/kafka_manager.py +++ b/graph_db_interface/kafka/kafka_manager.py @@ -9,64 +9,63 @@ class KafkaManager: """ - A manager for Kafka connectors in GraphDB. + Manage GraphDB Kafka connectors via SPARQL. + + Provides helpers to list, inspect, create, and drop Kafka connectors stored in + GraphDB following Ontotext's connector ontology. Reference: - https://graphdb.ontotext.com/documentation/11.1/kafka-graphdb-connector.html + https://graphdb.ontotext.com/documentation/11.1/kafka-graphdb-connector.html """ - def __init__(self, db: "GraphDB"): + def __init__( + self, + db: "GraphDB", + ): self.db = db - self.db.add_prefix("kafka", "") - self.db.add_prefix( - "kafka-inst", "" - ) self.logger = logging.getLogger(self.__class__.__name__) self.logger.info("KafkaManager initialized") def get_existing_connector_ids(self) -> List[str]: """ - Get the IDs of existing Kafka connectors from the graph database. + Get the IDs of existing Kafka connectors. - This method queries the graph database using SPARQL to retrieve all connector - IDs that are registered in the system. It constructs a SELECT query that looks - for resources with the kafka:listConnectors predicate. - - Args: - None + Queries the graph database for resources with the `kafka:listConnectors` predicate. Returns: - List[str]: A list of connector ID strings. Returns an empty list if no - connectors are found in the database. + List[str]: Connector IDs; empty when none are found. + + Raises: + GraphDbException: If the underlying query execution fails. """ - query = SPARQLQuery(prefixes=self.db.get_prefixes()) - query.add_select_block( + query = SPARQLQuery.select( variables=["?cntUri", "?cntStr"], where_clauses=["?cntUri kafka:listConnectors ?cntStr ."], + prefixes=self.db.get_prefixes(), ) - query_string = query.to_string(validate=True) - results = self.db.query(query=query_string) + results = self.db.query(query=query) return [res["cntStr"]["value"] for res in results["results"]["bindings"]] def get_status_of_connectors( - self, id: Optional[str] = None + self, + id: Optional[str] = None, ) -> Optional[Dict[str, Dict]]: """ - Get the status of Kafka connectors from the graph database. + Get the status of Kafka connectors. - This method queries the graph database for connector status information. It can retrieve - the status of all connectors or a specific connector if an ID is provided. + When `id` is provided, returns the status of that connector; otherwise returns + statuses for all connectors. Args: - id (Optional[str], optional): The ID of a specific connector to query. If None, - retrieves the status of all connectors. Defaults to None. + id (Optional[str]): Connector id to filter by. Defaults to None. Returns: - Optional[Dict[str, Dict]]: A dictionary mapping connector names to their status - information. Returns None if no connectors are found or the query returns no results. + Optional[Dict[str, Dict]]: Mapping of connector name to status; `None` when no results. + + Raises: + GraphDbException: If the underlying query execution fails. """ - query = SPARQLQuery(prefixes=self.db.get_prefixes()) - query.add_select_block( + query = SPARQLQuery.select( variables=["?cntUri", "?cntStr", "?cntStatus"], where_clauses=( ["?cntUri kafka:listConnectors ?cntStr ."] @@ -74,9 +73,9 @@ def get_status_of_connectors( if id is None else [f"kafka-inst:{id} kafka:connectorStatus ?cntStatus ."] ), + prefixes=self.db.get_prefixes(), ) - query_string = query.to_string(validate=True) - results = self.db.query(query=query_string) + results = self.db.query(query=query) if results["results"]["bindings"]: return { res["cntStr"]["value"]: res["cntStatus"]["value"] @@ -85,48 +84,56 @@ def get_status_of_connectors( else: return None - def get_connector_create_options(self, id: str) -> Optional[Dict]: + def get_connector_create_options( + self, + id: str, + ) -> Optional[str]: """ - Retrieve the create options for a Kafka connector from the graph database. + Retrieve the creation options for a Kafka connector. - This method queries the graph database to fetch the creation configuration string - associated with a specific Kafka connector instance. + Queries the graph database for the stored creation configuration string for the + given connector instance. Args: - id (str): The identifier of the Kafka connector instance. + id (str): The connector identifier. Returns: - Optional[Dict]: The create options string if found, None otherwise. - Note: Despite the return type hint suggesting Dict, this method - returns a string value from the query results or None. + Optional[str]: The creation options string when available, otherwise `None`. + + Raises: + GraphDbException: If the underlying query execution fails. """ - query = SPARQLQuery(prefixes=self.db.get_prefixes()) - query.add_select_block( + query = SPARQLQuery.select( variables=["?createString"], where_clauses=[f"kafka-inst:{id} kafka:listOptionValues ?createString ."], + prefixes=self.db.get_prefixes(), ) - query_string = query.to_string(validate=True) - results = self.db.query(query=query_string) + results = self.db.query(query=query) if results["results"]["bindings"]: return results["results"]["bindings"][0]["createString"]["value"] return None - def drop_connector(self, id: str) -> bool: + def drop_connector( + self, + id: str, + ) -> bool: """ - Drops the specified Kafka connector. + Drop the specified Kafka connector. + + Args: + id (str): Connector identifier to drop. Returns: - bool: True if the connector was dropped successfully, False otherwise. + bool: True on success, False otherwise. """ - query = SPARQLQuery(prefixes=self.db.get_prefixes()) - query.add_insert_data_block( + query = SPARQLQuery.insert_data( triples=[ (f"kafka-inst:{id}", "kafka:dropConnector", "[]"), - ] + ], + prefixes=self.db.get_prefixes(), ) - query_string = query.to_string(validate=False) try: - self.db.query(query=query_string, update=True) + self.db.query(query=query, update=True) self.logger.info(f"Dropped Kafka connector with ID: {id}") return True except Exception as e: @@ -136,43 +143,37 @@ def drop_connector(self, id: str) -> bool: return False def create_connector( - self, id: str, connector_config: dict, overwrite: bool = False - ): + self, + id: str, + connector_config: dict, + overwrite: Optional[bool] = False, + ) -> None: """ - Create a Kafka connector with the specified ID and configuration. + Create a Kafka connector with the specified configuration. - This method creates a new Kafka connector by inserting the connector configuration - into the graph database using a SPARQL INSERT DATA query. If a connector with the - same ID already exists and overwrite is True, the existing connector will be dropped - before creating the new one. + Inserts the connector configuration into GraphDB via an INSERT DATA query. If a + connector with the same id exists and `overwrite=True`, it will be dropped first. Args: - id (str): The unique identifier for the Kafka connector. - connector_config (dict): A dictionary containing the configuration parameters - for the Kafka connector. This will be serialized to JSON and stored in - the database. - overwrite (bool, optional): If True, drops any existing connector with the - same ID before creating the new one. Defaults to False. - - Returns: - None + id (str): Unique connector identifier. + connector_config (dict): Kafka connector configuration to serialize and store. + overwrite (Optional[bool]): Drop any existing connector with the same id first. Defaults to False. """ if overwrite and id in self.get_existing_connector_ids(): self.drop_connector(id) - query = SPARQLQuery(prefixes=self.db.get_prefixes()) - query.add_insert_data_block( + query = SPARQLQuery.insert_data( triples=[ ( f"kafka-inst:{id}", "kafka:createConnector", f"'''{json.dumps(connector_config, indent=2)}'''", ), - ] + ], + prefixes=self.db.get_prefixes(), ) - query_string = query.to_string(validate=False) try: - self.db.query(query=query_string, update=True) + self.db.query(query=query, update=True) self.logger.info(f"Created Kafka connector with ID: {id}") except Exception as e: self.logger.error( diff --git a/graph_db_interface/queries/named_graph.py b/graph_db_interface/queries/named_graph.py index b96c45e..b40e365 100644 --- a/graph_db_interface/queries/named_graph.py +++ b/graph_db_interface/queries/named_graph.py @@ -1,28 +1,33 @@ # To be imported into ..graph_db.py GraphDB class -from typing import List, Optional, TYPE_CHECKING +from typing import List, TYPE_CHECKING +from graph_db_interface.utils.types import GraphNameLike if TYPE_CHECKING: from graph_db_interface import GraphDB -def get_list_of_named_graphs(self: "GraphDB") -> Optional[List]: - """Get a list of named graphs in the currently set repository. +def get_list_of_named_graphs( + self: "GraphDB", +) -> List[GraphNameLike]: + """ + Get the list of named graphs in the current repository. Returns: - Optional[List]: List of named graph IRIs. Can be an empty list. + List[IRI]: List of named graph IRIs. Can be an empty list. + + Raises: + GraphDbException: If the underlying request to GraphDB fails. """ # TODO: This query is quite slow and should be optimized # SPARQL query to retrieve all named graphs query = """ - SELECT DISTINCT ?graph WHERE { +SELECT DISTINCT ?graph WHERE { GRAPH ?graph { ?s ?p ?o } - } +} """ - results = self.query(query) - + results = self.query(query, convert_bindings=True) if results is None: return [] - - return [result["graph"]["value"] for result in results["results"]["bindings"]] + return [result["graph"] for result in results["results"]["bindings"]] diff --git a/graph_db_interface/queries/ontology_helpers.py b/graph_db_interface/queries/ontology_helpers.py index 5ed992a..c4a6e2f 100644 --- a/graph_db_interface/queries/ontology_helpers.py +++ b/graph_db_interface/queries/ontology_helpers.py @@ -1,8 +1,12 @@ # To be imported into ..graph_db.py GraphDB class +import uuid -from typing import List, Optional, TYPE_CHECKING +from typing import List, Optional, Union, Callable, TYPE_CHECKING from graph_db_interface.utils import utils +from graph_db_interface.utils.iri import IRI +from graph_db_interface.utils.types import IRILike, GraphNameLike from graph_db_interface.exceptions import InvalidInputError +from rdflib import Namespace from graph_db_interface.sparql_query import SPARQLQuery @@ -12,29 +16,31 @@ def iri_exists( self: "GraphDB", - iri: str, - as_sub: bool = False, - as_pred: bool = False, - as_obj: bool = False, - include_explicit: bool = True, - include_implicit: bool = True, + iri: IRILike, + as_sub: Optional[bool] = False, + as_pred: Optional[bool] = False, + as_obj: Optional[bool] = False, + include_explicit: Optional[bool] = True, + include_implicit: Optional[bool] = True, + named_graph: Optional[GraphNameLike] = None, ) -> bool: """ - Checks if a given IRI exists in the graph database as a subject, predicate, or object. + Check if an IRI exists as subject, predicate, or object. Args: - iri (str): The IRI to check for existence. - as_sub (bool, optional): If True, checks if the IRI exists as a subject. Defaults to False. - as_pred (bool, optional): If True, checks if the IRI exists as a predicate. Defaults to False. - as_obj (bool, optional): If True, checks if the IRI exists as an object. Defaults to False. - include_explicit (bool, optional): If True, includes explicitly defined triples in the query. Defaults to True. - include_implicit (bool, optional): If True, includes implicitly inferred triples in the query. Defaults to True. + iri (IRILike): The IRI to check for existence. + as_sub (Optional[bool]): If True, check existence as subject. Defaults to False. + as_pred (Optional[bool]): If True, check existence as predicate. Defaults to False. + as_obj (Optional[bool]): If True, check existence as object. Defaults to False. + include_explicit (Optional[bool]): Include explicit triples (`FROM onto:explicit`). Defaults to True. + include_implicit (Optional[bool]): Include inferred triples (`FROM onto:implicit`). Defaults to True. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. Returns: - bool: True if the IRI exists in the graph database based on the specified criteria, False otherwise. + bool: True if the IRI exists based on the specified criteria, False otherwise. Raises: - InvalidInputError: If none of `as_sub`, `as_pred`, or `as_obj` is set to True. + InvalidInputError: If none of `as_sub`, `as_pred`, or `as_obj` is True. """ # Check if either as_subject, as_predicate, or as_object is True @@ -43,35 +49,25 @@ def iri_exists( "At least one of as_sub, as_pred, or as_obj must be True" ) + iri = IRI(iri) + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph + # Define potential query parts where_clauses = [] if as_sub: - sub = utils.prepare_subject(iri, ensure_iri=True) - where_clauses.append(f"{{{sub} ?p ?o . }}") + where_clauses.append(f"{{{iri.n3()} ?p ?o . }}") if as_pred: - pred = utils.prepare_predicate(iri, ensure_iri=True) - where_clauses.append(f"{{?s {pred} ?o . }}") + where_clauses.append(f"{{?s {iri.n3()} ?o . }}") if as_obj: - obj = utils.prepare_object(iri, as_string=True) - where_clauses.append(f"{{?s ?p {obj} . }}") + where_clauses.append(f"{{?s ?p {iri.n3()} . }}") - query = SPARQLQuery( - named_graph=self._named_graph, - prefixes=self._prefixes, + query = SPARQLQuery.ask( + where_clauses=where_clauses, + named_graph=named_graph, include_explicit=include_explicit, include_implicit=include_implicit, ) - - query.add_ask_block( - where_clauses=where_clauses, - ) - - query_string = query.to_string(validate=True) - - result = self.query( - query=query_string, - update=False, - ) + result = self.query(query=query, update=False) if result is not None and result["boolean"]: self.logger.debug(f"Found IRI {iri}") return True @@ -80,77 +76,151 @@ def iri_exists( return False -def is_subclass(self: "GraphDB", subclass_iri: str, class_iri: str) -> bool: +def new_iri( + self: "GraphDB", + base: IRILike, + schema: Optional[Callable[[IRI], IRI]] = None, + test_schema: bool = True, +) -> IRI: + """ + Generate a new unique IRI within the graph database's namespace. + + Args: + base (IRILike): The base IRI or namespace for the new IRI. + schema (Optional[Callable[[IRI], IRI]]): A callable that generates the new IRI. + Takes the base IRI as an argument. Defaults to a `{onto}#{fragment}-{UUID4}`. + If fragment of base is empty, uses `{onto}#instance-{UUID4}`. + + Returns: + IRI: A new unique IRI. + """ + if base is None: + raise InvalidInputError("Base IRI must be provided for new IRI generation") + + base = IRI(base) + + if schema is None: + schema = lambda base: ( + f"{base}-{uuid.uuid4()}" + if base.fragment + else f"{base}#instance-{uuid.uuid4()}" + ) + elif test_schema and schema(base) == schema(base): + raise ValueError("Schema function must produce different values on each call") + + def new() -> str: + return IRI(schema(base)) + + iri = new() + + while self.iri_exists(iri, as_sub=True, as_pred=True, as_obj=True): + iri = new() + return iri + + +def new_blank_id( + self: "GraphDB", + schema: Optional[Callable[[], str]] = lambda: f"genid-{uuid.uuid4()}", +) -> str: + """ + Generate a new unique blank node identifier. + + Args: + schema (Optional[Callable[[], str]]): A callable that generates the blank node ID. + Defaults to a `genid-` format. + + Returns: + str: A new unique blank node identifier. """ - Determines whether a given class (subclass_iri) is a subclass of another class (class_iri) - based on the "rdfs:subClassOf" relationship. + if schema() == schema(): + raise ValueError("Schema function must produce different values on each call") + + genid = schema() + + while genid in self._blank_ids: + genid = schema() + + self._blank_ids.add(genid) + return genid + + +def is_subclass( + self: "GraphDB", + subclass_iri: IRILike, + class_iri: IRILike, + named_graph: Optional[GraphNameLike] = None, +) -> bool: + """ + Check whether one class is a subclass of another (`rdfs:subClassOf`). + + Asks for `subclass_iri rdfs:subClassOf class_iri` Args: - subclass_iri (str): The IRI of the potential subclass. - class_iri (str): The IRI of the potential superclass. + subclass_iri (IRILike): The IRI of the potential subclass. + class_iri (IRILike): The IRI of the potential superclass. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. Returns: - bool: True if subclass_iri is a subclass of class_iri, False otherwise. + bool: True if `subclass_iri` is a subclass of `class_iri`, False otherwise. """ - return self.triple_exists(subclass_iri, "rdfs:subClassOf", class_iri) + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph + return self.triple_exists( + (subclass_iri, "rdfs:subClassOf", class_iri), named_graph=named_graph + ) -def owl_is_named_individual(self: "GraphDB", iri: str) -> bool: +def owl_is_named_individual( + self: "GraphDB", + iri: IRILike, + named_graph: Optional[GraphNameLike] = None, +) -> bool: """ - Checks if the given IRI corresponds to an OWL named individual. + Check if the given IRI corresponds to an OWL named individual. - This method verifies whether the provided IRI is explicitly defined as - an `owl:NamedIndividual` in the RDF graph by checking for the existence - of the triple (IRI, rdf:type, owl:NamedIndividual). If the triple does - not exist, a warning is logged. + Asks for `iri rdf:type owl:NamedIndividual`. Args: - iri (str): The IRI to be checked. + iri (IRILike): The IRI to check. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. Returns: bool: True if the IRI is a named individual, False otherwise. """ - if not self.triple_exists(iri, "rdf:type", "owl:NamedIndividual"): - self.logger.debug(f"IRI {iri} is not a named individual!") - return False - return True + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph + return self.triple_exists( + (iri, "rdf:type", "owl:NamedIndividual"), named_graph=named_graph + ) def owl_get_classes_of_individual( self: "GraphDB", - instance_iri: str, - ignored_prefixes: Optional[List[str]] = None, - local_name: bool = False, - include_explicit=True, - include_implicit=False, -) -> List[str]: + instance_iri: IRILike, + ignored_prefixes: Optional[List[Namespace]] = None, + local_name: Optional[bool] = False, + include_explicit: Optional[bool] = True, + include_implicit: Optional[bool] = False, + named_graph: Optional[GraphNameLike] = None, +) -> List[Union[IRI, str]]: """ - Retrieves the OWL classes associated with a given individual (instance IRI) - from a graph database. + Get the OWL classes associated with a given individual. + + Builds a SPARQL query that returns the classes for an instance IRI and + optionally filters out results by prefix or returns local names only. Args: - instance_iri (str): The IRI of the individual whose classes are to be retrieved. - ignored_prefixes (Optional[List[str]]): A list of prefixes to ignore when - filtering classes. Defaults to ["owl", "rdfs"] if not provided. - local_name (bool): If True, returns the local names of the classes - (i.e., the part of the IRI after the last '#', '/', or ':'). - Defaults to False. - include_explicit (bool): If True, includes explicitly defined triples in the query. - Defaults to True. - include_implicit (bool): If True, includes implicitly inferred triples in the query. - Defaults to False. + instance_iri (IRILike): IRI of the individual to inspect. + ignored_prefixes (Optional[List[Namespace]]): Prefixes/namespaces to ignore + when collecting classes. Defaults to ["owl", "rdfs"]. + local_name (Optional[bool]): If True, return only the local names of the classes. Defaults to False. + include_explicit (Optional[bool]): Include explicit triples. Defaults to True. + include_implicit (Optional[bool]): Include inferred triples. Defaults to False. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. Returns: - List[str]: A list of class IRIs or local names (depending on the value - of `local_name`) associated with the given individual. - - Notes: - - The method constructs a SPARQL query to retrieve the classes of the - individual and applies optional filtering based on ignored prefixes. - - If no results are found, an empty list is returned. - - The `utils.get_local_name` function is used to extract the local name - from the IRI if `local_name` is set to True. + List[Union[IRI, str]]: Class IRIs, or local names if `local_name=True`. """ + instance_iri = IRI(instance_iri) + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph ignored_prefixes = ( ignored_prefixes if ignored_prefixes is not None else ["owl", "rdfs"] ) @@ -169,30 +239,28 @@ def owl_get_classes_of_individual( else: filter_conditions = "" - query = SPARQLQuery( - named_graph=self._named_graph, - prefixes=self._prefixes, - include_explicit=include_explicit, - include_implicit=include_implicit, - ) - - query.add_select_block( + query = SPARQLQuery.select( variables=["?class"], where_clauses=[ f"?class rdf:type owl:Class .", - f"{utils.prepare_subject(instance_iri)} rdf:type ?class .", + f"{instance_iri.n3()} rdf:type ?class .", filter_conditions, ], + named_graph=named_graph, + include_explicit=include_explicit, + include_implicit=include_implicit, ) - - query_string = query.to_string(validate=True) - - results = self.query(query=query_string) + results = self.query(query=query, convert_bindings=True) if results is None: return [] - classes = [result["class"]["value"] for result in results["results"]["bindings"]] - if local_name is True: - classes = [utils.get_local_name(iri) for iri in classes] + if local_name: + classes = [ + utils.get_local_name(result["class"]) + for result in results["results"]["bindings"] + ] + return classes + + classes = [result["class"] for result in results["results"]["bindings"]] return classes diff --git a/graph_db_interface/queries/rdf4j/graph_store.py b/graph_db_interface/queries/rdf4j/graph_store.py index 3ff93fe..d2f5a20 100644 --- a/graph_db_interface/queries/rdf4j/graph_store.py +++ b/graph_db_interface/queries/rdf4j/graph_store.py @@ -1,6 +1,8 @@ from requests import Response -from typing import TYPE_CHECKING, Optional, Tuple +from typing import TYPE_CHECKING, Optional from rdflib import Graph +from graph_db_interface.utils.iri import IRI +from graph_db_interface.utils.types import GraphNameLike if TYPE_CHECKING: from graph_db_interface import GraphDB @@ -8,115 +10,155 @@ def fetch_statements( self: "GraphDB", - graph_uri: Optional[str] = None, -) -> Tuple[Response, Graph]: + graph_iri: Optional[GraphNameLike] = None, +) -> Optional[Graph]: """ - Fetch the contents of either a explicit named or the default graph. - If graph_uri is None, the default graph is fetched. + Fetch statements from a named or the default graph. + + Queries the RDF4J Graph Store endpoint and parses the response into an + `rdflib.Graph`. When `graph_iri` is None, the default graph is fetched. + + Args: + graph_iri (Optional[GraphNameLike]): The named graph IRI to fetch; when + None, the default graph is fetched. + + Returns: + Optional[Graph]: The parsed graph on success; `None` when the request fails. + + Raises: + requests.exceptions.RequestException: If the underlying HTTP request fails. """ - default_graph = True if graph_uri is None else False + graph_iri_str = ( + str(IRI(graph_iri)) if graph_iri else "http://www.openrdf.org/schema/sesame#nil" + ) g = Graph() endpoint = f"repositories/{self._repository}/rdf-graphs/service" - if graph_uri is None: - graph_uri = "http://www.openrdf.org/schema/sesame#nil" response: Response = self._make_request( "get", endpoint, - params={"graph": graph_uri} if graph_uri else None, + params={"graph": graph_iri_str}, headers={"Content-Type": "application/x-turtle"}, ) - if response.status_code == 200: - if not default_graph: - self.logger.debug(f"Named graph {graph_uri} fetched successfully!") - else: - self.logger.debug("Default graph fetched successfully!") - g.parse(data=response.text, format="nt") - else: - if not default_graph: - self.logger.warning( - f"Failed to fetch named graph: {response.status_code} -" - f" {response.text}" - ) - else: - self.logger.warning( - f"Failed to fetch default graph: {response.status_code} -" - f" {response.text}" - ) - return response, g + + if response.status_code != 200: + self.logger.warning( + f"Failed to fetch named graph: {response.status_code} - {response.text}" + if graph_iri_str + else f"Failed to fetch default graph: {response.status_code} - {response.text}" + ) + return None + + self.logger.debug( + f"Named graph '{graph_iri_str}' fetched successfully!" + if graph_iri_str + else "Default graph fetched successfully!" + ) + g.parse(data=response.text, format="nt") + return g def import_statements( self: "GraphDB", content: str, - overwrite: bool = False, - graph_uri: Optional[str] = None, - content_type: str = "application/x-turtle", -): - default_graph = True if graph_uri is None else False - if default_graph: - endpoint = f"repositories/{self._repository}/rdf-graphs/service?default" - else: + overwrite: Optional[bool] = False, + graph_iri: Optional[GraphNameLike] = None, + content_type: Optional[str] = "application/x-turtle", +) -> bool: + """ + Import RDF statements into a named or the default graph. + + Sends content to the RDF4J Graph Store endpoint using POST (append) or PUT + (overwrite). When `graph_iri` is None, the default graph is targeted. + + Args: + content (str): RDF content to import. + overwrite (Optional[bool]): Use PUT to overwrite existing content. Defaults to False. + graph_iri (Optional[GraphNameLike]): Target named graph IRI; default graph when None. + content_type (Optional[str]): MIME type of `content` (e.g., 'application/x-turtle'). + Defaults to 'application/x-turtle'. + + Returns: + bool: True on success (HTTP 204), False otherwise. + + Raises: + requests.exceptions.RequestException: If the underlying HTTP request fails. + """ + graph_iri_str = str(IRI(graph_iri)) if graph_iri else None + if graph_iri_str: endpoint = f"repositories/{self._repository}/rdf-graphs/service" + else: + endpoint = f"repositories/{self._repository}/rdf-graphs/service?default" method = "put" if overwrite else "post" response: Response = self._make_request( method, endpoint, - params={"graph": graph_uri} if graph_uri else None, + params={"graph": graph_iri_str} if graph_iri_str else None, headers={"Content-Type": content_type}, data=content, ) - if response.status_code == 204: - if not default_graph: - self.logger.debug( - f"Statements imported to named graph {graph_uri} successfully!" - ) - else: - self.logger.debug("Statements imported to default graph successfully!") - else: - if not default_graph: - self.logger.warning( - f"Failed to import statements to named graph: {response.status_code} -" - f" {response.text}" - ) - else: - self.logger.warning( - f"Failed to import statements to default graph: {response.status_code} -" - f" {response.text}" - ) - return response - - -def clear_graph(self: "GraphDB", graph_uri: Optional[str] = None): + + if response.status_code != 204: + self.logger.warning( + f"Failed to import statements to named graph: {response.status_code} - {response.text}" + if graph_iri_str + else f"Failed to import statements to default graph: {response.status_code} - {response.text}" + ) + return False + + self.logger.debug( + f"Named graph {graph_iri_str} imported successfully!" + if graph_iri_str + else "Default graph imported successfully!" + ) + return True + + +def clear_graph( + self: "GraphDB", + graph_iri: Optional[GraphNameLike] = None, +) -> bool: """ - Deletes the specified named graph from the triplestore. + Clear a named graph or the default graph. + + Deletes the specified named graph from the triplestore; when `graph_iri` is + None, clears the default graph. + + Args: + graph_iri (Optional[GraphNameLike]): IRI of the named graph to clear; + default graph when None. + + Returns: + bool: True on success (HTTP 204), False otherwise. + + Raises: + requests.exceptions.RequestException: If the underlying HTTP request fails. """ - default_graph = True if graph_uri is None else False - if default_graph: - endpoint = f"repositories/{self._repository}/rdf-graphs/service?default" - else: + graph_iri_str = str(IRI(graph_iri)) if graph_iri else None + if graph_iri_str: endpoint = f"repositories/{self._repository}/rdf-graphs/service" + else: + endpoint = f"repositories/{self._repository}/rdf-graphs/service?default" response: Response = self._make_request( "delete", endpoint, - params={"graph": graph_uri} if graph_uri else None, + params={"graph": graph_iri_str} if graph_iri_str else None, ) - if response.status_code == 204: - if not default_graph: - self.logger.debug(f"Named graph {graph_uri} cleared successfully!") - else: - self.logger.debug(f"Default graph cleared successfully!") - else: - if not default_graph: - self.logger.warning( - f"Failed to clear named graph: {response.status_code} - {response.text}" - ) - else: - self.logger.warning( - f"Failed to clear default graph: {response.status_code} - {response.text}" - ) - return response + if response.status_code != 204: + self.logger.warning( + f"Failed to clear named graph: {response.status_code} - {response.text}" + if graph_iri_str + else f"Failed to clear default graph: {response.status_code} - {response.text}" + ) + return False + + self.logger.debug( + f"Named graph {graph_iri_str} cleared successfully!" + if graph_iri_str + else "Default graph cleared successfully!" + ) + return True diff --git a/graph_db_interface/queries/triple_multi.py b/graph_db_interface/queries/triple_multi.py index d801905..3e0d130 100644 --- a/graph_db_interface/queries/triple_multi.py +++ b/graph_db_interface/queries/triple_multi.py @@ -1,8 +1,20 @@ # To be imported into ..graph_db.py GraphDB class -from typing import List, Union, Any, Optional, Tuple, TYPE_CHECKING -from rdflib import Literal +from typing import Dict, List, Union, Any, Optional, Tuple, TYPE_CHECKING +from rdflib import BNode, Literal from graph_db_interface.utils import utils +from graph_db_interface.utils.iri import IRI +from graph_db_interface.utils.types import ( + Subject, + Predicate, + PartialTripleLike, + SubjectLike, + PredicateLike, + ObjectLike, + GraphNameLike, + TriplesLike, + Triple, +) from graph_db_interface.exceptions import InvalidInputError from graph_db_interface.sparql_query import SPARQLQuery @@ -13,307 +25,419 @@ def triples_get( self: "GraphDB", - sub: Optional[str] = None, - pred: Optional[str] = None, - obj: Optional[Any] = None, - include_explicit: bool = True, - include_implicit: bool = True, -) -> Union[List[Tuple], List[str]]: + triple: Optional[PartialTripleLike] = None, + sub: Optional[SubjectLike] = None, + pred: Optional[PredicateLike] = None, + obj: Optional[ObjectLike] = None, + include_explicit: Optional[bool] = True, + include_implicit: Optional[bool] = True, + named_graph: Optional[GraphNameLike] = None, +) -> List[Tuple[Subject, Predicate, Any]]: """ - Retrieve triples based on the specified subject, predicate, and/or object. + Retrieve triples matching any combination of subject, predicate, or object. Args: - sub (Optional[str]): The subject of the triple. Can be an IRI, shorthand IRI, or a string. - pred (Optional[str]): The predicate of the triple. Can be an IRI, shorthand IRI, or a string. - obj (Optional[Any]): The object of the triple. Can be an IRI, shorthand IRI, Literal, or a string. - include_explicit (bool): Whether to include explicitly defined triples. Defaults to True. - include_implicit (bool): Whether to include implicitly inferred triples. Defaults to True. + triple (Optional[PartialTripleLike]): Combined (subject, predicate, object) filter tuple. Use this + or individual `sub`/`pred`/`obj`. + sub (Optional[SubjectLike]): Subject filter (IRI/shorthand/string). + pred (Optional[PredicateLike]): Predicate filter (IRI/shorthand/string). + obj (Optional[ObjectLike]): Object filter (IRI/shorthand/Literal/string). + include_explicit (Optional[bool]): Include explicit triples. Defaults to True. + include_implicit (Optional[bool]): Include inferred triples. Defaults to True. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. Returns: - Union[List[Tuple], List[str]]: A list of triples matching the query. Each triple is represented as a tuple - (subject, predicate, object), where the object is converted to its Python type if applicable. + List[Tuple[Subject, Predicate, Any]]: Matching triples as `(subject, predicate, object)`, where the + object is converted to an appropriate Python type when applicable. Raises: - InvalidInputError: If none of the subject, predicate, or object is provided. + InvalidInputError: If neither or both of `triple` and any of `sub`/`pred`/`obj` are provided. """ - if sub is None and pred is None and obj is None: + elems_given = sub is not None or pred is not None or obj is not None + if (triple and elems_given) or (not triple and not elems_given): raise InvalidInputError( - "At least one of subject, predicate, or object must be provided" + "Either 'triple' or 'sub/pred/obj' must be provided, not both." ) + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph + if named_graph is not None and not hasattr(named_graph, "n3"): + named_graph = IRI(named_graph) + + sub, pred, obj = utils.sanitize_triple( + triple or (sub, pred, obj), allow_partial=True + ) + binds = [] filter = [] - def append_bind_and_filter(var: str, value: str): - if utils.is_iri(value): - binds.append(f"BIND({utils.ensure_absolute(value)} AS {var})") - elif utils.is_shorthand_iri(value): - binds.append(f"BIND({value} AS {var})") + def _append_bind_and_filter( + var: str, + value: Union[IRI, BNode, Literal], + ) -> None: + if isinstance(value, (IRI, BNode)): + binds.append(f"BIND({value.n3()} AS {var})") elif isinstance(value, Literal): filter.append(f"FILTER(?o={value.n3()})") else: - filter.append(f"FILTER(CONTAINS(STR({var}), '{value}'))") + raise Exception( + f"Value must be either IRI or Literal, is type {type(value)}" + ) if sub is not None: - sub = utils.prepare_subject(sub, ensure_iri=False) - append_bind_and_filter("?s", sub) + _append_bind_and_filter("?s", sub) if pred is not None: - pred = utils.prepare_predicate(pred, ensure_iri=False) - append_bind_and_filter("?p", pred) + _append_bind_and_filter("?p", pred) if obj is not None: - obj = utils.prepare_object(obj, ensure_iri=False) - append_bind_and_filter("?o", obj) + _append_bind_and_filter("?o", obj) - query = SPARQLQuery( - named_graph=self._named_graph, # type: ignore - prefixes=self._prefixes, - include_explicit=include_explicit, - include_implicit=include_implicit, - ) - query.add_select_block( + query = SPARQLQuery.select( variables=["?s", "?p", "?o"], where_clauses=binds + ["?s ?p ?o ."] + filter, + named_graph=named_graph, + include_explicit=include_explicit, + include_implicit=include_implicit, ) - query_string = query.to_string(validate=True) - if query_string is None: - self.logger.error( - "Unable to construct SPARQL query, returning empty list of triples" - ) - return [] + results = self.query(query=query, convert_bindings=True) - results = self.query(query=query_string) converted_results = [ - ( - result["s"]["value"], - result["p"]["value"], - utils.convert_query_result_to_python_type(result["o"]), - ) + (result["s"], result["p"], result["o"]) for result in results["results"]["bindings"] ] + return converted_results -def triples_add( - self, - triples_to_add: List[Tuple[str, str, Any]], - check_exist: bool = True, - named_graph: Optional[str] = None, +def any_triple_exists( + self: "GraphDB", + triples: TriplesLike, + named_graph: Optional[GraphNameLike] = None, ) -> bool: """ - Adds multiple triples to the graph database. + Check if any of the given triples exist. Args: - triples_to_add (List[Tuple[str, str, Any]]): A list of triples to add, where each triple is represented as a tuple (subject, predicate, object). - check_exist (bool, optional): Flag to check if any of the triples already exists. Then, no triple will be added. In Defaults to True. + triples (TriplesLike): Triples to check. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. Returns: - bool: True if all triples were successfully added, False otherwise. + bool: True if at least one exists, False otherwise. """ - if not triples_to_add: - raise InvalidInputError("The list of triples to add must not be empty.") + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph + + if not triples: + raise InvalidInputError(f"Cannot check existence of empty triple list.") - prepared_triples = [] + validated_triples = [utils.sanitize_triple(triple) for triple in triples] - for sub, pred, obj in triples_to_add: - sub = utils.prepare_subject(sub, ensure_iri=True) - pred = utils.prepare_predicate(pred, ensure_iri=True) - obj = utils.prepare_object(obj, as_string=True) + triple_groups = utils.group_triples_by_bnode(validated_triples) - if not sub or not pred or not obj: - raise InvalidInputError(f"Invalid triple: ({sub}, {pred}, {obj})") - return False - prepared_triples.append((sub, pred, obj)) + # Build UNION patterns for ASK query + union_patterns = [] + for group in triple_groups: + group_pattern = " .\n ".join( + utils.triple_to_string(triple, "") for triple in group + ) + union_patterns.append(f"{{\n {group_pattern} .\n }}") + + where_clause = "\n UNION\n ".join(union_patterns) - if check_exist: - ask_query = SPARQLQuery( - named_graph=self._named_graph, - prefixes=self._prefixes, + query = SPARQLQuery.ask( + where_clauses=[where_clause], + named_graph=named_graph, + ) + ask_result = self.query(query=query, update=False) + if ask_result is None: + raise InvalidInputError( + f"Could not query 'any_triple_exists' for triples, named_graph: {named_graph or 'default'}, repository: {self._repository}" ) - ask_query.add_ask_block( - where_clauses=[ - f"{sub} {pred} {obj} ." for sub, pred, obj in prepared_triples - ], + + if ask_result["boolean"] is True: + self.logger.debug( + f"At least one of the triples exists, named_graph: {named_graph or 'default'}, repository: {self._repository}" ) - ask_query_string = ask_query.to_string() - if ask_query_string is None: - return False - ask_result = self.query(query=ask_query_string, update=False) - if ask_result is not None and ask_result["boolean"]: - self.logger.warning("One of the triples to add already exists in the graph.") - return False - - query = SPARQLQuery( - named_graph=self._named_graph, - prefixes=self._prefixes, + return True + + self.logger.debug( + f"None of the triples exists, named_graph: {named_graph or 'default'}, repository: {self._repository}" ) - query.add_insert_data_block( - triples=prepared_triples, + return False + + +def all_triple_exists( + self: "GraphDB", + triples: TriplesLike, + named_graph: Optional[GraphNameLike] = None, +) -> bool: + """ + Check if all of the given triples exist. + + Args: + triples (TriplesLike): Triples to check. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. + + Returns: + bool: True if all exist, False otherwise. + """ + self.logger.setLevel(10) + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph + + if not triples: + raise InvalidInputError(f"Cannot check existence of empty triple list.") + + triple_strings = [] + for triple in triples: + triple = utils.sanitize_triple(triple) + triple_strings.append(utils.triple_to_string(triple, ".")) + + query = SPARQLQuery.ask( + where_clauses=triple_strings, + named_graph=named_graph, ) + ask_result = self.query(query=query, update=False) + if ask_result is None: + raise InvalidInputError( + f"Could not query 'all_triple_exists' for triples ({triple_strings}), named_graph: {named_graph or "default"}, repository: {self._repository}" + ) - # if check_exist: - # query.add_insert_exists_block( - # triples=prepared_triples, - # ) - # else: - # query.add_insert_data_block( - # triples=prepared_triples, - # ) - - query_string = query.to_string() - if query_string is None: + if ask_result["boolean"] is False: + self.logger.debug( + f"Not all of the triples exist: ({triple_strings}), named_graph: {named_graph or "default"}, repository: {self._repository}" + ) return False - if named_graph: - old_named_graph = self._named_graph - self.named_graph = named_graph + self.logger.debug( + f"All of the triples exist: ({triple_strings}), named_graph: {named_graph or "default"}, repository: {self._repository}" + ) + return True + - result = self.query(query=query_string, update=True) - if not result: - self.logger.warning(f"Failed to add triples: {prepared_triples}") +def triples_add( + self: "GraphDB", + triples_to_add: TriplesLike, + check_exist: Optional[bool] = True, + named_graph: Optional[GraphNameLike] = None, +) -> bool: + """ + Add multiple triples to the graph database. + + Args: + triples_to_add (TriplesLike): Triples to add. + check_exist (Optional[bool]): If True, abort when any triple already exists. Defaults to True. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. + + Returns: + bool: True if all triples were added, False otherwise. + """ + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph + + if not triples_to_add: + return True + + validated_triples_to_add = [ + utils.sanitize_triple(triple) for triple in triples_to_add + ] + + if check_exist and self.any_triple_exists( + triples=validated_triples_to_add, named_graph=named_graph + ): + self.logger.warning( + "At least one of the triples to add already exists in the graph." + ) return False - if named_graph: - self.named_graph = named_graph + triple_strings = [ + utils.triple_to_string(triple, ".") for triple in validated_triples_to_add + ] + query = SPARQLQuery.insert_data( + triples=validated_triples_to_add, + named_graph=named_graph, + ) + result = self.query(query=query, update=True) + if not result: + self.logger.warning( + f"Failed to add triples: ({triple_strings}), named_graph: {named_graph or "default"}, repository: {self._repository}" + ) + return False + + self.logger.debug( + f"Successfully added triples: ({triple_strings}), named_graph: {named_graph or "default"}, repository: {self._repository}" + ) return result def triples_delete( - self, - triples_to_delete: List[Tuple[str, str, Union[str, Literal]]], - check_exist: bool = True, + self: "GraphDB", + triples_to_delete: TriplesLike, + check_exist: Optional[bool] = True, + named_graph: Optional[GraphNameLike] = None, ) -> bool: """ Delete multiple triples from the graph database. Args: - triples_to_delete (List[Tuple[str, str, Union[str, Literal]]]): A list of triples to delete, where each triple is represented as a tuple (subject, predicate, object). - check_exist (bool, optional): Flag to check if each triple exists before attempting to delete it. Defaults to True. + triples_to_delete (TriplesLike): Triples to delete. + check_exist (Optional[bool]): If True, abort when any triple does not exist. Defaults to True. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. Returns: - bool: Returns True if all triples were successfully deleted, False otherwise. + bool: True if all triples were deleted, False otherwise. """ + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph + if not triples_to_delete: - raise InvalidInputError("The list of triples to delete must not be empty.") + return True - prepared_triples = [] - where_clauses = [] - for sub, pred, obj in triples_to_delete: - sub = utils.prepare_subject(sub, ensure_iri=True) - pred = utils.prepare_predicate(pred, ensure_iri=True) - obj = utils.prepare_object(obj, as_string=True) + validated_triples_to_delete = [ + utils.sanitize_triple(triple) for triple in triples_to_delete + ] - if check_exist: + if check_exist and not self.all_triple_exists( + triples=validated_triples_to_delete, named_graph=named_graph + ): + self.logger.warning( + "At least one of the triples to delete does not exist in the graph." + ) + return False - if not self.triple_exists(sub, pred, obj): - self.logger.warning( - f"Triple does not exist and cannot be deleted: {sub} {pred} {obj}" - ) - return False - where_clauses.append(f"{sub} {pred} {obj} .") - prepared_triples.append((sub, pred, obj)) + triple_strings = [ + utils.triple_to_string(triple, ".") for triple in validated_triples_to_delete + ] - query = SPARQLQuery( - named_graph=self._named_graph, - prefixes=self._prefixes, + query = SPARQLQuery.delete_data( + triples=validated_triples_to_delete, + named_graph=named_graph, ) + result = self.query(query=query, update=True) + if not result: + self.logger.warning( + f"Failed to delete triples: ({triple_strings}), named_graph: {named_graph or "default"}, repository: {self._repository}" + ) + return False - query.add_delete_data_block( - triples=prepared_triples, + self.logger.debug( + f"Successfully deleted triples: ({triple_strings}), named_graph: {named_graph or "default"}, repository: {self._repository}" ) - - result = self.query(query=query.to_string(), update=True) - if result: - self.logger.debug(f"Successfully deleted triples: {prepared_triples}") - else: - self.logger.warning(f"Failed to delete triples: {prepared_triples}") - - return result + return True def triples_update( - self, - old_triples: List[Tuple[str, str, Union[str, Literal]]], - new_triples: List[Tuple[str, str, Union[str, Literal]]], - check_exist: bool = True, + self: "GraphDB", + old_triples: TriplesLike, + new_triples: TriplesLike, + check_exist: Optional[bool] = True, + named_graph: Optional[GraphNameLike] = None, ) -> bool: """ Update multiple RDF triples in the triplestore. Args: - old_triples (List[Tuple[str, str, Union[str, Literal]]]): A list of triples to update, where each triple is represented as a tuple (subject, predicate, object). - new_triples (List[Tuple[Optional[str], Optional[str], Optional[Union[str, Literal]]]]): A list of new triples to replace the old triples. - check_exist (bool): Whether to check for the existence of old triples before updating. + old_triples (TriplesLike): Triples to be replaced. + new_triples (TriplesLike): Replacement triples (same length as `old_triples`). + check_exist (Optional[bool]): If True, abort when any old triple does not exist. Defaults to True. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. Returns: bool: True if the update was successful, False otherwise. """ - if not old_triples or not new_triples: - raise InvalidInputError("Old and new triples lists must not be empty.") + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph + + if not old_triples and not new_triples: + return True if len(old_triples) != len(new_triples): raise InvalidInputError("Old and new triples lists must have the same length.") - delete_triples = [] - insert_triples = [] - where_clauses = [] + validated_old_triples = [utils.sanitize_triple(triple) for triple in old_triples] + validated_new_triples = [utils.sanitize_triple(triple) for triple in new_triples] - for triple in old_triples: - if len(triple) != 3: - raise InvalidInputError( - "Each old triple must have exactly three elements (subject, predicate, object)." - ) - sub_old, pred_old, obj_old = triple - if check_exist: - if not self.triple_exists(sub_old, pred_old, obj_old): - self.logger.warning(f"Triple does not exist: {sub_old} {pred_old} {obj_old}") - return False - - sub_old = utils.prepare_subject(sub_old, ensure_iri=True) - pred_old = utils.prepare_predicate(pred_old, ensure_iri=True) - obj_old = utils.prepare_object(obj_old, as_string=True) - delete_triples.append((sub_old, pred_old, obj_old)) - where_clauses.append(f"{sub_old} {pred_old} {obj_old} .") - - for triple in new_triples: - if len(triple) != 3: - raise InvalidInputError( - "Each new triple must have exactly three elements (subject, predicate, object)." - ) - sub_new, pred_new, obj_new = triple - - if sub_new is not None: - sub_new = utils.prepare_subject(sub_new, ensure_iri=True) - if pred_new is not None: - pred_new = utils.prepare_predicate(pred_new, ensure_iri=True) - if obj_new is not None: - obj_new = utils.prepare_object(obj_new, as_string=True) - insert_triples.append((sub_new, pred_new, obj_new)) - - query = SPARQLQuery( - named_graph=self._named_graph, - prefixes=self._prefixes, - ) - query.add_delete_insert_data_block( - delete_triples=delete_triples, - insert_triples=insert_triples, - where_clauses=where_clauses, - ) - query_string = query.to_string(validate=True) - if query_string is None: + if check_exist and not self.all_triple_exists( + triples=validated_old_triples, named_graph=named_graph + ): + self.logger.warning( + "At least one of the triples to update does not exist in the graph." + ) return False - result = self.query(query=query_string, update=True) - if result: - self.logger.debug( - f"Successfully updated triples {old_triples} -> {new_triples}, named_graph: {self._named_graph}, repository:" - f" {self._repository}" + + def _render_term( + term: Any, + bn_map: Optional[Dict[BNode, str]] = None, + prefix: str = "", + ) -> str: + if isinstance(term, BNode): + if bn_map is None: + raise InvalidInputError( + "Blank nodes in predicates are not supported for updates." + ) + if term not in bn_map: + bn_map[term] = f"?{prefix}{len(bn_map) + 1}" + return bn_map[term] + if hasattr(term, "n3"): + return term.n3() + return IRI(term).n3() + + def _build_patterns( + triples: List[Triple], + bn_map: Dict[BNode, str], + prefix: str, + ) -> List[str]: + patterns: List[str] = [] + for subject, predicate, obj in triples: + subj_str = _render_term(subject, bn_map, prefix) + pred_str = _render_term(predicate) + obj_str = _render_term(obj, bn_map, prefix) + patterns.append(f"{subj_str} {pred_str} {obj_str} .") + return patterns + + old_bn_var_map: Dict[BNode, str] = {} + new_bn_var_map: Dict[BNode, str] = {} + + old_delete_patterns = _build_patterns( + validated_old_triples, old_bn_var_map, "oldbn" + ) + insert_patterns = _build_patterns(validated_new_triples, new_bn_var_map, "newbn") + + def _format_block(patterns: List[str]) -> str: + if not patterns: + return "" + return "\n".join(f" {pattern}" for pattern in patterns) + + delete_block = _format_block(old_delete_patterns) + insert_block = _format_block(insert_patterns) + + where_patterns = list(dict.fromkeys(old_delete_patterns)) + where_block_parts: List[str] = [] + if where_patterns: + where_block_parts.append(_format_block(where_patterns)) + if new_bn_var_map: + where_block_parts.extend( + f" BIND(BNODE() AS {var})" for var in new_bn_var_map.values() ) - else: + where_block = "\n".join(where_block_parts) + + graph_clause = f"WITH {named_graph.n3()}\n" if named_graph else "" + + query = f"""{graph_clause}DELETE {{ +{delete_block} +}} +INSERT {{ +{insert_block} +}} +WHERE {{ +{where_block} +}} +""".strip() + result = self.query(query=query, update=True) + if not result: self.logger.warning( - f"Failed to update triples {old_triples} -> {new_triples}, named_graph: {self._named_graph}, repository:" - f" {self._repository}" + f"Failed to update triples ({validated_old_triples}) -> ({validated_new_triples}), named_graph: {named_graph or "default"}, repository: {self._repository}" ) - return result + return False + + self.logger.debug( + f"Successfully updated triples ({validated_old_triples}) -> ({validated_new_triples}), named_graph: {named_graph or "default"}, repository: {self._repository}" + ) + return True diff --git a/graph_db_interface/queries/triple_single.py b/graph_db_interface/queries/triple_single.py index 3ff1ead..dc036f3 100644 --- a/graph_db_interface/queries/triple_single.py +++ b/graph_db_interface/queries/triple_single.py @@ -1,8 +1,16 @@ # To be imported into ..graph_db.py GraphDB class -from typing import Union, Any, Optional, TYPE_CHECKING -from rdflib import Literal +from typing import Optional, TYPE_CHECKING from graph_db_interface.utils import utils +from graph_db_interface.utils.iri import IRI +from graph_db_interface.utils.types import ( + PartialTripleLike, + SubjectLike, + PredicateLike, + ObjectLike, + GraphNameLike, + TripleLike, +) from graph_db_interface.exceptions import InvalidInputError from graph_db_interface.sparql_query import SPARQLQuery @@ -13,255 +21,194 @@ def triple_exists( self: "GraphDB", - sub: str, - pred: str, - obj: Union[str, Literal], + triple: TripleLike, + named_graph: Optional[GraphNameLike] = None, ) -> bool: """ - Checks if a specific triple exists in the graph database. + Check whether a specific triple exists in the graph database. Args: - sub (str): The subject of the triple. It will be processed to ensure it is an IRI. - pred (str): The predicate of the triple. It will be processed to ensure it is an IRI. - obj (Union[str, Literal]): The object of the triple. It can be a string or a Literal and - will be processed to ensure it is represented as a string. + triple (TripleLike): The triple `(subject, predicate, object)` to check. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. Returns: - bool: True if the triple exists in the graph database, False otherwise. + bool: True if the triple exists, False otherwise. """ - sub = utils.prepare_subject(sub, ensure_iri=True) - pred = utils.prepare_predicate(pred, ensure_iri=True) - obj = utils.prepare_object(obj, as_string=True) + triple = utils.sanitize_triple(triple) + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph - query = SPARQLQuery(named_graph=self._named_graph, prefixes=self._prefixes) - query.add_ask_block( + query = SPARQLQuery.ask( where_clauses=[ - f"{sub} {pred} {obj} .", + utils.triple_to_string(triple, "."), ], + named_graph=named_graph, ) - query_string = query.to_string() - - result = self.query(query=query_string) - if result is not None and result["boolean"]: - self.logger.debug(f"Found triple {sub}, {pred}, {obj}") - return True + result = self.query(query=query) + if result is None or result["boolean"] is False: + self.logger.debug( + f"Unable to find triple ({utils.triple_to_string(triple)}), named_graph: {named_graph or "default"}, repository: {self._repository}" + ) + return False self.logger.debug( - f"Unable to find triple {sub}, {pred}, {obj}, named_graph:" - f" {self._named_graph}, repository: {self._repository}" + f"Found triple ({utils.triple_to_string(triple)}), named_graph: {named_graph or "default"}, repository: {self._repository}" ) - return False + return True def triple_add( self: "GraphDB", - sub: str, - pred: str, - obj: Any, - named_graph: Optional[str] = None, + triple: TripleLike, + named_graph: Optional[GraphNameLike] = None, ) -> bool: """ - Adds a triple (subject, predicate, object) to the graph database. - - This method prepares the subject, predicate, and object to ensure they are - in the correct format (e.g., IRI or string) and constructs a SPARQL query - to insert the triple into the specified named graph. + Add a triple to the graph database. Args: - sub (str): The subject of the triple. It will be processed to ensure it - is a valid IRI. - pred (str): The predicate of the triple. It will be processed to ensure - it is a valid IRI. - obj (Any): The object of the triple. It will be processed to ensure it - is represented as a string. + triple (TripleLike): The triple `(subject, predicate, object)` to insert. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. Returns: - bool: True if the triple was successfully inserted into the graph - database, False otherwise. + bool: True if the triple was inserted, False otherwise. """ - sub = utils.prepare_subject(sub, ensure_iri=True) - pred = utils.prepare_predicate(pred, ensure_iri=True) - obj = utils.prepare_object(obj, as_string=True) + triple = utils.sanitize_triple(triple) + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph - query = SPARQLQuery( - named_graph=self._named_graph, - prefixes=self._prefixes, + query = SPARQLQuery.insert_data( + triples=[triple], + named_graph=named_graph, ) - query.add_insert_data_block( - triples=[(sub, pred, obj)], - ) - query_string = query.to_string() - if query_string is None: + result = self.query(query=query, update=True) + if not result: + self.logger.warning( + f"Failed to insert triple: ({utils.triple_to_string(triple)}) named_graph: {named_graph or "default"}, repository: {self._repository}" + ) return False - if named_graph: - old_named_graph = self._named_graph - self.named_graph = named_graph - result = self.query(query=query_string, update=True) - if result: - self.logger.debug( - f"New triple inserted: {sub}, {pred}, {obj} named_graph:" - f" {self._named_graph}, repository: {self._repository}" - ) + self.logger.debug( + f"Successfully inserted triple: ({utils.triple_to_string(triple)}) named_graph: {named_graph or "default"}, repository: {self._repository}" + ) - if named_graph: - self.named_graph = old_named_graph - return result + return True def triple_delete( self: "GraphDB", - sub: str, - pred: str, - obj: Union[str, Literal], - check_exist: bool = True, + triple: TripleLike, + check_exist: Optional[bool] = True, + named_graph: Optional[GraphNameLike] = None, ) -> bool: - """Delete a single triple. A SPAQRL delete query will be successfull, even though the triple to delete does not exist in the first place. + """ + Delete a single triple. + + A SPARQL DELETE operation in GraphDB can be successful even if the triple + does not exist. When `check_exist=True`, the function verifies the triple is + present before attempting deletion. Args: - subject (str): valid subject IRI - predicate (str): valid predicate IRI - object (str): valid object IRI - named_graph (str, optional): The IRI of a named graph. Defaults to None. - check_exist (bool, optional): Flag if you want to check if the triple exists before aiming to delete it. Defaults to True. + triple (TripleLike): The triple `(subject, predicate, object)` to delete. + check_exist (Optional[bool]): Whether to verify existence prior to deletion. Defaults to True. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. Returns: - bool: Returns True if query was successfull. False otherwise. + bool: True if deletion succeeded (or triple absent when `check_exist=False`), False otherwise. """ - sub = utils.prepare_subject(sub, ensure_iri=True) - pred = utils.prepare_predicate(pred, ensure_iri=True) - obj = utils.prepare_object(obj, as_string=True) + triple = utils.sanitize_triple(triple) + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph if check_exist: - if not self.triple_exists(sub, pred, obj): + if not self.triple_exists(triple, named_graph=named_graph): self.logger.warning("Unable to delete triple since it does not exist") return False - query = SPARQLQuery( - named_graph=self._named_graph, - prefixes=self._prefixes, - ) - query.add_delete_data_block( - triples=[(sub, pred, obj)], - ) - query_string = query.to_string() - if query_string is None: + query = SPARQLQuery.delete_data( + triples=[triple], + named_graph=named_graph, + ) + result = self.query(query=query, update=True) + if not result: + self.logger.warning( + f"Failed to delete triple: ({utils.triple_to_string(triple)}), named_graph: {named_graph or "default"}, repository: {self._repository}" + ) return False - # Execute the SPARQL query - result = self.query(query=query_string, update=True) - if result: - self.logger.debug(f"Successfully deleted triple: {sub} {pred} {obj}") - else: - self.logger.warning(f"Failed to delete triple: {sub} {pred} {obj}") - - return result + self.logger.debug( + f"Successfully deleted triple: ({utils.triple_to_string(triple)}), named_graph: {named_graph or "default"}, repository: {self._repository}" + ) + return True def triple_update( self: "GraphDB", - sub_old: str, - pred_old: str, - obj_old: Union[str, Literal], - sub_new: Optional[str] = None, - pred_new: Optional[str] = None, - obj_new: Optional[Union[str, Literal]] = None, - check_exist: bool = True, + old_triple: TripleLike, + new_triple: Optional[PartialTripleLike] = None, + new_sub: Optional[SubjectLike] = None, + new_pred: Optional[PredicateLike] = None, + new_obj: Optional[ObjectLike] = None, + check_exist: Optional[bool] = True, + named_graph: Optional[GraphNameLike] = None, ) -> bool: """ - Updates any part of an existing triple (subject, predicate, or object) in the RDF store. + Update a triple by replacing any of its parts. - This function replaces the specified part of an existing triple using a SPARQL - `DELETE ... INSERT ... WHERE` query. + Performs a SPARQL `DELETE ... INSERT ... WHERE` that replaces the old triple + with a new triple built from provided parts. Args: - old_subject (str, optional): The subject of the triple to be updated. - old_predicate (str, optional): The predicate of the triple to be updated. - old_object (str, optional): The object of the triple to be updated. - new_subject (str, optional): The new subject to replace the old subject. - new_predicate (str, optional): The new predicate to replace the old predicate. - new_object (str, optional): The new object to replace the old object. - named_graph (str, optional): The named graph where the triple update should be performed. - check_exist (bool, optional): If `True`, checks if the old triple exists before updating. - Defaults to `True`. + old_triple (TripleLike): Existing triple to update. + new_triple (Optional[PartialTripleLike]): Replacement values (subject/predicate/object). Use this + or `new_sub`/`new_pred`/`new_obj`. + new_sub (Optional[SubjectLike]): Replacement subject. + new_pred (Optional[PredicateLike]): Replacement predicate. + new_obj (Optional[ObjectLike]): Replacement object. + check_exist (Optional[bool]): If True, verify that `old_triple` exists before updating. Defaults to True. + named_graph (Optional[GraphNameLike]): Override the client's default named graph. Returns: - bool: `True` if the update was successful, `False` otherwise. + bool: True if the update succeeded, False otherwise. Raises: - Any exceptions thrown by `self.query()` if the SPARQL update request fails. - - Example: - ```python - success = rdf_store.triple_update_any( - old_subject="", - old_predicate="", - old_object="", - new_subject="" - ) - ``` + InvalidInputError: If neither or both of `new_triple` and any of `new_sub`/`new_pred`/`new_obj` are provided, + or if input triples are incomplete/invalid. """ - if not (sub_old and pred_old and obj_old): + elems_given = new_sub is not None or new_pred is not None or new_obj is not None + if (new_triple and elems_given) or (not new_triple and not elems_given): raise InvalidInputError( - "All parts of the triple to update (sub_old, pred_old, obj_old) must be provided." + "Either 'new triple' or 'new_sub/new_pred/new_obj' must be provided, not both." ) - if sub_new is None and pred_new is None and obj_new is None: - raise InvalidInputError( - "At least one of sub_new, pred_new, or obj_new must be provided." - ) + old_triple = utils.sanitize_triple(old_triple) + new_triple = utils.sanitize_triple( + new_triple or (new_sub, new_pred, new_obj), + allow_partial=True, + ) - sub_old = utils.prepare_subject(sub_old, ensure_iri=True) - pred_old = utils.prepare_predicate(pred_old, ensure_iri=True) - obj_old = utils.prepare_object(obj_old, as_string=True) + named_graph = IRI(named_graph) if named_graph is not None else self.named_graph if check_exist: - if not self.triple_exists( - sub_old, - pred_old, - obj_old, - ): - self.logger.warning(f"Triple does not exist: {sub_old} {pred_old} {obj_old}") + if not self.triple_exists(old_triple, named_graph=named_graph): + self.logger.warning( + f"Triple does not exist: ({utils.triple_to_string(old_triple)})" + ) return False - if sub_new is not None: - sub_new = utils.prepare_subject(sub_new, ensure_iri=True) - if pred_new is not None: - pred_new = utils.prepare_predicate(pred_new, ensure_iri=True) - if obj_new is not None: - obj_new = utils.prepare_object(obj_new, as_string=True) - # Determine replacement variables - update_sub = sub_new if sub_new else sub_old - update_pred = pred_new if pred_new else pred_old - update_obj = obj_new if obj_new else obj_old + update_triple = tuple(n if n else o for o, n in zip(old_triple, new_triple)) - query = SPARQLQuery( - named_graph=self._named_graph, - prefixes=self._prefixes, + query = SPARQLQuery.delete_insert_data( + delete_triples=[old_triple], + insert_triples=[update_triple], + where_clauses=[utils.triple_to_string(old_triple, ".")], + named_graph=named_graph, ) - query.add_delete_insert_data_block( - delete_triples=[(sub_old, pred_old, obj_old)], - insert_triples=[(update_sub, update_pred, update_obj)], - where_clauses=[f"{sub_old} {pred_old} {obj_old} ."], - ) - query_string = query.to_string(validate=True) - if query_string is None: - return False - - result = self.query(query=query_string, update=True) - - if result: - self.logger.debug( - f"Successfully updated triple to: {update_sub} {update_pred}" - f" {update_obj}, named_graph: {self._named_graph}, repository:" - f" {self._repository}" - ) - else: + result = self.query(query=query, update=True) + if not result: self.logger.warning( - f"Failed to update triple to: {update_sub} {update_pred}" - f" {update_obj}, named_graph: {self._named_graph}, repository:" - f" {self._repository}" + f"Failed to update triple to: ({utils.triple_to_string(update_triple)}), named_graph: {named_graph or "default"}, repository: {self._repository}" ) + return False - return result + self.logger.debug( + f"Successfully updated triple to: ({utils.triple_to_string(update_triple)}), named_graph: {named_graph or "default"}, repository: {self._repository}" + ) + return True diff --git a/graph_db_interface/sparql_query.py b/graph_db_interface/sparql_query.py index c915f78..1be2d46 100644 --- a/graph_db_interface/sparql_query.py +++ b/graph_db_interface/sparql_query.py @@ -1,6 +1,9 @@ from enum import Enum -from typing import List, Optional, Dict, Tuple +from typing import List, Optional from graph_db_interface.utils import utils +from graph_db_interface.utils.iri import IRI +from graph_db_interface.utils.types import TriplesLike, GraphNameLike + class SPARQLQueryType(Enum): """Enum for different SPARQL query types.""" @@ -16,43 +19,93 @@ class SPARQLQueryType(Enum): DELETE_DATA = "DELETE DATA" DELETE_INSERT = "DELETE/INSERT" + class SPARQLQuery: + """ + Helper for composing and validating SPARQL queries. + + Builds SELECT/ASK and UPDATE blocks (INSERT/DELETE) with optional default + named graph and control over explicit/implicit inference. + """ + def __init__( self, - named_graph: Optional[str] = None, - prefixes: Optional[Dict[str, str]] = None, - include_explicit: bool = True, - include_implicit: bool = True, + named_graph: Optional[GraphNameLike] = None, + include_explicit: Optional[bool] = True, + include_implicit: Optional[bool] = True, ): - self._named_graph = named_graph - self._prefixes = prefixes + self._named_graph = IRI(named_graph) if named_graph is not None else None self._include_explicit = include_explicit self._include_implicit = include_implicit self._query_blocks = [] + @classmethod + def select( + cls, + variables: List[str], + where_clauses: List[str], + select_type: Optional[SPARQLQueryType] = SPARQLQueryType.SELECT, + **kwargs, + ) -> "SPARQLQuery": + """ + Create a new SPARQLQuery instance with a SELECT block. + Refer to `add_select_block` for details. + """ + query = cls(**kwargs) + query.add_select_block(variables, where_clauses, select_type) + return query + def add_select_block( self, variables: List[str], where_clauses: List[str], - select_type: SPARQLQueryType = SPARQLQueryType.SELECT, - ) -> str: + select_type: Optional[SPARQLQueryType] = SPARQLQueryType.SELECT, + ) -> None: + """ + Add a SELECT block to the query. + + Args: + variables (List[str]): Variable names to project (e.g., ["?s", "?p", "?o"]). + where_clauses (List[str]): WHERE patterns/filters to include. + select_type (Optional[SPARQLQueryType]): SELECT variant (e.g., DISTINCT). Defaults to SELECT. + """ block_parts = [] block_parts.append( f"{select_type.value} {self._create_variable_string(variables)}" ) part = self._add_explicit_implicit() if self._named_graph: - block_parts.append(f"FROM {utils.ensure_absolute(self._named_graph)}") + block_parts.append(f"FROM {self._named_graph.n3()}") if part: block_parts.append(part) block_parts.append(f"WHERE {{{self._combine_where_clauses(where_clauses)}}}") block = "\n".join(block_parts) self._query_blocks.append({"type": select_type, "data": block}) + @classmethod + def ask( + cls, + where_clauses: List[str], + **kwargs, + ) -> "SPARQLQuery": + """ + Create a new SPARQLQuery instance with a ASK block. + Refer to `add_ask_block` for details. + """ + query = cls(**kwargs) + query.add_ask_block(where_clauses) + return query + def add_ask_block( self, where_clauses: List[str], - ) -> str: + ) -> None: + """ + Add an ASK block to the query. + + Args: + where_clauses (List[str]): WHERE patterns/filters to include. + """ block_parts = [] block_parts.append("ASK") part = self._add_explicit_implicit() @@ -68,13 +121,34 @@ def add_ask_block( block = "\n".join(block_parts) self._query_blocks.append({"type": SPARQLQueryType.ASK, "data": block}) + @classmethod + def insert_data( + cls, + triples: TriplesLike, + **kwargs, + ) -> "SPARQLQuery": + """ + Create a new SPARQLQuery instance with an INSERT DATA block. + Refer to `add_insert_data_block` for details. + """ + query = cls(**kwargs) + query.add_insert_data_block(triples) + return query + def add_insert_data_block( self, - triples: List[Tuple[str]], - ) -> str: + triples: TriplesLike, + ) -> None: + """ + Add an INSERT DATA block comprised of triples. + + Args: + triples (TriplesLike): Triples to insert. + """ block_parts = [] data_combined = "\n".join( - f"{triple[0]} {triple[1]} {triple[2]} ." for triple in triples + utils.triple_to_string(utils.sanitize_triple(triple), ".") + for triple in triples ) block_parts.append( f"""INSERT DATA {{ @@ -85,13 +159,34 @@ def add_insert_data_block( block = "\n".join(block_parts) self._query_blocks.append({"type": SPARQLQueryType.INSERT_DATA, "data": block}) + @classmethod + def insert_exists( + cls, + triples: TriplesLike, + **kwargs, + ) -> "SPARQLQuery": + """ + Create a new SPARQLQuery instance with an INSERT ... WHERE NOT EXISTS block. + Refer to `add_insert_exists_block` for details. + """ + query = cls(**kwargs) + query.add_insert_exists_block(triples) + return query + def add_insert_exists_block( self, - triples: List[Tuple[str]], - ) -> str: + triples: TriplesLike, + ) -> None: + """ + Add an INSERT ... WHERE NOT EXISTS block. + + Args: + triples (TriplesLike): Triples to insert when they do not already exist. + """ block_parts = [] data_combined = "\n".join( - f"{triple[0]} {triple[1]} {triple[2]} ." for triple in triples + utils.triple_to_string(utils.sanitize_triple(triple), ".") + for triple in triples ) block_parts.append( f"""INSERT {{ @@ -103,15 +198,38 @@ def add_insert_exists_block( """ ) block = "\n".join(block_parts) - self._query_blocks.append({"type": SPARQLQueryType.INSERT_EXISTS, "data": block}) + self._query_blocks.append( + {"type": SPARQLQueryType.INSERT_EXISTS, "data": block} + ) + + @classmethod + def delete_data( + cls, + triples: TriplesLike, + **kwargs, + ) -> "SPARQLQuery": + """ + Create a new SPARQLQuery instance with a DELETE DATA block. + Refer to `add_delete_data_block` for details. + """ + query = cls(**kwargs) + query.add_delete_data_block(triples) + return query def add_delete_data_block( self, - triples: List[Tuple[str]], - ) -> str: + triples: TriplesLike, + ) -> None: + """ + Add a DELETE DATA block comprised of triples. + + Args: + triples (TriplesLike): Triples to delete. + """ block_parts = [] data_combined = "\n".join( - f"{triple[0]} {triple[1]} {triple[2]} ." for triple in triples + utils.triple_to_string(utils.sanitize_triple(triple), ".") + for triple in triples ) block_parts.append( f"""DELETE DATA {{ @@ -122,22 +240,52 @@ def add_delete_data_block( block = "\n".join(block_parts) self._query_blocks.append({"type": SPARQLQueryType.DELETE_DATA, "data": block}) + @classmethod + def delete_insert_data( + cls, + delete_triples: TriplesLike, + insert_triples: TriplesLike, + where_clauses: List[str], + **kwargs, + ) -> "SPARQLQuery": + """ + Create a new SPARQLQuery instance with a DELETE DATA block. + Refer to `add_delete_insert_data_block` for details. + """ + query = cls(**kwargs) + query.add_delete_insert_data_block( + delete_triples, + insert_triples, + where_clauses, + ) + return query + def add_delete_insert_data_block( self, - delete_triples: List[Tuple[str]], - insert_triples: List[Tuple[str]], + delete_triples: TriplesLike, + insert_triples: TriplesLike, where_clauses: List[str], - ): + ) -> None: + """ + Add a combined DELETE/INSERT block with WHERE. + + Args: + delete_triples (TriplesLike): Triples to delete. + insert_triples (TriplesLike): Triples to insert. + where_clauses (List[str]): WHERE patterns selecting the triples to update. + """ block_parts = [] if self._named_graph: - block_parts.append(f"WITH {utils.ensure_absolute(self._named_graph)}") + block_parts.append(f"WITH {self._named_graph.n3()}") delete_triples_combined = "\n".join( - f"{triple[0]} {triple[1]} {triple[2]} ." for triple in delete_triples + utils.triple_to_string(utils.sanitize_triple(triple), ".") + for triple in delete_triples ) block_parts.append(f"DELETE {{{delete_triples_combined}}}") insert_triples_combined = "\n".join( - f"{triple[0]} {triple[1]} {triple[2]} ." for triple in insert_triples + utils.triple_to_string(utils.sanitize_triple(triple), ".") + for triple in insert_triples ) block_parts.append(f"INSERT {{{insert_triples_combined}}}") @@ -147,36 +295,69 @@ def add_delete_insert_data_block( {"type": SPARQLQueryType.DELETE_INSERT, "data": block} ) - def _create_variable_string(self, variables: List[str]) -> str: - """Create a string representation of the variables for the SELECT query.""" + def _create_variable_string( + self, + variables: List[str], + ) -> str: + """ + Create a string representation of projected variables. + + Args: + variables (List[str]): Variables to project. + + Returns: + str: Space-separated variables or "*" if empty. + """ return " ".join(variables) if variables else "*" - def _combine_where_clauses(self, where_clauses: List[str]) -> str: + def _combine_where_clauses( + self, + where_clauses: List[str], + ) -> str: + """ + Combine WHERE clauses into a single string separated by newlines. + + Args: + where_clauses (List[str]): WHERE clause strings. + + Returns: + str: Combined WHERE content. + """ if len(where_clauses) >= 1: return "\n".join(where_clauses) else: return "" - def _get_prefix_string(self) -> str: - return ( - "\n".join( - f"PREFIX {prefix}: {iri}" for prefix, iri in self._prefixes.items() - ) - + "\n" - ) - def _add_explicit_implicit(self) -> Optional[str]: + """ + Generate a FROM clause for explicit/implicit inclusion. + + Returns: + Optional[str]: A `FROM <...>` clause or `None` when both are included. + """ if self._include_explicit and not self._include_implicit: - return "FROM onto:explicit" + return f"FROM " elif self._include_implicit and not self._include_explicit: - return "FROM onto:implicit" + return f"FROM " return None - def to_string(self, validate: bool = True) -> str: - query_parts = [] - if self._prefixes: - query_parts.append(self._get_prefix_string()) + def to_string( + self, + validate: Optional[bool] = True, + ) -> str: + """ + Compose the full SPARQL string and optionally validate it. + Args: + validate (Optional[bool]): If True, validate the query or update structure. + + Returns: + str: The composed query string. + + Raises: + InvalidQueryError: If validation of the composed query fails. + """ + query_parts = [] for block in self._query_blocks: query_parts.append(block["data"]) @@ -199,4 +380,4 @@ def to_string(self, validate: bool = True) -> str: ): # Validate the update query utils.validate_update_query(query) - return query \ No newline at end of file + return query diff --git a/graph_db_interface/utils/graph_db_credentials.py b/graph_db_interface/utils/graph_db_credentials.py index 43aeb7e..33d7da4 100644 --- a/graph_db_interface/utils/graph_db_credentials.py +++ b/graph_db_interface/utils/graph_db_credentials.py @@ -1,35 +1,43 @@ +from __future__ import annotations + from dataclasses import dataclass import os + @dataclass(frozen=True) class GraphDBCredentials: """ - A class representing database credentials for connecting to a graph database. - - Attributes: - host (str): The hostname or IP address of the database server. - username (str): The username for authenticating with the database. - password (str): The password for authenticating with the database. - database_name (str): The name of the specific database to connect to. - + Immutable credentials for connecting to a GraphDB instance. + + Contains the base URL, user credentials, and target repository used by + the client to authenticate and perform SPARQL operations. + + Args: + base_url (str): Base URL of the GraphDB instance (e.g., "http://localhost:7200"). + username (str): Username for GraphDB authentication. + password (str): Password for GraphDB authentication. + repository (str): Repository identifier to target within the instance. """ + base_url: str username: str password: str repository: str @classmethod - def from_env(cls): - ''' - Create a GraphDB instance using environment variables. The following environment variables must be set: - - `GRAPHDB_USERNAME`: The username for GraphDB authentication. - - `GRAPHDB_PASSWORD`: The password for GraphDB authentication. - - `GRAPHDB_URL`: The base URL of the GraphDB instance. - - `GRAPHDB_REPOSITORY`: The name of the GraphDB repository to use. + def from_env(cls) -> GraphDBCredentials: + """ + Build credentials from environment variables. + + The following environment variables must be set: `GRAPHDB_USERNAME`, + `GRAPHDB_PASSWORD`, `GRAPHDB_URL`, and `GRAPHDB_REPOSITORY`. + + Returns: + GraphDBCredentials: A credentials instance populated from the environment. Raises: - ValueError: If any of the required environment variables are not set. - ''' + ValueError: If any of the required environment variables are missing. + """ if os.getenv("GRAPHDB_USERNAME") is None: raise ValueError("GRAPHDB_USERNAME environment variable is not set.") @@ -49,13 +57,5 @@ def from_env(cls): base_url=base_url, username=username, password=password, - repository=repository + repository=repository, ) - - def __iter__(self): - return iter(( - self.base_url, - self.username, - self.password, - self.repository, - )) \ No newline at end of file diff --git a/graph_db_interface/utils/iri.py b/graph_db_interface/utils/iri.py new file mode 100644 index 0000000..a2e7b53 --- /dev/null +++ b/graph_db_interface/utils/iri.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +from graph_db_interface.exceptions import InvalidIRIError + +from typing import Optional, Any, Dict +from rdflib import URIRef, Literal +from pydantic import GetCoreSchemaHandler +from pydantic_core import CoreSchema, core_schema +import regex as re + + +class IRI(URIRef): + """ + Lightweight IRI wrapper with prefix support and validation. + + The stored format is a full IRI string, eg. `http://example.org#Name`. + Prefixes are resolved from the provided dictionary or class-level registry. + + Valid input formats are: + - Full IRIs without fragments: `http://example.org` + - Prefixes without fragments: `prefix:` + - Full IRI with fragment: `http://example.org#Name` + - Prefixes with fragments: `prefix:Name` + + Validation supports: + - `str`, `rdflib.URIRef`, `IRI` or other subclass of `str` inputs. + `rdflib.Literal` or non-string inputs raise `TypeError`. + If desired, convert to string explicitly before passing. + - Separate base and name: `base="http://example.org", value="Name"`. + - Angle brackets `<...>`. + - Trailing `#`, `\` or `:` from value and base. + """ + + PREFIXES = { + "owl": "http://www.w3.org/2002/07/owl", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns", + "rdfs": "http://www.w3.org/2000/01/rdf-schema", + "xsd": "http://www.w3.org/2001/XMLSchema", + "kafka": "http://www.ontotext.com/connectors/kafka", + "kafka-inst": "http://www.ontotext.com/connectors/kafka/instance", + } + PREFIXES_INV = {v: k for k, v in PREFIXES.items()} + + # Valid schemes for full IRIs, end with '://' + SCHEMES = {"http://", "https://"} + + # Patterns for lined encoding/decoding. Underscore must be first. + LINED_PATTERN = [ + ("_", "__"), + (":", "_c_"), + ("/", "_s_"), + (".", "_d_"), + ("#", "_h_"), + ] + # Patterns for lined encoding/decoding. Underscore must be last. + LINED_PATTERN_INV = [(b, a) for a, b in reversed(LINED_PATTERN)] + + def __new__( + cls, + value: str, + base: Optional[str] = None, + prefixes: Optional[Dict[str, str]] = None, + ) -> IRI: + """ + Create a new IRI instance with normalization and validation. + + Args: + value (str): Full IRI, shorthand (`prefix:name`), or local name. + base (Optional[str]): Optional base IRI or prefix to combine with `value`. + prefixes (Optional[Dict[str, str]]): Additional prefix mappings to use. + + Returns: + IRI: The normalized IRI instance. + + Raises: + TypeError: If inputs are not strings or are of type `rdflib.Literal`. + If desired, convert to string explicitly before passing. + InvalidIRIError: If inputs cannot be resolved into a valid IRI. + """ + iri = IRI._sanitize(value, base, prefixes) + return super().__new__(cls, iri) + + @property + def short(self) -> str: + """ + Return a prefixed form if the base is registered. + + Returns: + str: `prefix:name` if the base IRI is known; otherwise the full IRI. + """ + onto, fragment = str(self).rsplit("#", 1) + if onto in IRI.PREFIXES_INV: + return f"{IRI.PREFIXES_INV[onto]}:{fragment}" + return self.n3() + + @property + def lined(self) -> str: + """ + Convert IRI to valid Python identifier using reversible marker encoding. + + Character encoding: + - _ → __ (underscore escaped to preserve marker distinction) + - : → _c_ (colon) + - / → _s_ (slash) + - . → _d_ (dot) + - # → _h_ (hash) + + This is a simple, human-readable alternative to Punycode—optimized for + debugging and direct IRI recognition in code, not for compression. + + Markers use mnemonic single letters (_c_=colon, _s_=slash, etc.) for + easy visual decoding. Order of replacement matters for reversibility. + + Example: + https://www.sfb1574.kit.edu/ontologies/TransferUnit#hasConveyorBelt + → https_c_www_d_sfb1574_d_kit_d_edu_s_ontologies_s_TransferUnit_h_hasConveyorBelt + """ + lined = str(self) + for a, b in self.LINED_PATTERN: + lined = lined.replace(a, b) + return lined + + @classmethod + def from_lined(cls, lined: str) -> IRI: + """ + Decode a lined identifier back to full IRI. + + Reverses the encoding applied by `lined` property. + Decode order: _c_→:, _s_→/, _d_→., _h_→#, then __→_. + + Example: https_c__s__s_example_d_com_h_Property → https://example.com#Property + """ + for a, b in cls.LINED_PATTERN_INV: + lined = lined.replace(a, b) + return cls(lined) + + @property + def onto(self) -> str: + """ + Return the ontology/base part of the IRI (before the '#'). + + Returns: + str: The base IRI. + """ + return str(self).rsplit("#", 1)[0] + + def __hash__(self) -> int: + """ + Hash based on the string form to ensure stable behavior in sets/dicts. + + Returns: + int: The hash value. + """ + return str(self).__hash__() + + def __eq__( + self, + other: Any, + ) -> bool: + """ + Compare IRIs by string value with tolerant string inputs. + + Args: + other (Any): Another `IRI`, `URIRef`, or string to compare. + + Returns: + bool: True if the IRIs resolve to the same string form. + """ + # Includes IRI + if isinstance(other, URIRef): + return str(self) == str(other) + # Sanitize string before comparison + if isinstance(other, str): + try: + return self == IRI(other) + except: + return False + return False + + @classmethod + def add_prefix( + cls, + prefix: str, + iri: str, + ) -> None: + """ + Register or overwrite a prefix mapping. + + Args: + prefix (str): Prefix label (e.g., "ex"). + iri (str): Base IRI to map (with or without angle brackets or trailing '#'). + """ + iri = str(IRI(iri)) + cls.PREFIXES[prefix] = iri + cls.PREFIXES_INV[iri] = prefix + + @classmethod + def remove_prefix( + cls, + prefix: str, + ) -> bool: + """ + Remove a registered prefix mapping. + + Args: + prefix (str): The prefix label to remove. + + Returns: + bool: True if the prefix existed and was removed, False otherwise. + """ + if prefix in cls.PREFIXES: + del cls.PREFIXES_INV[cls.PREFIXES[prefix]] + del cls.PREFIXES[prefix] + return True + return False + + @classmethod + def get_prefixes(cls) -> Dict[str, str]: + """ + Get a copy of the current prefix registry. + + Returns: + Dict[str, str]: Mapping of prefix to base IRI (without trailing '#'). + """ + return cls.PREFIXES.copy() + + @classmethod + def _sanitize( + cls, + value: str, + base: Optional[str], + prefixes: Optional[Dict[str, str]] = None, + ) -> str: + """ + Normalize and validate IRI inputs into a canonical string form. + + Args: + value (str): Full IRI, shorthand (`prefix:name`), or local name. + base (Optional[str]): Base IRI or prefix to combine with `value`. + prefixes (Optional[Dict[str, str]]): Additional prefix mappings to use. + + Returns: + str: The normalized IRI string. + + Raises: + TypeError: If inputs are not strings or are of type `rdflib.Literal`. + If desired, convert to string explicitly before passing. + InvalidIRIError: If inputs cannot be resolved into a valid IRI. + """ + if value is None and base is None: + raise InvalidIRIError("Invalid IRI: both value and base cannot be None") + + # Shortcut known good IRIs + if isinstance(value, IRI) and base is None: + return str(value) + + if isinstance(base, IRI) and value is None: + return str(base) + + # Convert value to str + if value is not None: + if isinstance(value, Literal): + raise TypeError( + f"'value' is of type Literal and probably not intended as IRI. Convert to string explicitly if intended: {value}" + ) + if not isinstance(value, str): + raise TypeError( + f"Invalid IRI: value is not a string and probably not intended as IRI. Convert to string explicitly if intended: {value}" + ) + value = str(value) + + if base is not None: + if isinstance(base, Literal): + raise TypeError( + f"'base' is of type Literal and probably not intended as IRI. Convert to string explicitly if intended: {base}" + ) + if not isinstance(base, str): + raise TypeError( + f"Invalid IRI: base is not a string and probably not intended as IRI. Convert to string explicitly if intended: {base}" + ) + base = str(base) + + # Merge base and value + raw_base = base.strip("<>").rstrip("#:/") if base is not None else "" + raw_value = value.strip("<>").rstrip("#:/") if value is not None else "" + if raw_base == "" and raw_value == "": + raise InvalidIRIError("Invalid IRI: empty string") + + if raw_base == "": + raw = raw_value + elif raw_value == "": + raw = raw_base + elif raw_base in IRI.PREFIXES.keys(): + raw = raw_base + ":" + raw_value + else: + raw = raw_base + "#" + raw_value + + # Resolve prefixes and formats + prefixes = IRI.PREFIXES if prefixes is None else {**IRI.PREFIXES, **prefixes} + + # Direct prefix, without fragment + if raw in prefixes: + return prefixes[raw] + + # Full IRI + if any(raw.startswith(scheme) for scheme in IRI.SCHEMES): + if raw.count(":") > 1: + raise InvalidIRIError( + f"Invalid IRI: ':' outside of supported schemes {IRI.SCHEMES} ({value}, {base})" + ) + if raw.count("#") > 1: + raise InvalidIRIError(f"Invalid IRI: multiple '#' in ({value}, {base})") + return raw + + # Mixed or malformed combinations (e.g., 'owl#Class', 'owl:owl#Class', 'owl#owl:Class') + if ":" in raw and "#" in raw: + raise InvalidIRIError( + f"Invalid IRI: mixed '#' and ':' outside of supported schemes {IRI.SCHEMES} in ({value}, {base})" + ) + if "#" in raw: + raise InvalidIRIError(f"Invalid IRI: malformed format ({value}, {base})") + + colon_count = raw.count(":") + if colon_count > 1: + raise InvalidIRIError( + f"Invalid IRI: multiple ':' separators in ({value}, {base})" + ) + if colon_count == 1: + # One ':': 'prefix:fragment' form + prefix, fragment = raw.split(":") + if prefix not in prefixes: + raise InvalidIRIError(f"Invalid IRI: unknown prefix '{prefix}'") + return prefixes[prefix] + "#" + fragment + + raise InvalidIRIError( + f"Invalid IRI: malformed format or unknown prefix ({value}, {base})" + ) + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> CoreSchema: + """ + Provide a permissive Pydantic core schema for IRI fields. + + Args: + source_type (Any): The source type passed by Pydantic. + handler (GetCoreSchemaHandler): Pydantic schema handler. + + Returns: + CoreSchema: A schema accepting any value (validated by IRI itself). + """ + return core_schema.any_schema() diff --git a/graph_db_interface/utils/pretty_print.py b/graph_db_interface/utils/pretty_print.py index 8c57780..26e3797 100644 --- a/graph_db_interface/utils/pretty_print.py +++ b/graph_db_interface/utils/pretty_print.py @@ -1,36 +1,69 @@ from __future__ import annotations from typing import Any, Optional import json +from graph_db_interface.utils.iri import IRI +import re -def format_result(result: list[tuple[str]] | tuple[tuple[str, ...], ...] | dict[Any, Any] | list[dict], variables: Optional[list[str]] = None, grouping_variables: Optional[list[str]] = None) -> str: - """Return a human-readable string for SPARQL query results. + +def shorten_block(s: str) -> str: + """ + Search a text for IRI patterns with known namespaces and shorten them. + """ + # Pattern to find quoted strings + quote_pattern = re.compile(r'"([^"]*)"') + + def replace_iri(match: re.Match) -> str: + content = match.group(1) + # Skip empty strings + if not content: + return match.group(0) + try: + # Try to create an IRI and get its short form + shortened = IRI(content).short + return f'"{shortened}"' + except Exception: + # If it's not a valid IRI, return the original match + return match.group(0) + + return quote_pattern.sub(replace_iri, s) + + +def format_result( + result: ( + list[tuple[str]] | tuple[tuple[str, ...], ...] | dict[Any, Any] | list[dict] + ), + variables: Optional[list[str]] = None, + grouping_variables: Optional[list[str]] = None, +) -> str: + """ + Return a human-readable string for SPARQL query results. This utility formats different shapes of results produced by SPARQL SELECT queries into an easy-to-read textual representation used in logs or CLI output. - Parameters: - result: One of the following structures: + Args: + result (list[tuple[str]] | tuple[tuple[str, ...], ...] | dict[Any, Any] | list[dict]): + One of the following structures: - tuple of tuples: tabular rows, where each inner tuple corresponds to a row - ordered by ``variables``. - - tuple of scalars: a single-column result (when ``variables`` has length 1). - - dict: a nested mapping keyed by the values of ``grouping_variables`` where the leaves - are tuples (or empty tuples) representing the non-grouped ``variables``. + ordered by `variables`. + - tuple of scalars: a single-column result (when `variables` has length 1). + - dict: a nested mapping keyed by the values of `grouping_variables` where the leaves + are tuples (or empty tuples) representing the non-grouped `variables`. - list[dict]: raw JSON bindings from a SPARQL endpoint (each dict is a binding); this is rendered as a compact pretty-printed JSON-like list. - variables: List of non-grouped variable names (without leading ``?``) that define the - order and width of columns when rendering tabular/tuple results. May be ``None`` when - rendering grouped structures only. - grouping_variables: List of grouped variable names (without leading ``?``) that define the - nesting order for dictionary results. When present, headers are annotated by the current - grouping key at each level. + variables (Optional[list[str]]): Non-grouped variable names (without leading `?`) that + define the order and width of columns when rendering tabular/tuple results. + grouping_variables (Optional[list[str]]): Grouped variable names (without leading `?`) that + define the nesting order for dictionary results. When present, headers are annotated by + the current grouping key at each level. Returns: - A formatted multi-line string. For empty inputs the string explicitly indicates which + str: A formatted multi-line string. For empty inputs the string explicitly indicates which variables and/or grouping variables were expected. Notes: - This function does not mutate the input data. - - Widths for tabular output are computed from both headers (``variables``) and the values. + - Widths for tabular output are computed from both headers (`variables`) and the values. """ if not result: @@ -43,21 +76,29 @@ def format_result(result: list[tuple[str]] | tuple[tuple[str, ...], ...] | dict[ out += f"({', '.join(v for v in variables)})" return out - if isinstance(result, list) and result and isinstance(result[0], dict): # raw JSON bindings + if ( + isinstance(result, list) and result and isinstance(result[0], dict) + ): # raw JSON bindings return f"Result: {_format_raw_json_bindings(result)}" - elif isinstance(result, dict): # nested structure - return f"Result: {_format_nested_structure(result, variables, grouping_variables)}" - else: # tuple of tuples or tuple of scalars + elif isinstance(result, dict): # nested structure + return ( + f"Result: {_format_nested_structure(result, variables, grouping_variables)}" + ) + else: # tuple of tuples or tuple of scalars return f"Result: {_format_entry(result, variables)}" -def _format_raw_json_bindings(result: list[dict]) -> str: - """Format raw JSON bindings (list of dicts) as pretty-printed JSON. - Parameters: - result: A list of dictionaries representing SPARQL query bindings. +def _format_raw_json_bindings( + result: list[dict], +) -> str: + """ + Format raw JSON bindings (list of dicts) as pretty-printed JSON. + + Args: + result (list[dict]): A list of dictionaries representing SPARQL query bindings. Returns: - A formatted JSON string representation of the input list. + str: A formatted JSON string representation of the input list. """ try: return json.dumps(result, indent=2, ensure_ascii=False) @@ -69,18 +110,25 @@ def _format_raw_json_bindings(result: list[dict]) -> str: rendered_items.append(entries) return "[{" + "},\n\t {".join(rendered_items) + "}]" -def _format_nested_structure(structure, variables: Optional[list[str]], grouping_variables: Optional[list[str]], level: int = 0) -> str: - """Format a nested dictionary result structure with indentation. - Parameters: - structure: A nested dict keyed by the values of ``grouping_variables`` where leaves are - tuples (or empty tuples) representing the non-grouped ``variables``. - variables: The non-grouped variable names used to render leaf tuples. - grouping_variables: Grouped variable names used to annotate each nesting level. - level: Current recursion depth used only for indentation. +def _format_nested_structure( + structure, + variables: list[str], + grouping_variables: list[str], + level: Optional[int] = 0, +) -> str: + """ + Format a nested dictionary result structure with indentation. + + Args: + structure (dict): Nested dict keyed by the values of `grouping_variables` where leaves are + tuples (or empty tuples) representing the non-grouped `variables`. + variables (list[str]): The non-grouped variable names used to render leaf tuples. + grouping_variables (list[str]): Grouped variable names used to annotate each nesting level. + level (Optional[int]): Current recursion depth used only for indentation. Returns: - A multi-line, indented string representation of the nested structure. + str: A multi-line, indented string representation of the nested structure. """ indent = " " * level if not structure: @@ -90,23 +138,31 @@ def _format_nested_structure(structure, variables: Optional[list[str]], grouping return "{}" items = [] for key, value in structure.items(): - formatted_value = _format_nested_structure(value, variables, grouping_variables, level + 1) + formatted_value = _format_nested_structure( + value, variables, grouping_variables, level + 1 + ) items.append(f"{indent} {key}: {formatted_value}") - return f"{{ # {grouping_variables[level]}\n" + ",\n".join(items) + f"\n{indent}}}" + return ( + f"{{ # {grouping_variables[level]}\n" + ",\n".join(items) + f"\n{indent}}}" + ) return f"\n {_format_entry(structure, variables, indent[:-1])}" -def _format_entry(structure: tuple, variables: list[str], indent: str = "") -> str: - """Format a flat tuple or tuple-of-tuples as aligned columns. - Parameters: - structure: Either a tuple of scalars (single-column) or a tuple of tuples (multi-column). - variables: Column headers (names without leading ``?``) used for width computation - and header rendering. - indent: Optional left indentation for multi-line alignment. +def _format_entry( + structure: tuple, + variables: list[str], + indent: Optional[str] = "", +) -> str: + """ + Format a flat tuple or tuple-of-tuples as aligned columns. + + Args: + structure (tuple): Either a tuple of scalars (single-column) or a tuple of tuples (multi-column). + variables (list[str]): Column headers (without leading `?`) used for width computation and header rendering. + indent (Optional[str]): Optional left indentation for multi-line alignment. Returns: - A string with a header row and aligned values. Single-column outputs render as a - vertical list; multi-column outputs render as a table-like block. + str: A header row and aligned values. Single-column outputs render as a vertical list; multi-column outputs render as a table-like block. """ if len(variables) == 1: lmax = len(variables[0]) @@ -123,5 +179,5 @@ def _format_entry(structure: tuple, variables: list[str], indent: str = "") -> s lmax[i] = max(lmax[i], len(re)) var_names = " ".join(f"{re:<{lmax[i]}}" for i, re in enumerate(variables)) rows = [" , ".join(f"{re:<{lmax[i]}}" for i, re in enumerate(s)) for s in structure] - content = f' ),\n{indent} ( '.join(rows) + content = f" ),\n{indent} ( ".join(rows) return f"{indent}# {var_names}\n{indent} (( {content} ))" diff --git a/graph_db_interface/utils/processing.py b/graph_db_interface/utils/processing.py index 795e285..dc9a7a4 100644 --- a/graph_db_interface/utils/processing.py +++ b/graph_db_interface/utils/processing.py @@ -1,4 +1,5 @@ from typing import Optional, Any, Hashable +from graph_db_interface.utils.utils import convert_multi_bindings_to_python_type def process_bindings_select( @@ -6,75 +7,40 @@ def process_bindings_select( variables: Optional[list[str]] = None, grouping_variables: Optional[list[str]] = None, ) -> tuple[tuple[Any, ...], ...] | tuple[Any, ...] | dict: - """Process SPARQL bindings from a SELECT query into tuples or a nested dictionary structure. + """ + Process SPARQL SELECT bindings into tuples or a nested dictionary. - This helper takes the raw SPARQL JSON bindings (``results.bindings``) and - converts them into a convenient Python structure. + Converts raw SPARQL JSON bindings (`results.bindings`) into a convenient Python + structure for downstream processing and display. - Parameters: - bindings (list[dict]): Raw bindings from SPARQL query results - (i.e., ``response['results']['bindings']``). - variables (Optional[list[str]]): Variable names (without leading ``?``) - that define the tuple entries and their order in the result. - grouping_variables (Optional[list[str]]): Grouping variable names (without leading ``?``) + Args: + bindings (list[dict[str, dict[str, Any]]]): Raw bindings from SPARQL query results + (i.e., `response['results']['bindings']`). + variables (Optional[list[str]]): Variable names (without leading `?`) that define the + tuple entries and their order in the result. + grouping_variables (Optional[list[str]]): Grouping variable names (without leading `?`) that define hierarchical dictionary keys in order. When provided, the result is a - nested dict keyed by the values of these variables, with leaves as described below. + nested dict keyed by these values, with leaves as described below. Returns: - tuple[tuple[Any, ...], ...] | tuple[Any, ...] | dict: - When grouping_variables is not provided: - - variables is empty or None -> () (empty tuple) - - variables has length 1 -> (a1, a2, ...) (tuple of scalar values) - - variables has length > 1 -> ((a1, b1, ...), (a2, b2, ...), ...) (tuple of tuples) - When grouping_variables is provided: - - Nested dict keyed by the values of grouping_variables (in order) - - Each leaf is processed using the entries in variables (shapes as above) + tuple[tuple[Any, ...], ...] | tuple[Any, ...] | dict: When `grouping_variables` is not provided: + - variables is empty or None -> () (empty tuple) + - variables has length 1 -> (a1, a2, ...) (tuple of scalar values) + - variables has length > 1 -> ((a1, b1, ...), (a2, b2, ...), ...) (tuple of tuples) + When `grouping_variables` is provided: + - Nested dict keyed by the values of `grouping_variables` (in order) + - Each leaf is processed using the entries in `variables` (shapes as above) Raises: - AssertionError: If any of the values referenced by ``grouping_variables`` in the - first binding are not hashable. Keys in ``grouping_variables`` must map to hashable - values to be usable as dictionary keys. - TypeError: If the bindings are empty and neither ``variables`` nor ``grouping_variables`` are provided. + AssertionError: If any values referenced by `grouping_variables` in the first binding + are not hashable. Grouping keys must be hashable to serve as dict keys. + TypeError: If the bindings are empty and neither `variables` nor `grouping_variables` are provided. Notes: - - ``variables`` and ``grouping_variables`` are expected without the leading ``?``. - - If neither ``variables`` nor ``grouping_variables`` are provided, the ``variables`` are inferred from the bindings. - - Example: - 1) Without grouping: - ``` - grouping_variables = None - variables = ["?machine", "?part", "?parameter", "?value"] - result -> ( - ("machine1", "part1", "parameter1", "value1"), - ("machine1", "part1", "parameter2", "value2"), - ("machine1", "part2", "parameter1", "value3"), - ("machine1", "part2", "parameter2", "value4"), - ... - ) - ``` - 2) With grouping: - ``` - grouping_variables = ["?machine", "?part"] - variables = ["?parameter", "?value"] - result -> { - "machine1": { - "part1": ( - ("parameter1", "value1"), - ("parameter2", "value2"), - ... - ), - "part2": ( - ("parameter1", "value3"), - ("parameter2", "value4"), - ... - ), - ... - }, - ... - } - ``` + - `variables` and `grouping_variables` are expected without the leading `?`. + - If neither is provided, `variables` are inferred from the first binding when available. """ + bindings = convert_multi_bindings_to_python_type(bindings) if not variables and not grouping_variables: assert bindings, TypeError( @@ -86,19 +52,21 @@ def process_bindings_select( if not variables: extract_entry = lambda binding: None elif len(variables) == 1: - extract_entry = lambda binding: binding[variables[0]]["value"] + extract_entry = lambda binding: binding[variables[0]] else: extract_entry = lambda binding: tuple( - binding[variable]["value"] for variable in variables + binding[variable] for variable in variables ) if not grouping_variables: return tuple(extract_entry(binding) for binding in bindings) else: # Process with grouping variables + if not bindings: + return {} + assert all( - isinstance(bindings[0][key]["value"], Hashable) - for key in grouping_variables + isinstance(bindings[0][key], Hashable) for key in grouping_variables ), TypeError("All datatypes in grouping_variables must be hashable.") result = {} @@ -107,9 +75,9 @@ def process_bindings_select( for binding in bindings: leaf = result for key in grouping_variables[:-1]: - key_value = binding[key]["value"] + key_value = binding[key] leaf = leaf.setdefault(key_value, {}) - key_value = binding[grouping_variables[-1]]["value"] + key_value = binding[grouping_variables[-1]] leaf.setdefault(key_value, []).append(extract_entry(binding)) leaf_dicts.append(leaf) diff --git a/graph_db_interface/utils/types.py b/graph_db_interface/utils/types.py new file mode 100644 index 0000000..944a538 --- /dev/null +++ b/graph_db_interface/utils/types.py @@ -0,0 +1,41 @@ +from typing import Any, TypeAlias, Union, Set, List, Optional, Tuple +from rdflib import Literal, BNode + +from graph_db_interface.utils.iri import IRI + +GraphName: TypeAlias = IRI + +Subject: TypeAlias = Union[IRI, BNode] +Predicate: TypeAlias = IRI +Object: TypeAlias = Union[IRI, BNode, Literal] + +Triple: TypeAlias = Tuple[Subject, Predicate, Object] +PartialTriple: TypeAlias = Tuple[ + Optional[Subject], Optional[Predicate], Optional[Object] +] + +Triples: TypeAlias = Union[Set[Triple], List[Triple], Tuple[Triple]] +PartialTriples: TypeAlias = Union[ + Set[PartialTriple], List[PartialTriple], Tuple[PartialTriple] +] + + +IRILike: TypeAlias = Union[str, IRI] +BNodeLike: TypeAlias = Union[str, BNode] +LiteralLike: TypeAlias = Union[Any, Literal] + +GraphNameLike: TypeAlias = IRILike + +SubjectLike: TypeAlias = Union[IRILike, BNodeLike] +PredicateLike: TypeAlias = IRILike +ObjectLike: TypeAlias = Union[IRILike, BNodeLike, LiteralLike] + +TripleLike: TypeAlias = Tuple[SubjectLike, PredicateLike, ObjectLike] +PartialTripleLike: TypeAlias = Tuple[ + Optional[SubjectLike], Optional[PredicateLike], Optional[ObjectLike] +] + +TriplesLike: TypeAlias = Union[Set[TripleLike], List[TripleLike], Tuple[TripleLike]] +PartialTriplesLike: TypeAlias = Union[ + Set[PartialTripleLike], List[PartialTripleLike], Tuple[PartialTripleLike] +] diff --git a/graph_db_interface/utils/utils.py b/graph_db_interface/utils/utils.py index f143faa..10e410e 100644 --- a/graph_db_interface/utils/utils.py +++ b/graph_db_interface/utils/utils.py @@ -1,311 +1,401 @@ import logging -from typing import Union, Dict, Optional, Any -from rdflib import URIRef, Literal, XSD, Dataset +import re +from typing import List, Union, Optional, Any, Dict +from rdflib import Literal, XSD, Dataset, BNode, URIRef from rdflib.plugins.sparql.processor import prepareQuery -from urllib.parse import urlparse from graph_db_interface.exceptions import ( InvalidInputError, - InvalidIRIError, InvalidQueryError, ) - +from graph_db_interface.utils.iri import IRI +from graph_db_interface.utils.types import ( + BNodeLike, + IRILike, + Triple, + PartialTriple, + TripleLike, + PartialTripleLike, + Subject, + Predicate, + Object, + SubjectLike, + PredicateLike, + ObjectLike, +) +from graph_db_interface.utils.xsd_typemap import XSDToPythonMapper, XSDToPythonTypes LOGGER = logging.getLogger(__name__) +BNODE_PATTERN = re.compile(r"^_:[a-zA-Z][a-zA-Z0-9_\-]*$") -def validate_query(query: str): - try: - # Attempt to prepare the query - prepareQuery(query) - return True - except Exception as e: - error_message = f"SPAQRQL query validation failed: {e}" - LOGGER.error(error_message) - raise InvalidQueryError(error_message) - - -def validate_update_query(query: str): - try: - g = Dataset() - g.update(query) - return True - except Exception as e: - error_message = f"SPAQRQL update query validation failed: {e}" - LOGGER.error(error_message) - raise InvalidQueryError(error_message) - - -def ensure_absolute(iri: str): - """Ensure the IRI is in absolute form enclosed in <>. - If the IRI is already absolute (i.e., enclosed in <>), it returns as is. - Otherwise, it wraps the IRI in <>. +def sanitize_triple( + triple: Union[TripleLike, PartialTripleLike], + allow_partial: Optional[bool] = False, +) -> Union[Triple, PartialTriple]: + """ + Validates and converts the components of a triple to their appropriate types. + + - A valid triple must have three entries (subject, predicate, object) defined. + - If allow_partial is false, no entry may be None. Otherweise, at least one entry + must be not None. + - Subject and predicate must be valid IRIs. + - The object can be either an IRI or a Literal + - If the object is of type IRI, URIRef, or str, it will be converted to IRI. + - If an object of type str should be treated as a Literal, it must be + explicitly converted to Literal before passing. + - If the object is of any other type, it will be converted to a Literal. Args: - iri (str): The input IRI. + triple (Union[TripleLike, PartialTripleLike]): The triple to validate and convert. + allow_partial (Optional[bool]): Whether to allow partial triples. Defaults to False. Returns: - str: The absolute IRI in <> format. + Union[Triple, PartialTriple]: The validated and converted triple. + Raises: + InvalidInputError: If the triple does not have three or too many None entries. + TypeError: If subject or predicate are not of type str, URIRef, or IRI. + InvalidIRIError: If object is of type str or URIRef (except Literal) and not in a valid IRI format. """ - iri = iri.strip() - # Check if already enclosed in <> - if iri.startswith("<") and iri.endswith(">"): - return iri + if allow_partial: + if len(triple) != 3 or all(e is None for e in triple): + error_message = f"Triple requires three components, at least one of which is not None: {triple}" + LOGGER.error(error_message) + raise InvalidInputError(error_message) + else: + if len(triple) != 3 or any(e is None for e in triple): + error_message = ( + f"Triple requires three components, neither of which is None: {triple}" + ) + LOGGER.error(error_message) + raise InvalidInputError(error_message) - return f"<{iri}>" + sub, pred, obj = triple + if sub is not None and not isinstance(sub, Subject): + sub = _to_subject(sub) + if pred is not None and not isinstance(pred, Predicate): + pred = _to_predicate(pred) + if obj is not None and not isinstance(obj, Object): + obj = _to_object(obj) -def is_absolute(iri: str) -> bool: - """Check if the IRI is in absolute form. + return sub, pred, obj - Args: - iri (str): The input IRI. - Returns: - bool: True if the IRI is absolute, False otherwise. - """ - return iri.startswith("<") and iri.endswith(">") +def _to_iri_or_bnode(value: Union[IRILike, BNodeLike]) -> Union[IRI, BNode]: + """Try converting a IRILike or BNodeLike.""" + if isinstance(value, Union[IRI, BNode]): + return value + if BNODE_PATTERN.match(str(value)): + return BNode(value[2:]) # Remove '_:' prefix for BNode id -def strip_angle_brackets(iri: str) -> str: - """Strip the angle brackets from the IRI if present. + return IRI(value) - Args: - iri (str): The input IRI. - Returns: - str: The IRI without angle brackets. - """ - # Remove angle brackets if they exist - return iri[1:-1] if is_absolute(iri) else iri +def _to_subject(sub: SubjectLike) -> Subject: + """Convert a SubjectLike to a Subject.""" + return _to_iri_or_bnode(sub) -def to_literal(value, datatype=None, as_string: bool = False) -> Union[Literal, str]: - """Convert a Python value to its corresponding XSD literal representation.""" - if isinstance(value, str) and datatype is None: - datatype = XSD.string - literal = Literal(value, datatype=datatype) - # literal = escape_string_literal(literal) - if as_string: - return literal.n3() - return literal +def _to_predicate(pred: PredicateLike) -> Predicate: + """Convert a PredicateLike to a Predicate.""" + return IRI(pred) -def from_xsd_literal(value: str, datatype: str): - """ - Convert a string value to its corresponding Python type based on the XSD datatype. - """ - literal = Literal(value, datatype=datatype) - return literal.toPython() +def _to_object(obj: ObjectLike) -> Object: + """Convert an ObjectLike to an Object.""" + if isinstance(obj, Object): + return obj - -def convert_query_result_to_python_type(result_binding: dict) -> Any: - """Convert a SPARQL query result binding to its corresponding Python type.""" - type = result_binding.get("type") - if type == "literal" and "datatype" in result_binding: - return from_xsd_literal(result_binding["value"], result_binding["datatype"]) + if isinstance(obj, str): + try: + return _to_iri_or_bnode(str(obj)) + except Exception as e: + error_message = f"Object is of type str but cannot be converted to IRI or BNode. If object is a , explicitly convert before passing: {obj} ({e})" + LOGGER.error(error_message) + raise type(e)(error_message) from e else: - # If no datatype is provided, return the value as is - return result_binding["value"] + return Literal(obj) -def get_local_name(iri: str): - iri = URIRef(strip_angle_brackets(iri)) - # If there's a fragment (i.e., the part after '#') - if iri.fragment: - return iri.fragment +def triple_to_string( + triple: TripleLike, + line_end: Optional[str] = None, +) -> str: + """Convert a triple to its string representation suitable for SPARQL queries. - # Otherwise, split by '/' and return the last segment - return iri.split("/")[-1] + Args: + triple (TripleLike): The triple to convert. + Returns: + str: The string representation of the triple. + """ + sub, pred, obj = sanitize_triple(triple) + return f"{sub.n3()} {pred.n3()} {obj.n3()}" + (f" {line_end}" if line_end else "") -def escape_string_literal(value: Union[str, Literal]) -> Union[Literal, str]: - if ( - isinstance(value, Literal) - and isinstance(value.value, str) - # Try to prevent double escaping. - and not '\\"' in value - ): - value = value.replace('"', '\\"') - return Literal(f'"{value}"', datatype=XSD.string) +def validate_query(query: str): + """ + Validate a SPARQL SELECT/ASK query string by parsing it. - return value + Args: + query (str): The SPARQL query to validate. + Returns: + bool: True if parsing succeeds. -def is_iri(value: str) -> bool: - """Checks if the provided value is a valid IRI.""" - stripped = strip_angle_brackets(value) - parseresult = urlparse(stripped) - if not parseresult.scheme or not parseresult.netloc: - return False - return True + Raises: + InvalidQueryError: If parsing fails. + """ + try: + # Attempt to prepare the query + prepareQuery(query) + return True + except Exception as e: + error_message = f"SPAQRQL query validation failed: {e}" + LOGGER.error(error_message) + raise InvalidQueryError(error_message) -def is_shorthand_iri(value: str, prefixes: Optional[Dict[str, str]] = None) -> bool: +def validate_update_query( + query: str, +): """ - Checks if the provided value is in the form of a shorthand IRI (prefix:localName). + Validate a SPARQL UPDATE string by applying it to a temporary dataset. - A shorthand IRI consists of a prefix and a local name separated by a colon (":"). - This function verifies if the given value matches this format and if a dict of prefixes - is given in the provided dictionary of prefixes. + Args: + query (str): The SPARQL UPDATE string to validate. - value (str): The string to check if it is a shorthand IRI. - prefixes (Optional[Dict[str, str]]): A dictionary mapping prefixes to their full IRIs. + Returns: + bool: True if validation succeeds. - bool: True if the value is in the form of a valid shorthand IRI, False otherwise. + Raises: + InvalidQueryError: If validation fails. """ - if is_iri(value): - return False - elif ":" in value: - # Check if value can be splitted exactly in two parts - if len(value.split(":")) != 2: - return False - prefix = value.split(":")[0] - if prefixes: - # Check if the prefix exists in the provided prefixes dictionary - if prefix in prefixes: - return True - else: - LOGGER.warning( - f"Prefix '{prefix}' not found in the provided prefixes dictionary." - ) - return False - else: - # If no prefixes are provided, just check the format - return True - else: - return False + try: + g = Dataset() + g.update(query) + return True + except Exception as e: + error_message = f"SPAQRQL update query validation failed: {e}" + LOGGER.error(error_message) + raise InvalidQueryError(error_message) -def prepare_subject(sub: str, ensure_iri: bool = True) -> str: +def to_literal( + value: Any, + datatype: Optional[str] = None, + as_string: Optional[bool] = False, +) -> Union[Literal, str]: """ - Prepares and validates a subject string, ensuring it conforms to IRI (Internationalized Resource Identifier) - standards if required. + Convert a Python value to an XSD literal. Args: - sub (str): The subject string to validate and prepare. - ensure_iri (bool, optional): If True, ensures the subject is a valid IRI. Defaults to True. + value (Any): The Python value to convert. + datatype: (Optional[str]) Optional XSD datatype to use; inferred as `XSD.string` for strings. + as_string (Optional[bool]): If True, return the N3 string form of the literal. Defaults to False. Returns: - str: The prepared subject string, either as an absolute IRI or as provided if valid. - - Raises: - InvalidInputError: If the provided subject is not a string. - InvalidIRIError: If the subject is not a valid IRI and `ensure_iri` is True. + Union[Literal, str]: The `rdflib.Literal` or its N3 string form when `as_string=True`. """ - if not type(sub) == str: - raise InvalidInputError(f"Provided subject '{sub}' is not a string.") - if is_iri(sub): - return ensure_absolute(sub) - elif is_shorthand_iri(sub): - return sub - else: - if ensure_iri is True: - raise InvalidIRIError( - f"Provided subject '{sub}' is not a valid IRI. Ensure 'ensure_iri' is set correctly." - ) - else: - return sub + if isinstance(value, str) and datatype is None: + datatype = XSD.string + literal = Literal(value, datatype=datatype) + # literal = escape_string_literal(literal) + if as_string: + return literal.n3() + return literal -def prepare_predicate(pred: str, ensure_iri: bool = True) -> str: +def from_xsd_literal( + value: str, + datatype: str, +): """ - Prepares a predicate string by validating and optionally ensuring it is an IRI (Internationalized Resource Identifier). + Convert an XSD-typed string value to a Python value. Args: - pred (str): The predicate to be validated and processed. - ensure_iri (bool, optional): If True, ensures the predicate is a valid IRI. Defaults to True. + value (str): The lexical form of the literal. + datatype (str): The XSD datatype IRI. Returns: - str: The processed predicate, either as an absolute IRI or as provided if valid. + Any: The converted Python value. + """ + return XSDToPythonMapper[URIRef(datatype)](value) - Raises: - InvalidInputError: If the provided predicate is not a string. - InvalidIRIError: If `ensure_iri` is True and the provided predicate is not a valid IRI. + +def convert_multi_bindings_to_python_type( + bindings: list[dict], +) -> list[dict]: """ - if not type(pred) == str: - raise InvalidInputError(f"Provided subject '{pred}' is not a string.") - if is_iri(pred): - return ensure_absolute(pred) - elif is_shorthand_iri(pred): - return pred - else: - if ensure_iri is True: - raise InvalidIRIError( - f"Provided predicate '{pred}' is not a valid IRI. Ensure 'ensure_iri' is set correctly." - ) - else: - return pred + Convert SPARQL query result bindings to their corresponding Python types. + + Args: + bindings (list[dict]): List of SPARQL query result bindings. + + Returns: + list[Any]: List of converted Python values. + + Notes: + Bindings are expected in the format + [ + { # binding 1 + 'var1': { 'type': _, 'value': _, 'datatype': _ }, + 'var2': { 'type': _, 'value': _, 'datatype': _ }, + }, + { # binding 2 + 'var1': { 'type': _, 'value': _, 'datatype': _ }, + 'var2': { 'type': _, 'value': _, 'datatype': _ }, + } + ] + and returned in the format + [ + { # binding 1 + 'var1': _, + 'var2': _, + }, + { # binding 2 + 'var1': _, + 'var2': _, + } + ] + """ + converted_bindings = [] + for binding in bindings: + converted_binding = {} + for name, entry in binding.items(): + if isinstance(entry, dict) and "type" in entry and "value" in entry: + converted_binding[name] = convert_binding_to_python_type(entry) + else: + converted_binding[name] = entry + converted_bindings.append(converted_binding) + return converted_bindings -def prepare_object( - obj: Any, as_string: bool = False, ensure_iri: bool = False -) -> Union[str, Literal]: +def convert_binding_to_python_type( + result_binding: dict, +) -> Any: """ - Prepares an object for use in a graph database context by ensuring it is in the - correct format, such as an IRI (Internationalized Resource Identifier) or a Literal. + Convert a SPARQL binding entry to a Python value. Args: - obj (Any): The object to be prepared. It can be a string, Literal, or any other type. - as_string (bool, optional): If True, converts a Literal object to its string representation. - Defaults to False. - ensure_iri (bool, optional): If True, ensures that the provided object is a valid IRI. - Raises an InvalidIRIError if the object is not a valid IRI. Defaults to False. + result_binding (dict): A single binding dict (e.g., `{ 'type': 'literal', ... }`). Returns: - Union[str, Literal]: The prepared object. This can be: - - A string representing an absolute or shorthand IRI. - - A Literal object or its string representation if `as_string` is True. + Any: A Python value converted from the binding, or the raw string when not typed. + """ + type = result_binding.get("type") + if type == "literal" and "datatype" in result_binding: + return from_xsd_literal(result_binding["value"], result_binding["datatype"]) + elif type == "bnode": + return BNode(result_binding["value"]) + elif type == "uri": + # Convert to IRI. if it is a recognized datatype, map to Python type + iri = IRI(result_binding["value"]) + return XSDToPythonTypes.get(iri, iri) + else: + # If no datatype is provided, return the value as is + return result_binding["value"] - Raises: - InvalidIRIError: If `ensure_iri` is True and the provided object is not a valid IRI. + +def get_local_name( + iri: str, +) -> str: """ - if ensure_iri: - if not type(obj) == str: - raise InvalidIRIError( - f"Provided object '{obj}' is not a string. Cannot be a valid IRI." - ) - if is_iri(obj): - return ensure_absolute(obj) - elif is_shorthand_iri(obj): - return obj - else: - raise InvalidIRIError( - f"Provided object '{obj}' is not a valid IRI. Ensure 'ensure_iri' is set correctly." - ) + Extract the local name from an IRI. - if type(obj) == str: - if is_iri(obj): - return ensure_absolute(obj) - else: - return obj + Prefers the fragment after `#` when present; otherwise returns the last path + segment after `/`. - if type(obj) == Literal: - # TODO: How to handle string escapes, obj: Literal = escape_string_literal(obj) - if as_string: - return obj.n3() - else: - return obj + Args: + iri (str): The input IRI (full or shorthand acceptable). - return to_literal(obj, as_string=as_string) + Returns: + str: The local name component. + """ + iri = IRI(iri) + # If there's a fragment (i.e., the part after '#') + if iri.fragment: + return iri.fragment + # Otherwise, split by '/' and return the last segment + return iri.split("/")[-1] -def encapsulate_named_graph(named_graph: Optional[str], content: str) -> str: + +def encapsulate_named_graph( + named_graph: IRI, + content: str, +) -> str: """ Encapsulates the given content within a named graph block if a named graph is provided. Args: - named_graph (Optional[str]): The IRI of the named graph. If None, the content is returned as is. + named_graph (IRI): The IRI of the named graph. If None, the content is returned as is. content (str): The SPARQL content to encapsulate. Returns: str: The encapsulated content or the original content if no named graph is provided. """ if named_graph: - named_graph = ensure_absolute(named_graph) - return f""" -GRAPH {named_graph} {{ + return f"""GRAPH {named_graph.n3()} {{ {content} }}""" return content + + +def group_triples_by_bnode(triples: List[Triple]) -> List[List[Triple]]: + """ + Groups triples sharing blank nodes into connected components to ensure + blank node semantics are preserved within query scope. + + Args: + triples (List[Triple]): Triples to check. + + Returns: + List[List[Triple]]: A list of lists of related triples + + Example: + ASK { + {

. } # Independent triple + UNION + { _:b1 . + _:b1 . } # Connected via blank node + UNION + { . } # Another independent triple + } + """ + triple_groups: Dict[BNode, List[Triple]] = {} + + for triple in triples: + # Extract blank nodes in the triple + bnodes = set() + for elem in triple: + if isinstance(elem, BNode): + bnodes.add(elem) + + if not bnodes: + # No blank nodes - independent triple + # Use triple itself as hashable key + triple_groups[triple] = [triple] + continue + + containing_sets = [ + triple_groups[bnode] for bnode in bnodes if bnode in triple_groups + ] + if not containing_sets: + # Create new group + containing_set = [] + else: + # Existing groups - merge if multiple + containing_set = containing_sets[0] + for set_to_merge in containing_sets[1:]: + containing_set |= set_to_merge + + # Add triple to the containing group + containing_set.append(triple) + # Update bnode to group mapping - overwrite to merged group if needed + for bnode in bnodes: + triple_groups[bnode] = containing_set + + return list(triple_groups.values()) diff --git a/graph_db_interface/utils/xsd_typemap.py b/graph_db_interface/utils/xsd_typemap.py new file mode 100644 index 0000000..878ae1e --- /dev/null +++ b/graph_db_interface/utils/xsd_typemap.py @@ -0,0 +1,58 @@ +from typing import Any, Callable, Dict, Union +import datetime +from rdflib import URIRef +import rdflib.xsd_datetime +from rdflib.term import XSDToPython, _XSD_PFX, _RDF_XMLLITERAL +from graph_db_interface.utils.iri import IRI +import xml.dom.minidom + +# Extends rdflib.term.XSDToPython + +XSDToPythonMapper: Dict[IRI, Callable[[str], Any]] = { + **XSDToPython, + URIRef(_XSD_PFX + "string"): str, + URIRef(_XSD_PFX + "normalizedString"): str, + URIRef(_XSD_PFX + "token"): str, + URIRef(_XSD_PFX + "language"): str, + URIRef(_XSD_PFX + "anyURI"): IRI, + URIRef(_XSD_PFX + "decimal"): float, + URIRef(_RDF_XMLLITERAL): str, +} + +XSDToPythonTypes: Dict[IRI, type] = { + IRI("time", _XSD_PFX): datetime.time, + IRI("date", _XSD_PFX): datetime.date, + IRI("gYear", _XSD_PFX): datetime.date, + IRI("gYearMonth", _XSD_PFX): datetime.date, + IRI("dateTime", _XSD_PFX): datetime.datetime, + IRI("duration", _XSD_PFX): Union[rdflib.xsd_datetime.Duration, datetime.timedelta], + IRI("dayTimeDuration", _XSD_PFX): datetime.timedelta, + IRI("yearMonthDuration", _XSD_PFX): Union[ + rdflib.xsd_datetime.Duration, datetime.timedelta + ], + IRI("hexBinary", _XSD_PFX): bytes, + IRI("string", _XSD_PFX): str, + IRI("normalizedString", _XSD_PFX): str, + IRI("token", _XSD_PFX): str, + IRI("language", _XSD_PFX): str, + IRI("boolean", _XSD_PFX): bool, + IRI("decimal", _XSD_PFX): float, + IRI("integer", _XSD_PFX): int, + IRI("nonPositiveInteger", _XSD_PFX): int, + IRI("long", _XSD_PFX): int, + IRI("nonNegativeInteger", _XSD_PFX): int, + IRI("negativeInteger", _XSD_PFX): int, + IRI("int", _XSD_PFX): int, + IRI("unsignedLong", _XSD_PFX): int, + IRI("positiveInteger", _XSD_PFX): int, + IRI("short", _XSD_PFX): int, + IRI("unsignedInt", _XSD_PFX): int, + IRI("byte", _XSD_PFX): int, + IRI("unsignedShort", _XSD_PFX): int, + IRI("unsignedByte", _XSD_PFX): int, + IRI("float", _XSD_PFX): float, + IRI("double", _XSD_PFX): float, + IRI("base64Binary", _XSD_PFX): bytes, + IRI("anyURI", _XSD_PFX): IRI, + _RDF_XMLLITERAL: xml.dom.minidom.Document, +} diff --git a/poetry.lock b/poetry.lock index 869598e..98c416b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,16 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] [[package]] name = "astroid" @@ -12,9 +24,6 @@ files = [ {file = "astroid-3.3.8.tar.gz", hash = "sha256:a88c7994f914a4ea8572fac479459f4955eeccc877be3f2d959a33273b0cf40b"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} - [[package]] name = "black" version = "25.1.0" @@ -53,8 +62,6 @@ mypy-extensions = ">=0.4.3" packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -62,6 +69,157 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "certifi" +version = "2026.2.25" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, + {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-win32.whl", hash = "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win32.whl", hash = "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win_arm64.whl", hash = "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8"}, + {file = "charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69"}, + {file = "charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6"}, +] + [[package]] name = "click" version = "8.1.8" @@ -90,6 +248,152 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cramjam" +version = "2.11.0" +description = "Thin Python bindings to de/compression algorithms in Rust" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "cramjam-2.11.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d0859c65775e8ebf2cbc084bfd51bd0ffda10266da6f9306451123b89f8e5a63"}, + {file = "cramjam-2.11.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1d77b9b0aca02a3f6eeeff27fcd315ca5972616c0919ee38e522cce257bcd349"}, + {file = "cramjam-2.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66425bc25b5481359b12a6719b6e7c90ffe76d85d0691f1da7df304bfb8ce45c"}, + {file = "cramjam-2.11.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd748d3407ec63e049b3aea1595e218814fccab329b7fb10bb51120a30e9fb7e"}, + {file = "cramjam-2.11.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6d9a23a35b3a105c42a8de60fc2e80281ae6e758f05a3baea0b68eb1ddcb679"}, + {file = "cramjam-2.11.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:40a75b95e05e38a2a055b2446f09994ce1139151721659315151d4ad6289bbff"}, + {file = "cramjam-2.11.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5d042c376d2025300da37d65192d06a457918b63b31140f697f85fd8e310b29"}, + {file = "cramjam-2.11.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb148b35ab20c75b19a06c27f05732e2a321adbd86fadc93f9466dbd7b1154a7"}, + {file = "cramjam-2.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ee47c220f0f5179ddc923ab91fc9e282c27b29fabc60c433dfe06f08084f798"}, + {file = "cramjam-2.11.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0cf1b5a81b21ea175c976c3ab09e00494258f4b49b7995efc86060cced3f0b2e"}, + {file = "cramjam-2.11.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:360c00338ecf48921492455007f904be607fc7818de3d681acbcc542aae2fb36"}, + {file = "cramjam-2.11.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f31fcc0d30dc3f3e94ea6b4d8e1a855071757c6abf6a7b1e284050ab7d4c299c"}, + {file = "cramjam-2.11.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:033be66fdceb3d63b2c99b257a98380c4ec22c9e4dca54a2bfec3718cd24e184"}, + {file = "cramjam-2.11.0-cp310-cp310-win32.whl", hash = "sha256:1c6cea67f6000b81f6bd27d14c8a6f62d00336ca7252fd03ee16f6b70eb5c0d2"}, + {file = "cramjam-2.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:98aa4a351b047b0f7f9e971585982065028adc2c162c5c23c5d5734c5ccc1077"}, + {file = "cramjam-2.11.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:04cfa39118570e70e920a9b75c733299784b6d269733dbc791d9aaed6edd2615"}, + {file = "cramjam-2.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:66a18f68506290349a256375d7aa2f645b9f7993c10fc4cc211db214e4e61d2b"}, + {file = "cramjam-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:50e7d65533857736cd56f6509cf2c4866f28ad84dd15b5bdbf2f8a81e77fa28a"}, + {file = "cramjam-2.11.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1f71989668458fc327ac15396db28d92df22f8024bb12963929798b2729d2df5"}, + {file = "cramjam-2.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee77ac543f1e2b22af1e8be3ae589f729491b6090582340aacd77d1d757d9569"}, + {file = "cramjam-2.11.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad52784120e7e4d8a0b5b0517d185b8bf7f74f5e17272857ddc8951a628d9be1"}, + {file = "cramjam-2.11.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b86f8e6d9c1b3f9a75b2af870c93ceee0f1b827cd2507387540e053b35d7459"}, + {file = "cramjam-2.11.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:320d61938950d95da2371b46c406ec433e7955fae9f396c8e1bf148ffc187d11"}, + {file = "cramjam-2.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41eafc8c1653a35a5c7e75ad48138f9f60085cc05cd99d592e5298552d944e9f"}, + {file = "cramjam-2.11.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03a7316c6bf763dfa34279335b27702321da44c455a64de58112968c0818ec4a"}, + {file = "cramjam-2.11.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:244c2ed8bd7ccbb294a2abe7ca6498db7e89d7eb5e744691dc511a7dc82e65ca"}, + {file = "cramjam-2.11.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:405f8790bad36ce0b4bbdb964ad51507bfc7942c78447f25cb828b870a1d86a0"}, + {file = "cramjam-2.11.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6b1b751a5411032b08fb3ac556160229ca01c6bbe4757bb3a9a40b951ebaac23"}, + {file = "cramjam-2.11.0-cp311-cp311-win32.whl", hash = "sha256:5251585608778b9ac8effed544933df7ad85b4ba21ee9738b551f17798b215ac"}, + {file = "cramjam-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:dca88bc8b68ce6d35dafd8c4d5d59a238a56c43fa02b74c2ce5f9dfb0d1ccb46"}, + {file = "cramjam-2.11.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dba5c14b8b4f73ea1e65720f5a3fe4280c1d27761238378be8274135c60bbc6e"}, + {file = "cramjam-2.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:11eb40722b3fcf3e6890fba46c711bf60f8dc26360a24876c85e52d76c33b25b"}, + {file = "cramjam-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aeb26e2898994b6e8319f19a4d37c481512acdcc6d30e1b5ecc9d8ec57e835cb"}, + {file = "cramjam-2.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4f8d82081ed7d8fe52c982bd1f06e4c7631a73fe1fb6d4b3b3f2404f87dc40fe"}, + {file = "cramjam-2.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:092a3ec26e0a679305018380e4f652eae1b6dfe3fc3b154ee76aa6b92221a17c"}, + {file = "cramjam-2.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:529d6d667c65fd105d10bd83d1cd3f9869f8fd6c66efac9415c1812281196a92"}, + {file = "cramjam-2.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:555eb9c90c450e0f76e27d9ff064e64a8b8c6478ab1a5594c91b7bc5c82fd9f0"}, + {file = "cramjam-2.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5edf4c9e32493035b514cf2ba0c969d81ccb31de63bd05490cc8bfe3b431674e"}, + {file = "cramjam-2.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa2fe41f48c4d58d923803383b0737f048918b5a0d10390de9628bb6272b107"}, + {file = "cramjam-2.11.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9ca14cf1cabdb0b77d606db1bb9e9ca593b1dbd421fcaf251ec9a5431ec449f3"}, + {file = "cramjam-2.11.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:309e95bf898829476bccf4fd2c358ec00e7ff73a12f95a3cdeeba4bb1d3683d5"}, + {file = "cramjam-2.11.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:86dca35d2f15ef22922411496c220f3c9e315d5512f316fe417461971cc1648d"}, + {file = "cramjam-2.11.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:193c6488bd2f514cbc0bef5c18fad61a5f9c8d059dd56edf773b3b37f0e85496"}, + {file = "cramjam-2.11.0-cp312-cp312-win32.whl", hash = "sha256:514e2c008a8b4fa823122ca3ecab896eac41d9aa0f5fc881bd6264486c204e32"}, + {file = "cramjam-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:53fed080476d5f6ad7505883ec5d1ec28ba36c2273db3b3e92d7224fe5e463db"}, + {file = "cramjam-2.11.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2c289729cc1c04e88bafa48b51082fb462b0a57dbc96494eab2be9b14dca62af"}, + {file = "cramjam-2.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:045201ee17147e36cf43d8ae2fa4b4836944ac672df5874579b81cf6d40f1a1f"}, + {file = "cramjam-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:619cd195d74c9e1d2a3ad78d63451d35379c84bd851aec552811e30842e1c67a"}, + {file = "cramjam-2.11.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6eb3ae5ab72edb2ed68bdc0f5710f0a6cad7fd778a610ec2c31ee15e32d3921e"}, + {file = "cramjam-2.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df7da3f4b19e3078f9635f132d31b0a8196accb2576e3213ddd7a77f93317c20"}, + {file = "cramjam-2.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57286b289cd557ac76c24479d8ecfb6c3d5b854cce54ccc7671f9a2f5e2a2708"}, + {file = "cramjam-2.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28952fbbf8b32c0cb7fa4be9bcccfca734bf0d0989f4b509dc7f2f70ba79ae06"}, + {file = "cramjam-2.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78ed2e4099812a438b545dfbca1928ec825e743cd253bc820372d6ef8c3adff4"}, + {file = "cramjam-2.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9aecd5c3845d415bd6c9957c93de8d93097e269137c2ecb0e5a5256374bdc8"}, + {file = "cramjam-2.11.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:362fcf4d6f5e1242a4540812455f5a594949190f6fbc04f2ffbfd7ae0266d788"}, + {file = "cramjam-2.11.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:13240b3dea41b1174456cb9426843b085dc1a2bdcecd9ee2d8f65ac5703374b0"}, + {file = "cramjam-2.11.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:c54eed83726269594b9086d827decc7d2015696e31b99bf9b69b12d9063584fe"}, + {file = "cramjam-2.11.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f8195006fdd0fc0a85b19df3d64a3ef8a240e483ae1dfc7ac6a4316019eb5df2"}, + {file = "cramjam-2.11.0-cp313-cp313-win32.whl", hash = "sha256:ccf30e3fe6d770a803dcdf3bb863fa44ba5dc2664d4610ba2746a3c73599f2e4"}, + {file = "cramjam-2.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ee36348a204f0a68b03400f4736224e9f61d1c6a1582d7f875c1ca56f0254268"}, + {file = "cramjam-2.11.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7ba5e38c9fbd06f086f4a5a64a1a5b7b417cd3f8fc07a20e5c03651f72f36100"}, + {file = "cramjam-2.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8adeee57b41fe08e4520698a4b0bd3cc76dbd81f99424b806d70a5256a391d3"}, + {file = "cramjam-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b96a74fa03a636c8a7d76f700d50e9a8bc17a516d6a72d28711225d641e30968"}, + {file = "cramjam-2.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c3811a56fa32e00b377ef79121c0193311fd7501f0fb378f254c7f083cc1fbe0"}, + {file = "cramjam-2.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5d927e87461f8a0d448e4ab5eb2bca9f31ca5d8ea86d70c6f470bb5bc666d7e"}, + {file = "cramjam-2.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f1f5c450121430fd89cb5767e0a9728ecc65997768fd4027d069cb0368af62f9"}, + {file = "cramjam-2.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:724aa7490be50235d97f07e2ca10067927c5d7f336b786ddbc868470e822aa25"}, + {file = "cramjam-2.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54c4637122e7cfd7aac5c1d3d4c02364f446d6923ea34cf9d0e8816d6e7a4936"}, + {file = "cramjam-2.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17eb39b1696179fb471eea2de958fa21f40a2cd8bf6b40d428312d5541e19dc4"}, + {file = "cramjam-2.11.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:36aa5a798aa34e11813a80425a30d8e052d8de4a28f27bfc0368cfc454d1b403"}, + {file = "cramjam-2.11.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:449fca52774dc0199545fbf11f5128933e5a6833946707885cf7be8018017839"}, + {file = "cramjam-2.11.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:d87d37b3d476f4f7623c56a232045d25bd9b988314702ea01bd9b4a94948a778"}, + {file = "cramjam-2.11.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:26cb45c47d71982d76282e303931c6dd4baee1753e5d48f9a89b3a63e690b3a3"}, + {file = "cramjam-2.11.0-cp314-cp314-win32.whl", hash = "sha256:4efe919d443c2fd112fe25fe636a52f9628250c9a50d9bddb0488d8a6c09acc6"}, + {file = "cramjam-2.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ccec3524ea41b9abd5600e3e27001fd774199dbb4f7b9cb248fcee37d4bda84c"}, + {file = "cramjam-2.11.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:966ac9358b23d21ecd895c418c048e806fd254e46d09b1ff0cdad2eba195ea3e"}, + {file = "cramjam-2.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:387f09d647a0d38dcb4539f8a14281f8eb6bb1d3e023471eb18a5974b2121c86"}, + {file = "cramjam-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:665b0d8fbbb1a7f300265b43926457ec78385200133e41fef19d85790fc1e800"}, + {file = "cramjam-2.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ca905387c7a371531b9622d93471be4d745ef715f2890c3702479cd4fc85aa51"}, + {file = "cramjam-2.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c1aa56aef2c8af55a21ed39040a94a12b53fb23beea290f94d19a76027e2ffb"}, + {file = "cramjam-2.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5db59c1cdfaa2ab85cc988e602d6919495f735ca8a5fd7603608eb1e23c26d5"}, + {file = "cramjam-2.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1f893014f00fe5e89a660a032e813bf9f6d91de74cd1490cdb13b2b59d0c9a3"}, + {file = "cramjam-2.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c26a1eb487947010f5de24943bd7c422dad955b2b0f8650762539778c380ca89"}, + {file = "cramjam-2.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d5c8bfb438d94e7b892d1426da5fc4b4a5370cc360df9b8d9d77c33b896c37e"}, + {file = "cramjam-2.11.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:cb1fb8c9337ab0da25a01c05d69a0463209c347f16512ac43be5986f3d1ebaf4"}, + {file = "cramjam-2.11.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:1f6449f6de52dde3e2f1038284910c8765a397a25e2d05083870f3f5e7fc682c"}, + {file = "cramjam-2.11.0-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:382dec4f996be48ed9c6958d4e30c2b89435d7c2c4dbf32480b3b8886293dd65"}, + {file = "cramjam-2.11.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:d388bd5723732c3afe1dd1d181e4213cc4e1be210b080572e7d5749f6e955656"}, + {file = "cramjam-2.11.0-cp314-cp314t-win32.whl", hash = "sha256:0a70ff17f8e1d13f322df616505550f0f4c39eda62290acb56f069d4857037c8"}, + {file = "cramjam-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:028400d699442d40dbda02f74158c73d05cb76587a12490d0bfedd958fd49188"}, + {file = "cramjam-2.11.0-cp38-cp38-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bf81b2e517baadf41eb85c4762ae596dd1dd2c852988ce86a2df6aa7e31d9228"}, + {file = "cramjam-2.11.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9f995c6b638255c9301166ed7033cb8fe0f34043a46b8e6a055a56b8a38c2114"}, + {file = "cramjam-2.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8949f97ab445d8aa2ccbeab244b46257114d38b6860210b2109b7e5b3ff2c5e"}, + {file = "cramjam-2.11.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4b9a46eca804a51e8eb7b243c8e4513afc3b63aa60b69bc48e0efe6c648c4de0"}, + {file = "cramjam-2.11.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1789a057b6d09acf112c1c84701fc03ba5cc0fcf2ada786ce02a7e73dd466ca5"}, + {file = "cramjam-2.11.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:753710ae1f33b1a34178d104b7e1ac0a94a3f386d14dc24305663f63dc67cabc"}, + {file = "cramjam-2.11.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9115f7a4ba2f110e9dcda72a43adaeba202f42cf181877bcf3eecca359576bfe"}, + {file = "cramjam-2.11.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75b07d36ee034f05e3566878d83f3043d8297dad67937ada15c504ac3e50f9fd"}, + {file = "cramjam-2.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6a32313a5fdbc4fc4fd681a1895d55ee4bf81275e88638b1643b54ecf850cbe"}, + {file = "cramjam-2.11.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4526c4313306a264049e03e6c17b4e728a0647166dddbc1af7baf8e78a65721c"}, + {file = "cramjam-2.11.0-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:3705888b7acacddd46886926fa390dd3df0e1d9e6fe273fd4edb4cbf8eb64735"}, + {file = "cramjam-2.11.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72524cd27e67cf95d9c6bb5eacf47cf78473554f74685f57ccabb368988d91bc"}, + {file = "cramjam-2.11.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:84265f2221e83fb1e41a8e33788c06e3ba22629e88644d0841a470cc28baa3f7"}, + {file = "cramjam-2.11.0-cp38-cp38-win32.whl", hash = "sha256:c77570660abcf3b8931b258d57b600b3484795977797009bed112f0d7b6933bf"}, + {file = "cramjam-2.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:20c8684d2a693e3052532b9730d4399ba2ee212cacf3b961349aad35d13b0c8c"}, + {file = "cramjam-2.11.0-cp39-cp39-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2581e82dca742b55d8b1d7f33892394c06b057a74f2853ffcb0802dcddcbf694"}, + {file = "cramjam-2.11.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a9994a42cd12f07ece04eff94dbf6e127b3986f7af9b26db1eb4545c477a6604"}, + {file = "cramjam-2.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a4963dac24213690183110d6b41125fdc4af871a5a213589d6c6606d49e1b949"}, + {file = "cramjam-2.11.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c9af16f0b07d851b968c54e52d19430d820bb47c26d10a09cfb5c7127de26773"}, + {file = "cramjam-2.11.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e2400c09ba620e2ca91a903dbe907d75f6a1994d8337e9f3026778daa92b08d"}, + {file = "cramjam-2.11.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b820004db8b22715cee2ef154d4b47b3d76c4677ff217c587dd46f694a3052f9"}, + {file = "cramjam-2.11.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:261e9200942189d8201a005ffa1e29339479364b5b0013ab0758b03229d9ac67"}, + {file = "cramjam-2.11.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24c61f1fad56ca68aee53bf67b6a84cd762a2c71ee4b71064378547c2411ae6"}, + {file = "cramjam-2.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab86d22f69a21961f35d1a1b02278b5bb9a95c5f5b4722c6904bca343c8d219f"}, + {file = "cramjam-2.11.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a88bc9b191422cd5b22a1521b28607008590628b6b2a8a7db5c54ec04dc82fa1"}, + {file = "cramjam-2.11.0-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:7855bc4df5ed5f7fb1c98ea3fd98292e9acd3c097b1b21d596a69e1e60455400"}, + {file = "cramjam-2.11.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:19eb43e21db9dc42613599703c1a8e40b0170514a313f11f4c8be380425a1019"}, + {file = "cramjam-2.11.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cec977d673ad596bae6bdfc0091ee386cef05b515b23f2ce52f9fadd0156186a"}, + {file = "cramjam-2.11.0-cp39-cp39-win32.whl", hash = "sha256:dcc3b15b97f3054964b47e2a5fcfb4f5ff569e9af0a7af19f1d4c5f4231bbf3b"}, + {file = "cramjam-2.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:5eb0603d8f8019451fc00e1daf4022dfc9df59c16d2e68f925c77ac94555493b"}, + {file = "cramjam-2.11.0-pp310-pypy310_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:37bed927abc4a7ae2d2669baa3675e21904d8a038ed8e4313326ea7b3be62b2b"}, + {file = "cramjam-2.11.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:50e4a58635fa8c6897d84847d6e065eb69f92811670fc5e9f2d9e3b6279a02b6"}, + {file = "cramjam-2.11.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d1ba626dd5f81f7f09bbf59f70b534e2b75e0d6582b056b7bd31b397f1c13e9"}, + {file = "cramjam-2.11.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c71e140d5eb3145d61d59d0be0bf72f07cc4cf4b32cb136b09f712a3b1040f5f"}, + {file = "cramjam-2.11.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a6ed7926a5cca28edebad7d0fedd2ad492710ae3524d25fc59a2b20546d9ce1"}, + {file = "cramjam-2.11.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5eb4ed3cea945b164b0513fd491884993acac2153a27b93a84019c522e8eda82"}, + {file = "cramjam-2.11.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:52d5db3369f95b27b9f3c14d067acb0b183333613363ed34268c9e04560f997f"}, + {file = "cramjam-2.11.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4820516366d455b549a44d0e2210ee7c4575882dda677564ce79092588321d54"}, + {file = "cramjam-2.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d9e5db525dc0a950a825202f84ee68d89a072479e07da98795a3469df942d301"}, + {file = "cramjam-2.11.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62ab4971199b2270005359cdc379bc5736071dc7c9a228581c5122d9ffaac50c"}, + {file = "cramjam-2.11.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24758375cc5414d3035ca967ebb800e8f24604ececcba3c67d6f0218201ebf2d"}, + {file = "cramjam-2.11.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6c2eea545fef1065c7dd4eda991666fd9c783fbc1d226592ccca8d8891c02f23"}, + {file = "cramjam-2.11.0.tar.gz", hash = "sha256:5c82500ed91605c2d9781380b378397012e25127e89d64f460fea6aeac4389b4"}, +] + +[package.extras] +dev = ["black (==22.3.0)", "hypothesis (==6.60.0)", "numpy", "pytest (>=5.30)", "pytest-benchmark", "pytest-xdist"] + [[package]] name = "dill" version = "0.3.9" @@ -107,20 +411,19 @@ graph = ["objgraph (>=1.7.2)"] profile = ["gprof2dot (>=2022.7.29)"] [[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version < \"3.11\"" +python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, ] [package.extras] -test = ["pytest (>=6)"] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "iniconfig" @@ -134,19 +437,6 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] -[[package]] -name = "isodate" -version = "0.7.2" -description = "An ISO 8601 date/time/duration parser and formatter" -optional = false -python-versions = ">=3.7" -groups = ["main"] -markers = "python_version < \"3.11\"" -files = [ - {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, - {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, -] - [[package]] name = "isort" version = "6.0.1" @@ -244,6 +534,162 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pydantic" +version = "2.12.5" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + [[package]] name = "pylint" version = "3.3.4" @@ -257,19 +703,13 @@ files = [ ] [package.dependencies] -astroid = ">=3.3.8,<=3.4.0-dev0" +astroid = ">=3.3.8,<=3.4.0.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -dill = [ - {version = ">=0.2", markers = "python_version < \"3.11\""}, - {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, - {version = ">=0.3.6", markers = "python_version == \"3.11\""}, -] +dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""} isort = ">=4.2.5,<5.13.0 || >5.13.0,<7" mccabe = ">=0.6,<0.8" platformdirs = ">=2.2.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} tomlkit = ">=0.10.1" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} [package.extras] spelling = ["pyenchant (>=3.2,<4.0)"] @@ -304,15 +744,28 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "python-snappy" +version = "0.7.3" +description = "Python library for the snappy compression library from Google" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "python_snappy-0.7.3-py3-none-any.whl", hash = "sha256:074c0636cfcd97e7251330f428064050ac81a52c62ed884fc2ddebbb60ed7f50"}, + {file = "python_snappy-0.7.3.tar.gz", hash = "sha256:40216c1badfb2d38ac781ecb162a1d0ec40f8ee9747e610bcfefdfa79486cee3"}, +] + +[package.dependencies] +cramjam = "*" + [[package]] name = "rdflib" version = "7.1.3" @@ -326,7 +779,6 @@ files = [ ] [package.dependencies] -isodate = {version = ">=0.7.2,<1.0.0", markers = "python_version < \"3.11\""} pyparsing = ">=2.1.0,<4" [package.extras] @@ -337,48 +789,132 @@ networkx = ["networkx (>=2,<4)"] orjson = ["orjson (>=3.9.14,<4)"] [[package]] -name = "tomli" -version = "2.2.1" -description = "A lil' TOML parser" +name = "regex" +version = "2024.11.6" +description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version < \"3.11\"" +groups = ["main"] files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, + {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, + {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, + {file = "regex-2024.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62"}, + {file = "regex-2024.11.6-cp310-cp310-win32.whl", hash = "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e"}, + {file = "regex-2024.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45"}, + {file = "regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9"}, + {file = "regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad"}, + {file = "regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54"}, + {file = "regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d"}, + {file = "regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff"}, + {file = "regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f"}, + {file = "regex-2024.11.6-cp38-cp38-win32.whl", hash = "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4"}, + {file = "regex-2024.11.6-cp38-cp38-win_amd64.whl", hash = "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b"}, + {file = "regex-2024.11.6-cp39-cp39-win32.whl", hash = "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57"}, + {file = "regex-2024.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983"}, + {file = "regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519"}, ] +[[package]] +name = "requests" +version = "2.33.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"}, + {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + [[package]] name = "tomlkit" version = "0.13.2" @@ -393,18 +929,50 @@ files = [ [[package]] name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version < \"3.11\"" +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "urllib3" +version = "2.6.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + [metadata] lock-version = "2.1" -python-versions = "^3.9" -content-hash = "20e42b736c15c088ad7bbc6d71f0fd1d8ffe10419ed2f7cdb1a1b67a6a0f92d4" +python-versions = "^3.12" +content-hash = "e8a06fd55ef5b03b49fb0e472e8da78e57016e95c30532a6bf2af1f019ad9955" diff --git a/pyproject.toml b/pyproject.toml index 954af13..b541662 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,16 +4,18 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "graph-db-interface" -version = "1.1.0" +version = "1.2.0" description = "A simple interface to interact with a GraphDB instance" authors = ["Jan-Felix Klein ", "Nico Brandt ", "Sören Weindel ", "Etienne Hoffmann "] readme = "README.md" [tool.poetry.dependencies] -python = "^3.11" +python = "^3.12" rdflib = "==7.1.3" requests = "^2.32.3" python-snappy = "^0.7.3" +pydantic = "^2.11.7" +regex = "^2024.11.6" [tool.poetry.group.dev.dependencies] black = "^25.1.0" diff --git a/tests/conftest.py b/tests/conftest.py index 933971d..91bfc29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -import json import os import sys import pytest @@ -12,12 +11,26 @@ def db() -> GraphDB: "GRAPHDB_URL", "GRAPHDB_USERNAME", "GRAPHDB_PASSWORD", - "GRAPHDB_REPOSITORY", + "GRAPHDB_TEST_REPOSITORY", ]: if os.getenv(env_var) is None: print(f"Missing environment variable '{env_var}'.", file=sys.stderr) sys.exit(1) - credentials = GraphDBCredentials.from_env() + credentials = GraphDBCredentials( + base_url=os.getenv("GRAPHDB_URL"), + username=os.getenv("GRAPHDB_USERNAME"), + password=os.getenv("GRAPHDB_PASSWORD"), + repository=os.getenv("GRAPHDB_TEST_REPOSITORY"), + ) - return GraphDB(credentials=credentials) + db = GraphDB(credentials=credentials) + + for graph in [ + None, + "http://example.org/named_graph", + "http://example.org/local_named_graph", + ]: + db.clear_graph(graph) + + return db diff --git a/tests/test_graph_manipulation.py b/tests/test_graph_manipulation.py index f09113d..3386a26 100644 --- a/tests/test_graph_manipulation.py +++ b/tests/test_graph_manipulation.py @@ -1,335 +1,688 @@ -from typing import Tuple +import itertools import pytest from rdflib import Literal, XSD from graph_db_interface import GraphDB from graph_db_interface.exceptions import InvalidInputError from graph_db_interface.utils import utils +from graph_db_interface.utils.iri import IRI -NAMED_GRAPH = "http://example.org/named_graph" +GLOBAL_NAMED_GRAPH = "http://example.org/named_graph" +LOCAL_NAMED_GRAPH = "http://example.org/local_named_graph" -SUBJECT_1 = "" -PREDICATE_1 = "" -OBJECT_1 = Literal(0.5, datatype=XSD.double) # data value +SUB_1 = "http://example.org#subject_1" +PRED_1 = "http://example.org#predicate_1" +OBJ_1 = 0.5 -SUBJECT_2 = "" -PREDICATE_2 = "" -OBJECT_2 = Literal(42, datatype=XSD.integer) # data value +SUB_2 = "http://example.org#subject_2" +PRED_2 = "http://example.org#predicate_2" +OBJ_2 = 42 +NEW_SUB_1 = "http://example.org#new_subject" +NEW_PRED_1 = "http://example.org#new_predicate" +NEW_OBJ_1 = Literal('string with "quotes"', datatype=XSD.string) -NEW_SUBJECT_1 = "" -NEW_PREDICATE_1 = "" -NEW_OBJECT_1 = Literal('string with "quotes"', datatype=XSD.string) +NEW_SUB_2 = "http://example.org#new_subject_2" +NEW_PRED_2 = "http://example.org#new_predicate_2" +NEW_OBJ_2 = True -NEW_SUBJECT_2 = "" -NEW_PREDICATE_2 = "" -NEW_OBJECT_2 = Literal(True, datatype=XSD.boolean) -from typing import Any +@pytest.fixture(params=[None, LOCAL_NAMED_GRAPH], scope="module") +def named_graph(request) -> str: + # Provide a per-test local override for the named graph + return request.param -from typing import List, Union -LIST_OF_TRIPLES: List[Tuple[str, str, Union[str, Literal]]] = [ - (SUBJECT_1, PREDICATE_1, OBJECT_1), - (SUBJECT_2, PREDICATE_2, OBJECT_2), -] -LIST_OF_NEW_TRIPLES: List[Tuple[str, str, Union[str, Literal]]] = [ - (NEW_SUBJECT_1, NEW_PREDICATE_1, NEW_OBJECT_1), - (NEW_SUBJECT_2, NEW_PREDICATE_2, NEW_OBJECT_2), -] - - -@pytest.fixture(params=[None, NAMED_GRAPH], scope="module", autouse=True) +@pytest.fixture(params=[None, GLOBAL_NAMED_GRAPH], scope="module", autouse=True) def setup(request, db: GraphDB): - named_graph = request.param - # We once set a named graph and once we don't - db.named_graph = named_graph + # Set or unset the global named graph on the DB client + db.named_graph = request.param -def test_add_and_delete_triple(db: GraphDB): +def test_add_and_delete_triple(db: GraphDB, named_graph: str): # Add a new triple - result = db.triple_add(SUBJECT_1, PREDICATE_1, OBJECT_1) + result = db.triple_add( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True # try to delete the triple - result = db.triple_delete(SUBJECT_1, PREDICATE_1, OBJECT_1) + result = db.triple_delete( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True # try to delete the triple again - result = db.triple_delete(SUBJECT_1, PREDICATE_1, OBJECT_1) + result = db.triple_delete( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is False # if we dont check for existence it shoud return True - result = db.triple_delete(SUBJECT_1, PREDICATE_1, OBJECT_1, check_exist=False) + result = db.triple_delete( + (SUB_1, PRED_1, OBJ_1), + check_exist=False, + named_graph=named_graph, + ) assert result is True -def test_add_and_delete_multiple_triples(db: GraphDB): +def test_add_and_delete_multiple_triples(db: GraphDB, named_graph: str): # add multiple triples - result = db.triples_add(LIST_OF_TRIPLES) + result = db.triples_add( + [ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + ], + named_graph=named_graph, + ) assert result is True # try to delete the triples - result = db.triples_delete(LIST_OF_TRIPLES) + result = db.triples_delete( + [ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + ], + named_graph=named_graph, + ) assert result is True # try to delete the triples again - result = db.triples_delete(LIST_OF_TRIPLES) + result = db.triples_delete( + [ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + ], + named_graph=named_graph, + ) assert result is False # if we dont check for existence it shoud return True - result = db.triples_delete(LIST_OF_TRIPLES, check_exist=False) + result = db.triples_delete( + [ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + ], + check_exist=False, + named_graph=named_graph, + ) assert result is True -def test_update_triple(db: GraphDB): +def test_update_triple(db: GraphDB, named_graph: str): # Add a new triple - result = db.triple_add(SUBJECT_1, PREDICATE_1, OBJECT_1) + result = db.triple_add( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True # Input errors + # no new given with pytest.raises(InvalidInputError): - db.triple_update(sub_old=SUBJECT_1, pred_old=PREDICATE_1, obj_old=None) + db.triple_update( + old_triple=(SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) - # Nothing to update + # both new given + with pytest.raises(InvalidInputError): + db.triple_update( + old_triple=(SUB_1, PRED_1, OBJ_1), + new_triple=(NEW_SUB_1, NEW_PRED_1, NEW_OBJ_1), + new_sub=NEW_SUB_1, + named_graph=named_graph, + ) + + # old is incomplete with pytest.raises(InvalidInputError): - db.triple_update(sub_old=SUBJECT_1, pred_old=PREDICATE_1, obj_old=OBJECT_1) + db.triple_update( + old_triple=(SUB_1, PRED_1, None), + new_triple=(NEW_SUB_1, NEW_PRED_1, NEW_OBJ_1), + named_graph=named_graph, + ) + + # try to update the full triple + result = db.triple_update( + old_triple=(SUB_1, PRED_1, OBJ_1), + new_triple=(NEW_SUB_1, NEW_PRED_1, NEW_OBJ_1), + named_graph=named_graph, + ) + assert result is True - # try to update the full triple and change its object + # try to update the individual entries result = db.triple_update( - sub_old=SUBJECT_1, - pred_old=PREDICATE_1, - obj_old=OBJECT_1, - sub_new=NEW_SUBJECT_1, - pred_new=NEW_PREDICATE_1, - obj_new=NEW_OBJECT_1, + old_triple=(NEW_SUB_1, NEW_PRED_1, NEW_OBJ_1), + new_sub=SUB_1, + named_graph=named_graph, ) assert result is True - result = db.triple_delete(NEW_SUBJECT_1, NEW_PREDICATE_1, NEW_OBJECT_1) + result = db.triple_update( + old_triple=(SUB_1, NEW_PRED_1, NEW_OBJ_1), + new_pred=PRED_1, + named_graph=named_graph, + ) + assert result is True + + result = db.triple_update( + old_triple=(SUB_1, PRED_1, NEW_OBJ_1), + new_obj=OBJ_1, + named_graph=named_graph, + ) + assert result is True + + # adressing via individual arguments + result = db.triple_update( + (SUB_1, PRED_1, OBJ_1), + new_sub=NEW_SUB_1, + new_pred=NEW_PRED_1, + new_obj=NEW_OBJ_1, + named_graph=named_graph, + ) + assert result is True + + # Cleanup + result = db.triple_delete( + (NEW_SUB_1, NEW_PRED_1, NEW_OBJ_1), + named_graph=named_graph, + ) assert result is True -def test_update_triple_only_subject(db: GraphDB): +def test_update_triple_only_subject(db: GraphDB, named_graph: str): # Add a new triple - result = db.triple_add(SUBJECT_1, PREDICATE_1, OBJECT_1) + result = db.triple_add( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True # only update the subject of the triple result = db.triple_update( - sub_old=SUBJECT_1, - pred_old=PREDICATE_1, - obj_old=OBJECT_1, - sub_new=NEW_SUBJECT_1, + old_triple=(SUB_1, PRED_1, OBJ_1), + new_triple=(NEW_SUB_1, None, None), + named_graph=named_graph, ) assert result is True # try to delete the triple - result = db.triple_delete(NEW_SUBJECT_1, PREDICATE_1, OBJECT_1) + result = db.triple_delete( + (NEW_SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True -def test_update_triple_only_predicate(db: GraphDB): +def test_update_triple_only_predicate(db: GraphDB, named_graph: str): # Add a new triple - result = db.triple_add(SUBJECT_1, PREDICATE_1, OBJECT_1) + result = db.triple_add( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True # only update the predicate of the triple result = db.triple_update( - sub_old=SUBJECT_1, - pred_old=PREDICATE_1, - obj_old=OBJECT_1, - pred_new=NEW_PREDICATE_1, + old_triple=(SUB_1, PRED_1, OBJ_1), + new_triple=(None, NEW_PRED_1, None), + named_graph=named_graph, ) assert result is True # try to delete the triple - result = db.triple_delete(SUBJECT_1, NEW_PREDICATE_1, OBJECT_1) + result = db.triple_delete( + (SUB_1, NEW_PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True -def test_update_triple_only_object(db: GraphDB): +def test_update_triple_only_object(db: GraphDB, named_graph: str): # Add a new triple - result = db.triple_add(SUBJECT_1, PREDICATE_1, OBJECT_1) + result = db.triple_add( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True # only update the object of the triple result = db.triple_update( - sub_old=SUBJECT_1, - pred_old=PREDICATE_1, - obj_old=OBJECT_1, - obj_new=NEW_OBJECT_1, + old_triple=(SUB_1, PRED_1, OBJ_1), + new_triple=(None, None, NEW_OBJ_1), + named_graph=named_graph, ) assert result is True # try to delete the triple - result = db.triple_delete(SUBJECT_1, PREDICATE_1, NEW_OBJECT_1) + result = db.triple_delete( + (SUB_1, PRED_1, NEW_OBJ_1), + named_graph=named_graph, + ) assert result is True -def test_update_multiple_triples(db: GraphDB): +def test_update_multiple_triples(db: GraphDB, named_graph: str): # add multiple triples - result = db.triples_add(LIST_OF_TRIPLES) + result = db.triples_add( + [ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + ], + named_graph=named_graph, + ) assert result is True # update multiple triples result = db.triples_update( - old_triples=LIST_OF_TRIPLES, - new_triples=LIST_OF_NEW_TRIPLES, + old_triples=[ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + ], + new_triples=[ + (NEW_SUB_1, NEW_PRED_1, NEW_OBJ_1), + (NEW_SUB_2, NEW_PRED_2, NEW_OBJ_2), + ], + named_graph=named_graph, ) assert result is True # try to delete the new triples - result = db.triples_delete(LIST_OF_NEW_TRIPLES) + result = db.triples_delete( + [ + (NEW_SUB_1, NEW_PRED_1, NEW_OBJ_1), + (NEW_SUB_2, NEW_PRED_2, NEW_OBJ_2), + ], + named_graph=named_graph, + ) assert result is True -def test_iri_exists(db: GraphDB): +def test_iri_exists(db: GraphDB, named_graph: str): # add a new triple to the default graph - result = db.triple_add(SUBJECT_1, PREDICATE_1, OBJECT_1) + result = db.triple_add( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True # does not specify any part of a triple to look for with pytest.raises(InvalidInputError): - db.iri_exists(iri=SUBJECT_1) + db.iri_exists( + iri=SUB_1, + named_graph=named_graph, + ) # IRI should exist like this result = db.iri_exists( - iri=SUBJECT_1, + iri=SUB_1, as_sub=True, include_explicit=True, include_implicit=False, + named_graph=named_graph, ) assert result is True - result = db.iri_exists(SUBJECT_1, as_sub=True, as_pred=True) + result = db.iri_exists( + SUB_1, + as_sub=True, + as_pred=True, + named_graph=named_graph, + ) assert result is False - result = db.iri_exists(PREDICATE_1, as_pred=True) + result = db.iri_exists( + PRED_1, + as_pred=True, + named_graph=named_graph, + ) assert result is True result = db.iri_exists( - SUBJECT_1, + SUB_1, as_obj=True, include_explicit=True, include_implicit=False, + named_graph=named_graph, ) assert result is False result = db.iri_exists( - SUBJECT_1, + SUB_1, as_pred=True, include_explicit=True, include_implicit=False, + named_graph=named_graph, ) assert result is False - result = db.triple_delete(SUBJECT_1, PREDICATE_1, OBJECT_1) + result = db.triple_delete( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True -def test_convenience_functions(db: GraphDB): - subject = "" - predicate = "rdfs:subClassOf" - object = "" +def test_triple_exists(db: GraphDB, named_graph: str): + # Test return on empty DB + result = db.triple_exists( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) + assert result is False - result = db.triple_add(subject, predicate, object) + # Add triple + result = db.triple_add( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True - result = db.triple_add(subject, "rdf:type", "owl:NamedIndividual") + # Test if triple is now found + result = db.triple_exists( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True - result = db.triple_add(subject, "rdf:type", object) + # Test if modified triple is not found + result = db.triple_exists( + (SUB_2, PRED_1, OBJ_1), + named_graph=named_graph, + ) + assert result is False + + result = db.triple_exists( + (SUB_1, PRED_2, OBJ_1), + named_graph=named_graph, + ) + assert result is False + + result = db.triple_exists( + (SUB_1, PRED_1, OBJ_2), + named_graph=named_graph, + ) + assert result is False + + # Cleanup + result = db.triple_delete( + (SUB_1, PRED_1, OBJ_1), + named_graph=named_graph, + ) assert result is True - result = db.triple_add(object, "rdf:type", "owl:Class") + +def test_multi_triple_exists(db: GraphDB, named_graph: str): + # Test return on empty DB + result = db.any_triple_exists( + [ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + ], + named_graph=named_graph, + ) + assert result is False + + result = db.all_triple_exists( + [ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + ], + named_graph=named_graph, + ) + assert result is False + + # Add first and unrelated triple + result = db.triples_add( + [ + (SUB_1, PRED_1, OBJ_1), + (NEW_SUB_1, NEW_PRED_1, NEW_OBJ_1), + ], + named_graph=named_graph, + ) assert result is True - result = db.is_subclass(subject, object) + # One triple now matches + result = db.any_triple_exists( + [ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + ], + named_graph=named_graph, + ) assert result is True - result = db.is_subclass(subject, "") + result = db.all_triple_exists( + [ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + ], + named_graph=named_graph, + ) assert result is False - result = db.owl_is_named_individual(subject) + # Add second triple + result = db.triple_add( + (SUB_2, PRED_2, OBJ_2), + named_graph=named_graph, + ) assert result is True - result = db.owl_is_named_individual(predicate) - assert result is False + # One triple now matches + result = db.any_triple_exists( + [ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + ], + named_graph=named_graph, + ) + assert result is True + + result = db.all_triple_exists( + [ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + ], + named_graph=named_graph, + ) + assert result is True - classes = db.owl_get_classes_of_individual(subject, local_name=False) - assert classes == [utils.strip_angle_brackets(object)] + # Cleanup + result = db.triples_delete( + [ + (SUB_1, PRED_1, OBJ_1), + (SUB_2, PRED_2, OBJ_2), + (NEW_SUB_1, NEW_PRED_1, NEW_OBJ_1), + ], + named_graph=named_graph, + ) + assert result is True - classes = db.owl_get_classes_of_individual(subject, local_name=True) - assert classes == [utils.get_local_name(object)] - classes = db.owl_get_classes_of_individual(object, local_name=False) - assert classes == [] +def test_convenience_functions(db: GraphDB, named_graph: str): + sub = "http://example.org#instance" + pred = "rdfs:subClassOf" + obj = "http://example.org#myClass" - result = db.triple_delete(subject, predicate, object) + result = db.triple_add( + (sub, pred, obj), + named_graph=named_graph, + ) assert result is True - result = db.triple_delete(subject, "rdf:type", "owl:NamedIndividual") + result = db.triple_add( + (sub, "rdf:type", "owl:NamedIndividual"), + named_graph=named_graph, + ) assert result is True - result = db.triple_delete(subject, "rdf:type", object) + result = db.triple_add( + (sub, "rdf:type", obj), + named_graph=named_graph, + ) assert result is True - result = db.triple_delete(object, "rdf:type", "owl:Class") + result = db.triple_add( + (obj, "rdf:type", "owl:Class"), + named_graph=named_graph, + ) assert result is True + result = db.is_subclass( + sub, + obj, + named_graph=named_graph, + ) + assert result is True -def test_prefix_management(db: GraphDB): - """Test add_prefix, remove_prefix, and get_prefixes functionality""" + result = db.is_subclass( + sub, + "http://example.org#someNonExistingClass", + named_graph=named_graph, + ) + assert result is False - # Get initial prefixes (should include default prefixes: owl, rdf, rdfs, onto) - initial_prefixes = db.get_prefixes() - assert isinstance(initial_prefixes, dict) - assert "owl" in initial_prefixes - assert "rdf" in initial_prefixes - assert "rdfs" in initial_prefixes - assert "onto" in initial_prefixes - initial_count = len(initial_prefixes) + result = db.owl_is_named_individual( + sub, + named_graph=named_graph, + ) + assert result is True - # Test add_prefix with full IRI - db.add_prefix("ex", "") - prefixes = db.get_prefixes() - assert "ex" in prefixes - assert prefixes["ex"] == "" - assert len(prefixes) == initial_count + 1 + result = db.owl_is_named_individual( + pred, + named_graph=named_graph, + ) + assert result is False - # Test add_prefix with IRI without angle brackets (should add them) - db.add_prefix("test", "http://test.org/") - prefixes = db.get_prefixes() - assert "test" in prefixes - assert prefixes["test"] == "" - assert len(prefixes) == initial_count + 2 + classes = db.owl_get_classes_of_individual( + sub, + local_name=False, + named_graph=named_graph, + ) + assert classes == [obj] - # Test overwriting an existing prefix - db.add_prefix("ex", "") - prefixes = db.get_prefixes() - assert prefixes["ex"] == "" - assert len(prefixes) == initial_count + 2 # Count shouldn't increase + classes = db.owl_get_classes_of_individual( + sub, + local_name=True, + named_graph=named_graph, + ) + assert classes == [utils.get_local_name(obj)] - # Test remove_prefix for existing prefix - result = db.remove_prefix("ex") + classes = db.owl_get_classes_of_individual( + obj, + local_name=False, + named_graph=named_graph, + ) + assert classes == [] + + result = db.triple_delete( + (sub, pred, obj), + named_graph=named_graph, + ) assert result is True - prefixes = db.get_prefixes() - assert "ex" not in prefixes - assert len(prefixes) == initial_count + 1 - # Test remove_prefix for non-existing prefix - result = db.remove_prefix("nonexistent") - assert result is False + result = db.triple_delete( + (sub, "rdf:type", "owl:NamedIndividual"), + named_graph=named_graph, + ) + assert result is True + + result = db.triple_delete( + (sub, "rdf:type", obj), + named_graph=named_graph, + ) + assert result is True - # Test remove_prefix for another existing prefix - result = db.remove_prefix("test") + result = db.triple_delete( + (obj, "rdf:type", "owl:Class"), + named_graph=named_graph, + ) assert result is True - prefixes = db.get_prefixes() - assert "test" not in prefixes - assert len(prefixes) == initial_count - # Verify default prefixes are still intact - assert "owl" in prefixes - assert "rdf" in prefixes - assert "rdfs" in prefixes - assert "onto" in prefixes + +def test_iri_generation(db: GraphDB, named_graph: str): + counter = itertools.count() + valid_iri_schema = lambda base: f"{base}#{counter.__next__()}" + invalid_iri_schema = lambda base: f"{base}#fixed" + valid_genid_schema = lambda: f"blank-{counter.__next__()}" + invalid_genid_schema = lambda: "fixed-blank" + + base_no_fragment = "http://example.org" + base_with_fragment = "http://example.org#ClassName" + + # Generate a new IRI + iri1 = db.new_iri( + base=base_no_fragment, + ) + assert isinstance(iri1, IRI) + assert iri1.onto == "http://example.org" + + # Ensure uniqueness + iri2 = db.new_iri( + base=base_no_fragment, + ) + assert iri1 != iri2 + + # Generate with fragment base + iri3 = db.new_iri( + base=base_with_fragment, + ) + assert isinstance(iri3, IRI) + assert iri3.onto == "http://example.org" + assert iri3.fragment.startswith("ClassName-") + + # Ensure uniqueness + iri4 = db.new_iri( + base=base_with_fragment, + ) + assert iri3 != iri4 + + # Generate with schema + iri5 = db.new_iri( + base=base_no_fragment, + schema=valid_iri_schema, + ) + assert isinstance(iri5, IRI) + assert iri5.onto == "http://example.org" + assert iri1 != iri5 + + iri6 = db.new_iri( + base=base_no_fragment, + schema=valid_iri_schema, + ) + assert iri5 != iri6 + + # Invalid schema that does not produce unique IRIs + with pytest.raises(ValueError): + db.new_iri( + base=base_no_fragment, + schema=invalid_iri_schema, + ) + + # Generate blank node IDs + genid1 = db.new_blank_id() + assert isinstance(genid1, str) + + genid2 = db.new_blank_id() + assert genid1 != genid2 + + # Generate with schema + genid3 = db.new_blank_id( + schema=valid_genid_schema, + ) + assert isinstance(genid3, str) + assert genid1 != genid3 + + genid4 = db.new_blank_id( + schema=valid_genid_schema, + ) + assert genid3 != genid4 + + # Invalid schema that does not produce unique blank IDs + with pytest.raises(ValueError): + db.new_blank_id( + schema=invalid_genid_schema, + ) diff --git a/tests/test_graph_store.py b/tests/test_graph_store.py index d9a8c2f..ea3df9d 100644 --- a/tests/test_graph_store.py +++ b/tests/test_graph_store.py @@ -25,147 +25,147 @@ foaf:name "Peter" . """ -GRAPH_URI = "http://test_named_graph" +GRAPH_IRI = "http://test_named_graph" def test_named_graph(db: GraphDB): # first fetch the test named graph to ensure it is empty - response, graph = db.fetch_statements( - graph_uri=GRAPH_URI, + graph = db.fetch_statements( + graph_iri=GRAPH_IRI, ) - assert response.status_code == 200 + assert graph is not None assert len(graph) == 0 # now we add some data to it - response = db.import_statements( + success = db.import_statements( content=TEST_TTL_DATA, overwrite=False, - graph_uri=GRAPH_URI, + graph_iri=GRAPH_IRI, content_type="application/x-turtle", ) - - assert response.status_code == 204 + assert success # fetch the named graph again to ensure data was added - response, graph = db.fetch_statements( - graph_uri=GRAPH_URI, + graph = db.fetch_statements( + graph_iri=GRAPH_IRI, ) - assert response.status_code == 200 + assert graph is not None assert len(graph) == 5 # now we import new data with overwrite=True - response = db.import_statements( + success = db.import_statements( content=UPDATED_TTL_DATA, overwrite=True, - graph_uri=GRAPH_URI, + graph_iri=GRAPH_IRI, content_type="application/x-turtle", ) - assert response.status_code == 204 + assert success # fetch the named graph again to ensure data was updated - response, graph = db.fetch_statements( - graph_uri=GRAPH_URI, + graph = db.fetch_statements( + graph_iri=GRAPH_IRI, ) - assert response.status_code == 200 + assert graph is not None assert len(graph) == 7 # now we add data again with overwrite=False - response = db.import_statements( + success = db.import_statements( content=TEST_TTL_DATA, overwrite=False, - graph_uri=GRAPH_URI, + graph_iri=GRAPH_IRI, content_type="application/x-turtle", ) - assert response.status_code == 204 + assert success # fetch the named graph again to ensure data was appended - response, graph = db.fetch_statements( - graph_uri=GRAPH_URI, + graph = db.fetch_statements( + graph_iri=GRAPH_IRI, ) - assert response.status_code == 200 + assert graph is not None assert len(graph) == 9 # clear the named graph - response = db.clear_graph( - graph_uri=GRAPH_URI, + success = db.clear_graph( + graph_iri=GRAPH_IRI, ) - assert response.status_code == 204 + assert success # fetch the named graph again to ensure it is empty - response, graph = db.fetch_statements( - graph_uri=GRAPH_URI, + graph = db.fetch_statements( + graph_iri=GRAPH_IRI, ) - assert response.status_code == 200 + assert graph is not None assert len(graph) == 0 def test_default_graph(db: GraphDB): # first fetch the default graph - response, graph = db.fetch_statements( - graph_uri=None, + graph = db.fetch_statements( + graph_iri=None, ) - assert response.status_code == 200 + assert graph is not None triples_in_default_graph = int(len(graph)) print(f"Default graph has {triples_in_default_graph} triples.") # now we add some data to it - response = db.import_statements( + success = db.import_statements( content=TEST_TTL_DATA, overwrite=False, - graph_uri=None, + graph_iri=None, content_type="application/x-turtle", ) + assert success expected_num_triples = triples_in_default_graph + 5 # fetch the default graph again to ensure data was added - response, graph = db.fetch_statements( - graph_uri=None, + graph = db.fetch_statements( + graph_iri=None, ) - assert response.status_code == 200 + assert graph is not None assert int(len(graph)) == int(expected_num_triples) # now we import new data with overwrite=True - response = db.import_statements( + success = db.import_statements( content=UPDATED_TTL_DATA, overwrite=True, - graph_uri=None, + graph_iri=None, content_type="application/x-turtle", ) - assert response.status_code == 204 + assert success # fetch the default graph again to ensure data was updated - response, graph = db.fetch_statements( - graph_uri=None, + graph = db.fetch_statements( + graph_iri=None, ) - assert response.status_code == 200 + assert graph is not None assert len(graph) == 7 # now we add data again with overwrite=False - response = db.import_statements( + success = db.import_statements( content=TEST_TTL_DATA, overwrite=False, - graph_uri=None, + graph_iri=None, content_type="application/x-turtle", ) - assert response.status_code == 204 + assert success # fetch the default graph again to ensure data was appended - response, graph = db.fetch_statements( - graph_uri=None, + graph = db.fetch_statements( + graph_iri=None, ) - assert response.status_code == 200 + assert graph is not None assert len(graph) == 9 # clear the default graph - response = db.clear_graph( - graph_uri=None, + success = db.clear_graph( + graph_iri=None, ) - assert response.status_code == 204 + assert success # fetch the default graph again to ensure it is empty - response, graph = db.fetch_statements( - graph_uri=None, + graph = db.fetch_statements( + graph_iri=None, ) - assert response.status_code == 200 + assert graph is not None assert len(graph) == 0 diff --git a/tests/test_initialization.py b/tests/test_initialization.py index f735abf..0218683 100644 --- a/tests/test_initialization.py +++ b/tests/test_initialization.py @@ -24,10 +24,12 @@ def test_credentials_from_environment(db: GraphDB): os.getenv("GRAPHDB_URL") is None or os.getenv("GRAPHDB_USERNAME") is None or os.getenv("GRAPHDB_PASSWORD") is None - or os.getenv("GRAPHDB_REPOSITORY") is None + or os.getenv("GRAPHDB_TEST_REPOSITORY") is None ): pytest.skip("One or more environment variables are not set") + os.environ["GRAPHDB_REPOSITORY"] = os.getenv("GRAPHDB_TEST_REPOSITORY") + try: credentials = GraphDBCredentials.from_env() GraphDB(credentials=credentials) diff --git a/tests/test_iri.py b/tests/test_iri.py new file mode 100644 index 0000000..87137fb --- /dev/null +++ b/tests/test_iri.py @@ -0,0 +1,335 @@ +from graph_db_interface import IRI +from graph_db_interface.exceptions import InvalidIRIError +from rdflib import URIRef, Literal + +import pytest + + +def test_init_formats_onto(): + # Pure onto, without wrappers + ref_iri = IRI("http://www.w3.org/2002/07/owl") + assert ref_iri == "http://www.w3.org/2002/07/owl" + + # Pure onto, with # + iri = IRI("http://www.w3.org/2002/07/owl#") + assert iri == ref_iri + + # Pure onto, with <> + iri = IRI("") + assert iri == ref_iri + + # Pure onto, with <> and # + iri = IRI("") + assert iri == ref_iri + + # Explititly empty name + iri = IRI("", "http://www.w3.org/2002/07/owl") + assert iri == ref_iri + + # Pure onto, duplication + iri = IRI(None, "http://www.w3.org/2002/07/owl") + assert iri == ref_iri + + # Pure onto, using prefix + iri = IRI("owl:") + assert iri == ref_iri + + # Pure onto, malformed IRI + with pytest.raises(InvalidIRIError): + IRI("www.w3.org/2002/07/owl") + + with pytest.raises(InvalidIRIError): + IRI("http://www.w3.org/2002/07:owl#") + + # Pure onto, using unknown prefix + with pytest.raises(InvalidIRIError): + IRI("unknown_prefix:") + + +def test_init_formats_full(): + # Full IRI, without wrappers + ref_iri = IRI("http://www.w3.org/2002/07/owl#Class") + assert ref_iri == "http://www.w3.org/2002/07/owl#Class" + + # Full IRI, with <> + iri = IRI("") + assert iri == ref_iri + + # Full IRI, using prefix + iri = IRI("owl:Class") + assert iri == ref_iri + + # Full IRI, duplication + iri = IRI("http://www.w3.org/2002/07/owl#Class") + iri = IRI(iri) + assert iri == ref_iri + + # Single value, using wrong type + with pytest.raises(TypeError): + IRI(123) + + with pytest.raises(TypeError): + IRI(Literal("http://www.w3.org/2002/07/owl#Class")) + + # Full onto IRI, using ':' instead of '#' + with pytest.raises(InvalidIRIError): + IRI("http://www.w3.org/2002/07/owl:Class") + + # Unknown prefix + with pytest.raises(InvalidIRIError): + IRI("unknown_prefix:Class") + + # Two prefixes + with pytest.raises(InvalidIRIError): + IRI("owl:owl:Class") + + with pytest.raises(InvalidIRIError): + IRI("owl:http://www.w3.org/2002/07/owl#Class") + + with pytest.raises(InvalidIRIError): + IRI("http://www.w3.org/2002/07/owl#owl:Class") + + with pytest.raises(InvalidIRIError): + IRI("http://www.w3.org/2002/07/owl#http://www.w3.org/2002/07/owl#Class") + + with pytest.raises(InvalidIRIError): + IRI("http://www.w3.org/2002/07/owl#www.w3.org/2002/07/owl#Class") + + # mixed : and # + with pytest.raises(InvalidIRIError): + IRI("owl:owl#Class") + + with pytest.raises(InvalidIRIError): + IRI("owl#owl:Class") + + # Prefix, using '#' instead of ':' + with pytest.raises(InvalidIRIError): + IRI("owl#Class") + + +def test_init_formats_two_arguments(): + # Full IRI, without wrappers + ref_iri = IRI("Class", "http://www.w3.org/2002/07/owl") + assert ref_iri == "http://www.w3.org/2002/07/owl#Class" + + # Full IRI, with <> + iri = IRI("Class", "") + assert iri == ref_iri + + # Full IRI, with # + iri = IRI("Class", "http://www.w3.org/2002/07/owl#") + assert iri == ref_iri + + # Full IRI, with # and <> + iri = IRI("Class", "") + assert iri == ref_iri + + # Full IRI, using prefix + iri = IRI("Class", "owl") + assert iri == ref_iri + + # Full IRI, using prefix with : + iri = IRI("Class", "owl:") + assert iri == ref_iri + + iri = IRI("Class", "owl") + iri2 = IRI(iri) + assert iri2 == ref_iri + + # Full iri, using partial iri + iri1 = IRI("http://www.w3.org/2002/07/owl") + iri2 = IRI("Class", iri1) + assert iri2 == ref_iri + + # Transparent cases + iri1 = IRI(ref_iri, None) + assert iri1 == ref_iri + + iri1 = IRI(None, ref_iri) + assert iri1 == ref_iri + + # Two values, wrong type + with pytest.raises(TypeError): + IRI("Class", 123) + + with pytest.raises(TypeError): + IRI(123, "owl:") + + with pytest.raises(TypeError): + IRI("Class", Literal("http://www.w3.org/2002/07/owl")) + + with pytest.raises(TypeError): + IRI(Literal("Class"), "owl:") + + # Empty IRI + with pytest.raises(InvalidIRIError): + IRI("", "") + + with pytest.raises(InvalidIRIError): + IRI(None, None) + + # Unknown prefix + with pytest.raises(InvalidIRIError): + IRI("Class", "abc:") + + # Two prefixes + with pytest.raises(InvalidIRIError): + IRI("Class", "owl:owl:") + + with pytest.raises(InvalidIRIError): + IRI("Class", "http://www.w3.org/2002/07/owl#http://www.w3.org/2002/07/owl#") + + with pytest.raises(InvalidIRIError): + IRI("owl:Class", "owl:") + + with pytest.raises(InvalidIRIError): + IRI("owl:Class", "http://www.w3.org/2002/07/owl#") + + with pytest.raises(InvalidIRIError): + IRI("http://www.w3.org/2002/07/owl#Class", "owl:") + + with pytest.raises(InvalidIRIError): + iri = IRI("Class", "owl") + IRI(iri, "owl") + + +def test_prefix_management(): + """Test add_prefix, remove_prefix, and get_prefixes functionality""" + + # Get initial prefixes (should include default prefixes: owl, rdf, rdfs, onto) + initial_prefixes = IRI.get_prefixes() + assert isinstance(initial_prefixes, dict) + assert all( + prefix in initial_prefixes + for prefix in [ + "owl", + "rdf", + "rdfs", + "kafka", + "kafka-inst", + ] + ) + initial_count = len(initial_prefixes) + + # Test add_prefix + IRI.add_prefix("ex", "http://example.org#") + prefixes = IRI.get_prefixes() + assert "ex" in prefixes + assert prefixes["ex"] == "http://example.org" + assert len(prefixes) == initial_count + 1 + + # Test add_prefix with IRI with angle brackets (should remove them) + IRI.add_prefix("test", "") + prefixes = IRI.get_prefixes() + assert "test" in prefixes + assert prefixes["test"] == "http://test.org" + assert len(prefixes) == initial_count + 2 + + # Test overwriting an existing prefix + IRI.add_prefix("ex", "http://example.com") + prefixes = IRI.get_prefixes() + assert prefixes["ex"] == "http://example.com" + assert len(prefixes) == initial_count + 2 # Count shouldn't increase + + # Test remove_prefix for existing prefix + result = IRI.remove_prefix("ex") + assert result is True + prefixes = IRI.get_prefixes() + assert "ex" not in prefixes + assert len(prefixes) == initial_count + 1 + + # Test remove_prefix for non-existing prefix + result = IRI.remove_prefix("nonexistent") + assert result is False + + # Test remove_prefix for another existing prefix + result = IRI.remove_prefix("test") + assert result is True + prefixes = IRI.get_prefixes() + assert "test" not in prefixes + assert len(prefixes) == initial_count + + # Verify default prefixes are still intact + assert all( + prefix in initial_prefixes + for prefix in [ + "owl", + "rdf", + "rdfs", + "kafka", + "kafka-inst", + ] + ) + + +def test_eq(): + iri_ref = IRI("http://example.org#Test") + + iri2 = IRI("http://example.org#Test") + assert iri_ref == iri2 + + iri3 = IRI("http://example.org#AnotherTest") + assert iri_ref != iri3 + + str_iri = "http://example.org#Test" + assert iri_ref == str_iri + + str_iri_different = "http://example.org#Different" + assert iri_ref != str_iri_different + + str_not_iri = "NotAnIRI" + assert iri_ref != str_not_iri + + literal_iri = Literal("http://example.org#Test") + assert iri_ref != literal_iri + + uri_ref_iri = URIRef("http://example.org#Test") + assert iri_ref == uri_ref_iri + + uri_ref_iri_different = URIRef("http://example.org#Different") + assert iri_ref != uri_ref_iri_different + + not_a_string_or_iri = 12345 + assert iri_ref != not_a_string_or_iri + + +def test_hash(): + iri1 = IRI("http://example.org#Test") + iri2 = IRI("http://example.org#Test") + iri3 = IRI("http://example.org#AnotherTest") + + assert hash(iri1) == hash(iri2) + assert hash(iri1) != hash(iri3) + + +def test_short(): + iri1 = IRI("http://www.w3.org/2002/07/owl#Class") + iri3 = IRI("http://unknown.org#Entity") + + assert iri1.short == "owl:Class" + assert iri3.short == "" + + +def test_lined(): + iri1 = IRI("http://www.w3.org/2002/07/owl#Class") + iri2 = IRI("https://example.com/path/to/resource#Property") + iri3 = IRI("http://example.com#A_B_C") + iri4 = IRI("https://sub.domain.co.uk/path.to/resource_name#Property-1") + + assert iri1.lined == "http_c__s__s_www_d_w3_d_org_s_2002_s_07_s_owl_h_Class" + assert iri2.lined == "https_c__s__s_example_d_com_s_path_s_to_s_resource_h_Property" + assert iri3.lined == "http_c__s__s_example_d_com_h_A__B__C" + assert ( + iri4.lined + == "https_c__s__s_sub_d_domain_d_co_d_uk_s_path_d_to_s_resource__name_h_Property-1" + ) + + iri1_reconstructed = IRI.from_lined(iri1.lined) + iri2_reconstructed = IRI.from_lined(iri2.lined) + iri3_reconstructed = IRI.from_lined(iri3.lined) + iri4_reconstructed = IRI.from_lined(iri4.lined) + + assert iri1_reconstructed == iri1 + assert iri2_reconstructed == iri2 + assert iri3_reconstructed == iri3 + assert iri4_reconstructed == iri4 diff --git a/tests/test_triple_get.py b/tests/test_triple_get.py index 51e1d6a..cb01827 100644 --- a/tests/test_triple_get.py +++ b/tests/test_triple_get.py @@ -1,123 +1,172 @@ from graph_db_interface import GraphDB from graph_db_interface.utils import utils from graph_db_interface.exceptions import InvalidInputError +from graph_db_interface.utils.iri import IRI from rdflib import Literal, XSD import pytest -SUBJECT1 = "http://example.org/subject1" -PREDICATE1 = "http://example.org/predicate1" -OBJECT1 = Literal(0.5, datatype=XSD.double) # data value +GLOBAL_NAMED_GRAPH = "https://my_named_test_graph" +LOCAL_NAMED_GRAPH = "http://example.org/local_named_graph" -SUBJECT2 = "http://example.org/subject2" -PREDICATE2 = "http://example.org/predicate2" -OBJECT2 = "http://example.org/object2" +SUB_1 = "http://example.org#subject1" +PRED_1 = "http://example.org#predicate1" +OBJ_1 = 0.5 -NAMED_GRAPH = "" +SUBJ_2 = "http://example.org#subject2" +PRED_2 = "http://example.org#predicate2" +OBJ_2 = "http://example.org#object2" -@pytest.fixture(params=[None, NAMED_GRAPH], scope="module", autouse=True) -def setup(request, db: GraphDB): +@pytest.fixture(params=[None, LOCAL_NAMED_GRAPH], scope="module") +def named_graph(request) -> str: + # Provide a per-test local override for the named graph + return request.param + + +@pytest.fixture(params=[None, GLOBAL_NAMED_GRAPH], scope="module", autouse=True) +def setup(request, db: GraphDB, named_graph: str): """Fixture to set up the test environment. Is called twice""" - named_graph = request.param + + global_named_graph = request.param # We once set a named graph and once we don't - db.named_graph = named_graph + db.named_graph = global_named_graph + + # prioritize local override over global override + named_graph = named_graph or global_named_graph - db.triple_add(SUBJECT1, PREDICATE1, OBJECT1) - db.triple_add(SUBJECT2, PREDICATE2, OBJECT2) + db.triples_add( + [ + (SUB_1, PRED_1, OBJ_1), + (SUBJ_2, PRED_2, OBJ_2), + ], + named_graph=named_graph, + ) yield - db.triple_delete(SUBJECT1, PREDICATE1, OBJECT1, check_exist=True) - db.triple_delete(SUBJECT2, PREDICATE2, OBJECT2, check_exist=True) + db.triples_delete( + [ + (SUB_1, PRED_1, OBJ_1), + (SUBJ_2, PRED_2, OBJ_2), + ], + named_graph=named_graph, + ) -def test_wrong_input(db: GraphDB): +def test_wrong_input(db: GraphDB, named_graph: str): + # Neither sub, pred, obj given with pytest.raises(InvalidInputError): - db.triples_get() + db.triples_get( + named_graph=named_graph, + ) + # Both triple and explicit iri given + with pytest.raises(InvalidInputError): + db.triples_get( + (SUB_1, PRED_1, OBJ_1), + sub=SUB_1, + named_graph=named_graph, + ) -def test_triple_set_subjects(db: GraphDB): + +def test_triple_set_subjects(db: GraphDB, named_graph: str): # Unenclosed absolute IRI - result_triples = db.triples_get(sub=SUBJECT1, include_implicit=False) - result_triples_wrong = db.triples_get(sub=PREDICATE1, include_implicit=False) - assert result_triples == [(SUBJECT1, PREDICATE1, OBJECT1.toPython())] + result_triples = db.triples_get( + sub=SUB_1, + include_implicit=False, + named_graph=named_graph, + ) + result_triples_wrong = db.triples_get( + sub=PRED_1, + include_implicit=False, + named_graph=named_graph, + ) + assert result_triples == [(SUB_1, PRED_1, OBJ_1)] assert result_triples_wrong == [] # enclosed absolute IRI result_triples = db.triples_get( - sub=utils.ensure_absolute(SUBJECT1), include_implicit=False + sub=SUB_1, + include_implicit=False, + named_graph=named_graph, ) - assert result_triples == [(SUBJECT1, PREDICATE1, OBJECT1.toPython())] + assert result_triples == [(SUB_1, PRED_1, OBJ_1)] # shorthand IRI - db.add_prefix("ex", "http://example.org/") + IRI.add_prefix("ex", "http://example.org/") result_triples = db.triples_get( - sub=f"ex:{utils.get_local_name(SUBJECT1)}", include_implicit=False - ) - assert result_triples == [(SUBJECT1, PREDICATE1, OBJECT1.toPython())] - - # No iri but just a substring of the IRI - result_triples = db.triples_get(sub="example.org/subject", include_implicit=False) - assert sorted(result_triples) == sorted( - [(SUBJECT1, PREDICATE1, OBJECT1.toPython()), (SUBJECT2, PREDICATE2, OBJECT2)] + sub=f"ex:{utils.get_local_name(SUB_1)}", + include_implicit=False, + named_graph=named_graph, ) + assert result_triples == [(SUB_1, PRED_1, OBJ_1)] -def test_triple_set_predicates(db: GraphDB): +def test_triple_set_predicates(db: GraphDB, named_graph: str): # Unenclosed absolute IRI - result_triples = db.triples_get(pred=PREDICATE1, include_implicit=False) - assert result_triples == [(SUBJECT1, PREDICATE1, OBJECT1.toPython())] - - # enclosed absolute IRI result_triples = db.triples_get( - pred=utils.ensure_absolute(PREDICATE1), include_implicit=False + pred=PRED_1, + include_implicit=False, + named_graph=named_graph, ) - assert result_triples == [(SUBJECT1, PREDICATE1, OBJECT1.toPython())] + assert result_triples == [(SUB_1, PRED_1, OBJ_1)] - # shorthand IRI - db.add_prefix("ex", "http://example.org/") + # enclosed absolute IRI result_triples = db.triples_get( - pred=f"ex:{utils.get_local_name(PREDICATE1)}", include_implicit=False + pred=f"<{PRED_1}>", + include_implicit=False, + named_graph=named_graph, ) - assert result_triples == [(SUBJECT1, PREDICATE1, OBJECT1.toPython())] + assert result_triples == [(SUB_1, PRED_1, OBJ_1)] - # No iri but just a substring of the IRI + # shorthand IRI + IRI.add_prefix("ex", "http://example.org/") result_triples = db.triples_get( - pred="example.org/predicate", include_implicit=False - ) - assert sorted(result_triples) == sorted( - [(SUBJECT1, PREDICATE1, OBJECT1.toPython()), (SUBJECT2, PREDICATE2, OBJECT2)] + pred=f"ex:{utils.get_local_name(PRED_1)}", + include_implicit=False, + named_graph=named_graph, ) + assert result_triples == [(SUB_1, PRED_1, OBJ_1)] -def test_triple_set_objects(db: GraphDB): +def test_triple_set_objects(db: GraphDB, named_graph: str): # Unenclosed absolute IRI - result_triples = db.triples_get(obj=OBJECT2, include_implicit=False) - assert result_triples == [(SUBJECT2, PREDICATE2, OBJECT2)] + result_triples = db.triples_get( + obj=OBJ_2, + include_implicit=False, + named_graph=named_graph, + ) + assert result_triples == [(SUBJ_2, PRED_2, OBJ_2)] # enclosed absolute IRI result_triples = db.triples_get( - obj=utils.ensure_absolute(OBJECT2), include_implicit=False + obj=f"<{OBJ_2}>", + include_implicit=False, + named_graph=named_graph, ) - assert result_triples == [(SUBJECT2, PREDICATE2, OBJECT2)] + assert result_triples == [(SUBJ_2, PRED_2, OBJ_2)] # shorthand IRI - db.add_prefix("ex", "http://example.org/") + IRI.add_prefix("ex", "http://example.org/") result_triples = db.triples_get( - obj=f"ex:{utils.get_local_name(OBJECT2)}", include_implicit=False + obj=f"ex:{utils.get_local_name(OBJ_2)}", + include_implicit=False, + named_graph=named_graph, ) - assert result_triples == [(SUBJECT2, PREDICATE2, OBJECT2)] - - # No iri but just a substring of the IRI - result_triples = db.triples_get(obj="example.org/object", include_implicit=False) - assert sorted(result_triples) == sorted([(SUBJECT2, PREDICATE2, OBJECT2)]) + assert result_triples == [(SUBJ_2, PRED_2, OBJ_2)] if db.named_graph is not None: + # Object as a Python basic type + result_triples = db.triples_get( + obj=OBJ_1, + include_implicit=False, + named_graph=named_graph, + ) + assert result_triples == [(SUB_1, PRED_1, OBJ_1)] # Object as a rdflib Literal - result_triples = db.triples_get(obj=OBJECT1, include_implicit=False) - assert result_triples == [(SUBJECT1, PREDICATE1, OBJECT1.toPython())] - - # Object as a Python basic type - result_triples = db.triples_get(obj=OBJECT1.toPython(), include_implicit=False) - assert result_triples == [(SUBJECT1, PREDICATE1, OBJECT1.toPython())] - pass + result_triples = db.triples_get( + obj=Literal(OBJ_1, datatype=XSD.double), + include_implicit=False, + named_graph=named_graph, + ) + assert result_triples == [(SUB_1, PRED_1, OBJ_1)] diff --git a/tests/test_utils.py b/tests/test_utils.py index 8b9923f..781c10f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,11 +4,204 @@ InvalidInputError, InvalidQueryError, ) +from graph_db_interface.utils.iri import IRI import pytest -from rdflib import Literal +from rdflib import BNode, Literal import datetime +def test_sanitize_triple_iris(): + triple_three_iri = ( + IRI("http://example.org#subject"), + IRI("http://example.org#predicate"), + IRI("http://example.org#object"), + ) + # Must not modify + sanitized_triple = utils.sanitize_triple(triple_three_iri) + assert sanitized_triple == triple_three_iri + + triple = ( + "http://example.org#subject", + "http://example.org#predicate", + "http://example.org#object", + ) + # Must convert all three to IRI + sanitized_triple = utils.sanitize_triple(triple) + assert sanitized_triple == triple_three_iri + + +def test_sanitize_triple_bnodes(): + triple_bnode = ( + BNode("genid-123"), + IRI("http://example.org#predicate"), + BNode("genid-456"), + ) + # Must not modify + sanitized_triple = utils.sanitize_triple(triple_bnode) + assert sanitized_triple == triple_bnode + + triple_bnode_string = ( + "_:genid-123", + "http://example.org#predicate", + "_:genid-456", + ) + # Must convert to BNode, IRI + sanitized_triple = utils.sanitize_triple(triple_bnode_string) + assert sanitized_triple == triple_bnode + + triple = ( + IRI("http://example.org#subject"), + BNode("genid-123"), + IRI("http://example.org#object"), + ) + # Predicate is BNode, is illegal + with pytest.raises(InvalidIRIError): + utils.sanitize_triple(triple) + + +def test_sanitize_triple_literals(): + triple_iri_literal_string = ( + IRI("http://example.org#subject"), + IRI("http://example.org#predicate"), + Literal("literal_object"), + ) + # Must not modify + sanitized_triple = utils.sanitize_triple(triple_iri_literal_string) + assert sanitized_triple == triple_iri_literal_string + + triple_iri_literal_number = ( + IRI("http://example.org#subject"), + IRI("http://example.org#predicate"), + Literal(2.0), + ) + # Must not modify reference triple + sanitized_triple = utils.sanitize_triple(triple_iri_literal_number) + assert sanitized_triple == triple_iri_literal_number + + triple = ( + IRI("http://example.org#subject"), + IRI("http://example.org#predicate"), + 2.0, + ) + # Must convert python type to Literal + sanitized_triple = utils.sanitize_triple(triple) + assert sanitized_triple == triple_iri_literal_number + + triple = ( + "http://example.org#subject", + "http://example.org#predicate", + "literal_object", + ) + # Object str cannot be converted to IRI + with pytest.raises(InvalidIRIError): + utils.sanitize_triple(triple) + + triple = ( + IRI("http://example.org#subject"), + "not_an_iri", + IRI("http://example.org#object"), + ) + # second element not convertible to IRI + with pytest.raises(InvalidIRIError): + utils.sanitize_triple(triple) + + triple = ( + Literal("http://example.org#subject"), + IRI("http://example.org#predicate"), + IRI("http://example.org#object"), + ) + # Subject is Literal, is illegal + with pytest.raises(TypeError): + utils.sanitize_triple(triple) + + triple = ( + IRI("http://example.org#subject"), + Literal("http://example.org#predicate"), + IRI("http://example.org#object"), + ) + # Predicate is Literal, is illegal + with pytest.raises(TypeError): + utils.sanitize_triple(triple) + + +def test_sanitize_triple_partial(): + partial_triple_two_iri = ( + IRI("http://example.org#subject"), + None, + IRI("http://example.org#object"), + ) + # Must not modify + sanitized_triple = utils.sanitize_triple(partial_triple_two_iri, allow_partial=True) + assert sanitized_triple == partial_triple_two_iri + + partial_triple_mixed = ( + "http://example.org#subject", + None, + IRI("http://example.org#object"), + ) + # Must convert second str to IRI + sanitized_triple = utils.sanitize_triple(partial_triple_mixed, allow_partial=True) + assert sanitized_triple == partial_triple_two_iri + + partial_triple_iri_literal = ( + IRI("http://example.org#subject"), + None, + Literal("literal_string"), + ) + # Must not modify + sanitized_triple = utils.sanitize_triple( + partial_triple_iri_literal, allow_partial=True + ) + assert sanitized_triple == partial_triple_iri_literal + + partial_triple_iri_literal_number = ( + IRI("http://example.org#subject"), + None, + Literal(2.0), + ) + # Must not modify + sanitized_triple = utils.sanitize_triple( + partial_triple_iri_literal_number, allow_partial=True + ) + assert sanitized_triple == partial_triple_iri_literal_number + + partial_triple_iri_number = ( + IRI("http://example.org#subject"), + None, + 2.0, + ) + # Must convert python type to Literal + sanitized_triple = utils.sanitize_triple( + partial_triple_iri_number, allow_partial=True + ) + assert sanitized_triple == partial_triple_iri_literal_number + + partial_triple_mixed = ( + IRI("http://example.org#subject"), + None, + "literal_string", + ) + # Object str cannot be converted to IRI + with pytest.raises(InvalidIRIError): + utils.sanitize_triple(partial_triple_mixed, allow_partial=True) + + partial_triple_invalid = ( + IRI("http://example.org#subject"), + Literal("http://example.org#predicate"), + None, + ) + with pytest.raises(TypeError): + utils.sanitize_triple(partial_triple_invalid, allow_partial=True) + + partial_triple_two_entries = ( + IRI("http://example.org#subject"), + IRI("http://example.org#predicate"), + ) + # only 2 elements + with pytest.raises(InvalidInputError): + utils.sanitize_triple(partial_triple_two_entries) + + def test_validate_query(): valid_query = """ SELECT * @@ -30,15 +223,15 @@ def test_validate_query(): def test_validate_update_query(): valid_query = """ DELETE DATA { - GRAPH { - "object" . + GRAPH { + "object" . } } """ invalid_query = """ DELETE DATA { - GRAPH { - + GRAPH { + } } """ @@ -47,26 +240,6 @@ def test_validate_update_query(): utils.validate_update_query(invalid_query) -def test_ensure_absolute(): - iri = "http://www.sfb1574.kit.edu/core" - absolute_iri = utils.ensure_absolute("http://www.sfb1574.kit.edu/core") - assert f"<{iri}>" == absolute_iri - - -def test_is_absolute(): - absolute_iri = "" - relative_iri = "core:Resource" - assert utils.is_absolute(absolute_iri) is True - assert utils.is_absolute(relative_iri) is False - - -def test_strip_angle_brackets(): - iri = "http://www.sfb1574.kit.edu/core" - absolute_iri = f"<{iri}>" - assert iri == utils.strip_angle_brackets(absolute_iri) - assert iri == utils.strip_angle_brackets(iri) - - def test_to_literal(): literal_str = utils.to_literal(42, as_string=True) assert literal_str == '"42"^^' @@ -126,200 +299,43 @@ def test_from_xsd_literal(): assert value == datetime.datetime(2023, 1, 1, 12, 34, 56) -def test_convert_query_result_to_python_type(): - result_dict = {"type": "uri", "value": "http://example.org/object2"} +def test_convert_binding_to_python_type(): + result_dict = {"type": "uri", "value": "http://example.org#object2"} result_dict2 = { "datatype": "http://www.w3.org/2001/XMLSchema#double", "type": "literal", "value": "0.5", } - converted_result = utils.convert_query_result_to_python_type(result_dict) - assert converted_result == "http://example.org/object2" - converted_result = utils.convert_query_result_to_python_type(result_dict2) + converted_result = utils.convert_binding_to_python_type(result_dict) + assert converted_result == "http://example.org#object2" + converted_result = utils.convert_binding_to_python_type(result_dict2) assert converted_result == 0.5 + # TODO add more test cases here to catch last modification def test_get_local_name(): - iri = "" + iri = "" local_name = utils.get_local_name(iri) assert local_name == "subject" - iri = "http://example.org/predicate" + iri = "http://example.org#predicate" local_name = utils.get_local_name(iri) assert local_name == "predicate" - iri = "") is True - assert utils.is_iri("http://example.org/subject#fragment") is True - assert utils.is_iri("http://example.org/subject?query=param") is True - - -def test_is_shorthand_iri(): - assert utils.is_shorthand_iri("ex:subject") is True - assert ( - utils.is_shorthand_iri("ex:subject", prefixes={"ex": "http://example.org/"}) - is True - ) - assert ( - utils.is_shorthand_iri("subject", prefixes={"ex": "http://example.org/"}) - is False - ) - assert utils.is_shorthand_iri("http://example.org/subject") is False - assert utils.is_shorthand_iri("ex:subject", prefixes={}) is True - - -def test_prepare_subject(): - # provide absolute IRI - assert ( - utils.prepare_subject("", ensure_iri=True) - == "" - ) - assert ( - utils.prepare_subject("", ensure_iri=False) - == "" - ) - - # provide IRI, should be turned into absolute IRI - assert ( - utils.prepare_subject("http://example.org/subject", ensure_iri=True) - == "" - ) - assert ( - utils.prepare_subject("http://example.org/subject", ensure_iri=False) - == "" - ) - - # provide shorthand IRI, should be kept as is - assert utils.prepare_subject("ex:subject", ensure_iri=True) == "ex:subject" - assert utils.prepare_subject("ex:subject", ensure_iri=False) == "ex:subject" - - # simple strings should be returned as is - assert utils.prepare_subject("Hello", ensure_iri=False) == "Hello" - with pytest.raises(InvalidIRIError): - utils.prepare_subject("Hello", ensure_iri=True) - - # Literals should not be provided as subjects - with pytest.raises(InvalidInputError): - utils.prepare_subject(Literal("Hello"), ensure_iri=True) - with pytest.raises(InvalidInputError): - utils.prepare_subject(Literal(42.5), ensure_iri=True) - with pytest.raises(InvalidInputError): - utils.prepare_subject(Literal(42.5), ensure_iri=False) - - -def test_prepare_predicate(): - # provide absolute IRI - assert ( - utils.prepare_predicate("", ensure_iri=True) - == "" - ) - assert ( - utils.prepare_predicate("", ensure_iri=False) - == "" - ) - - # provide IRI, should be turned into absolute IRI - assert ( - utils.prepare_predicate("http://example.org/predicate", ensure_iri=True) - == "" - ) - assert ( - utils.prepare_predicate("http://example.org/predicate", ensure_iri=False) - == "" - ) - - # provide shorthand IRI, should be kept as is - assert utils.prepare_predicate("ex:predicate", ensure_iri=True) == "ex:predicate" - assert utils.prepare_predicate("ex:predicate", ensure_iri=False) == "ex:predicate" - - # simple strings should be returned as is - assert utils.prepare_predicate("Hello", ensure_iri=False) == "Hello" - with pytest.raises(InvalidIRIError): - utils.prepare_predicate("Hello", ensure_iri=True) - - # Literals should not be provided as predicates - with pytest.raises(InvalidInputError): - utils.prepare_predicate(Literal("Hello"), ensure_iri=True) - with pytest.raises(InvalidInputError): - utils.prepare_predicate(Literal(42.5), ensure_iri=True) - with pytest.raises(InvalidInputError): - utils.prepare_predicate(Literal(42.5), ensure_iri=False) - - -def test_prepare_object(): - # provide absolute IRI - assert ( - utils.prepare_object("", ensure_iri=True) - == "" - ) - assert ( - utils.prepare_object("", ensure_iri=False) - == "" - ) - - # provide IRI, should be turned into absolute IRI - assert ( - utils.prepare_object("http://example.org/subject", ensure_iri=True) - == "" - ) - assert ( - utils.prepare_object("http://example.org/subject", ensure_iri=False) - == "" - ) - - # provide shorthand IRI, should be kept as is - assert utils.prepare_object("ex:subject", ensure_iri=True) == "ex:subject" - assert utils.prepare_object("ex:subject", ensure_iri=False) == "ex:subject" - - # Literals - assert utils.prepare_object(Literal(42)) == Literal(42) - assert ( - utils.prepare_object(Literal(42), as_string=True) - == '"42"^^' - ) - with pytest.raises(InvalidIRIError): - utils.prepare_object(Literal(42), ensure_iri=True) - - # Simple strings should not be converted since they might be used for filtering - assert utils.prepare_object("Hello", as_string=True) == "Hello" - assert utils.prepare_object("Hello", as_string=False) == "Hello" - with pytest.raises(InvalidIRIError): - utils.prepare_object("Hello", ensure_iri=True) - - # Standard Python types - assert ( - utils.prepare_object(42.5, as_string=True) - == '"42.5"^^' - ) - assert utils.prepare_object(42.5, as_string=False) == Literal(42.5) - with pytest.raises(InvalidIRIError): - utils.prepare_object(42.5, ensure_iri=True) - - def test_encapsulate_named_graph(): - named_graph = "" + named_graph_str = "" + named_graph = IRI(named_graph_str) content = """SELECT * WHERE { ?s ?p ?o . }""" expected_result = f""" -GRAPH {named_graph} {{ +GRAPH {named_graph_str} {{ SELECT * WHERE {{ ?s ?p ?o .