diff --git a/.github/scripts/check_diff.py b/.github/scripts/check_diff.py index 0fcb163..b147274 100644 --- a/.github/scripts/check_diff.py +++ b/.github/scripts/check_diff.py @@ -2,7 +2,7 @@ import sys from typing import Dict -LIB_DIRS = ["libs/{lib}"] +LIB_DIRS = ["libs/neo4j"] if __name__ == "__main__": files = sys.argv[1:] diff --git a/.github/workflows/_release.yml b/.github/workflows/_release.yml index 5d4beec..239188d 100644 --- a/.github/workflows/_release.yml +++ b/.github/workflows/_release.yml @@ -12,7 +12,7 @@ on: working-directory: required: true type: string - default: 'libs/{lib}' + default: 'libs/neo4j' env: PYTHON_VERSION: "3.11" @@ -159,7 +159,9 @@ jobs: - name: Run integration tests env: - PARTNER_API_KEY: ${{ secrets.PARTNER_API_KEY }} + NEO4J_URI: ${{ secrets.NEO4J_URI }} + NEO4J_USERNAME: ${{ secrets.NEO4J_USERNAME }} + NEO4J_PASSWORD: ${{ secrets.NEO4J_PASSWORD }} run: make integration_tests working-directory: ${{ inputs.working-directory }} diff --git a/README.md b/README.md index 23bb051..31f28bb 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,5 @@ -# 🦜️🔗 LangChain {partner} +# 🦜️🔗 LangChain Neo4j -This repository contains 1 package with {partner} integrations with LangChain: +This repository contains 1 package with Neo4j integrations with LangChain: -- [langchain-{package_lower}](https://pypi.org/project/langchain-{package_lower}/) - -## Initial Repo Checklist (Remove this section after completing) - -This setup assumes that the partner package is already split. For those instructions, -see [these docs](https://python.langchain.com/docs/contributing/integrations#partner-packages). - -Code (auto ecli) - -- [ ] Fill out the readme above (for folks that follow pypi link) -- [ ] Copy package into /libs folder -- [ ] Update these fields in /libs/*/pyproject.toml - - - `tool.poetry.repository` - - `tool.poetry.urls["Source Code"]` - -Workflow code (auto ecli) - -- [ ] Populate .github/workflows/_release.yml with `on.workflow_dispatch.inputs.working-directory.default` -- [ ] Configure `LIB_DIRS` in .github/scripts/check_diff.py - -Workflow code (manual) - -- [ ] Add secrets as env vars in .github/workflows/_release.yml - -Monorepo workflow code (manual) - -- [ ] Pull in new code location, remove old in .github/workflows/api_doc_build.yml - -In github (manual) - -- [ ] Add integration testing secrets in Github (ask Erick for help) -- [ ] Add partner collaborators in Github (ask Erick for help) -- [ ] "Allow auto-merge" in General Settings -- [ ] Only "Allow squash merging" in General Settings -- [ ] Set up ruleset matching CI build (ask Erick for help) - - name: ci build - - enforcement: active - - bypass: write - - target: default branch - - rules: restrict deletions, require status checks ("CI Success"), block force pushes -- [ ] Set up ruleset - - name: require prs - - enforcement: active - - bypass: none - - target: default branch - - rules: restrict deletions, require a pull request before merging (0 approvals, no boxes), block force pushes - -Pypi (manual) - -- [ ] Add new repo to test-pypi and pypi trusted publishing (ask Erick for help) - -Slack - -- [ ] Set up release alerting in Slack (ask Erick for help) - -release: -/github subscribe langchain-ai/langchain-{partner_lower} releases workflows:{name:"release"} -/github unsubscribe langchain-ai/langchain-{partner_lower} issues pulls commits deployments +- [langchain-neo4j](https://pypi.org/project/langchain-neo4j/) diff --git a/libs/neo4j/.gitignore b/libs/neo4j/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/libs/neo4j/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/libs/neo4j/LICENSE b/libs/neo4j/LICENSE new file mode 100644 index 0000000..fc0602f --- /dev/null +++ b/libs/neo4j/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LangChain, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/neo4j/Makefile b/libs/neo4j/Makefile new file mode 100644 index 0000000..9a4e453 --- /dev/null +++ b/libs/neo4j/Makefile @@ -0,0 +1,64 @@ +.PHONY: all format lint test tests integration_tests docker_tests help extended_tests + +# Default target executed when no arguments are given to make. +all: help + +# Define a variable for the test file path. +TEST_FILE ?= tests/unit_tests/ +integration_test integration_tests: TEST_FILE = tests/integration_tests/ + + +# unit tests are run with the --disable-socket flag to prevent network calls +test tests: + poetry run pytest --disable-socket --allow-unix-socket $(TEST_FILE) + +test_watch: + poetry run ptw --snapshot-update --now . -- -vv $(TEST_FILE) + +# integration tests are run without the --disable-socket flag to allow network calls +integration_test integration_tests: + poetry run pytest $(TEST_FILE) + +###################### +# LINTING AND FORMATTING +###################### + +# Define a variable for Python and notebook files. +PYTHON_FILES=. +MYPY_CACHE=.mypy_cache +lint format: PYTHON_FILES=. +lint_diff format_diff: PYTHON_FILES=$(shell git diff --relative=libs/partners/neo4j --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$') +lint_package: PYTHON_FILES=langchain_neo4j +lint_tests: PYTHON_FILES=tests +lint_tests: MYPY_CACHE=.mypy_cache_test + +lint lint_diff lint_package lint_tests: + [ "$(PYTHON_FILES)" = "" ] || poetry run ruff check $(PYTHON_FILES) + [ "$(PYTHON_FILES)" = "" ] || poetry run ruff format $(PYTHON_FILES) --diff + [ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) && poetry run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE) + +format format_diff: + [ "$(PYTHON_FILES)" = "" ] || poetry run ruff format $(PYTHON_FILES) + [ "$(PYTHON_FILES)" = "" ] || poetry run ruff check --select I --fix $(PYTHON_FILES) + +spell_check: + poetry run codespell --toml pyproject.toml + +spell_fix: + poetry run codespell --toml pyproject.toml -w + +check_imports: $(shell find langchain_neo4j -name '*.py') + poetry run python ./scripts/check_imports.py $^ + +###################### +# HELP +###################### + +help: + @echo '----' + @echo 'check_imports - check imports' + @echo 'format - run code formatters' + @echo 'lint - run linters' + @echo 'test - run unit tests' + @echo 'tests - run unit tests' + @echo 'test TEST_FILE= - run all tests in file' diff --git a/libs/neo4j/README.md b/libs/neo4j/README.md new file mode 100644 index 0000000..df20dd2 --- /dev/null +++ b/libs/neo4j/README.md @@ -0,0 +1,148 @@ +# 🦜️🔗 LangChain Neo4j + +This package contains the LangChain integration with Neo4j. + +## 📦 Installation + +```bash +pip install -U langchain-neo4j +``` + +## 💻 Examples + +### Neo4jGraph + +The `Neo4jGraph` class is a wrapper around Neo4j's Python driver. +It provides a simple interface for interacting with a Neo4j database. + +```python +from langchain_neo4j import Neo4jGraph + +graph = Neo4jGraph(url="bolt://localhost:7687", username="neo4j", password="password") +graph.query("MATCH (n) RETURN n LIMIT 1;") +``` + +### Neo4jChatMessageHistory + +The `Neo4jChatMessageHistory` class is used to store chat message history in a Neo4j database. +It stores messages as nodes and creates relationships between them, allowing for easy querying of the conversation history. + +```python +from langchain_neo4j import Neo4jChatMessageHistory + +history = Neo4jChatMessageHistory( + url="bolt://localhost:7687", + username="neo4j", + password="password", + session_id="session_id_1", +) +history.add_user_message("hi!") +history.add_ai_message("whats up?") +history.messages +``` + +### Neo4jVector + +The `Neo4jVector` class provides functionality for managing a Neo4j vector store. +It enables you to create new vector indexes, add vectors to existing indexes, and perform queries on indexes. + +```python +from langchain.docstore.document import Document +from langchain_openai import OpenAIEmbeddings + +from langchain_neo4j import Neo4jVector + +# Create a vector store from some documents and embeddings +docs = [ + Document( + page_content=( + "LangChain is a framework to build " + "with LLMs by chaining interoperable components." + ), + ) +] +embeddings = OpenAIEmbeddings( + model="text-embedding-3-large", + api_key="sk-...", # Replace with your OpenAI API key +) +db = Neo4jVector.from_documents( + docs, + embeddings, + url="bolt://localhost:7687", + username="neo4j", + password="password", +) +# Query the vector store for similar documents +docs_with_score = db.similarity_search_with_score("What is LangChain?", k=1) +``` + +### GraphCypherQAChain + +The `CypherQAChain` class enables natural language interactions with a Neo4j database. +It uses an LLM and the database's schema to translate a user's question into a Cypher query, which is executed against the database. +The resulting data is then sent along with the user's question to the LLM to generate a natural language response. + +```python +from langchain_openai import ChatOpenAI + +from langchain_neo4j import GraphCypherQAChain, Neo4jGraph + +llm = ChatOpenAI( + temperature=0, + api_key="sk-...", # Replace with your OpenAI API key +) +graph = Neo4jGraph(url="bolt://localhost:7687", username="neo4j", password="password") +chain = GraphCypherQAChain.from_llm(llm=llm, graph=graph, allow_dangerous_requests=True) +chain.run("Who starred in Top Gun?") +``` + +## 🧪 Tests + +Install the test dependencies to run the tests: + +```bash +poetry install --with test,test_integration +``` + +### Unit Tests + +Run the unit tests using: + +```bash +make tests +``` + +### Integration Tests + +1. Start the Neo4j instance using Docker: + + ```bash + cd tests/integration_tests/docker-compose + docker-compose -f neo4j.yml up + ``` + +2. Run the tests: + + ```bash + make integration_tests + ``` + +## 🧹 Code Formatting and Linting + +Install the codespell, lint, and typing dependencies to lint and format your code: + +```bash +poetry install --with codespell,lint,typing +``` + +To format your code, run: + +```bash +make format +``` + +To lint it, run: + +```bash +make lint +``` diff --git a/libs/neo4j/langchain_neo4j/__init__.py b/libs/neo4j/langchain_neo4j/__init__.py new file mode 100644 index 0000000..fffeccf --- /dev/null +++ b/libs/neo4j/langchain_neo4j/__init__.py @@ -0,0 +1,21 @@ +from importlib import metadata + +from langchain_neo4j.chains.graph_qa.cypher import GraphCypherQAChain +from langchain_neo4j.chat_message_histories.neo4j import Neo4jChatMessageHistory +from langchain_neo4j.graphs.neo4j_graph import Neo4jGraph +from langchain_neo4j.vectorstores.neo4j_vector import Neo4jVector + +try: + __version__ = metadata.version(__package__) +except metadata.PackageNotFoundError: + # Case where package metadata is not available. + __version__ = "" +del metadata # optional, avoids polluting the results of dir(__package__) + +__all__ = [ + "GraphCypherQAChain", + "Neo4jChatMessageHistory", + "Neo4jGraph", + "Neo4jVector", + "__version__", +] diff --git a/libs/neo4j/langchain_neo4j/chains/__init__.py b/libs/neo4j/langchain_neo4j/chains/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/langchain_neo4j/chains/graph_qa/__init__.py b/libs/neo4j/langchain_neo4j/chains/graph_qa/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/langchain_neo4j/chains/graph_qa/cypher.py b/libs/neo4j/langchain_neo4j/chains/graph_qa/cypher.py new file mode 100644 index 0000000..b8353a1 --- /dev/null +++ b/libs/neo4j/langchain_neo4j/chains/graph_qa/cypher.py @@ -0,0 +1,400 @@ +"""Question answering over a graph.""" + +from __future__ import annotations + +import re +from typing import Any, Dict, List, Optional, Union + +from langchain.chains.base import Chain +from langchain.chains.llm import LLMChain +from langchain_community.chains.graph_qa.prompts import ( + CYPHER_GENERATION_PROMPT, + CYPHER_QA_PROMPT, +) +from langchain_community.graphs.graph_store import GraphStore +from langchain_core.callbacks import CallbackManagerForChainRun +from langchain_core.language_models import BaseLanguageModel +from langchain_core.messages import ( + AIMessage, + BaseMessage, + SystemMessage, + ToolMessage, +) +from langchain_core.output_parsers import StrOutputParser +from langchain_core.prompts import ( + BasePromptTemplate, + ChatPromptTemplate, + HumanMessagePromptTemplate, + MessagesPlaceholder, +) +from langchain_core.runnables import Runnable +from pydantic import Field + +from langchain_neo4j.chains.graph_qa.cypher_utils import ( + CypherQueryCorrector, + Schema, +) + +INTERMEDIATE_STEPS_KEY = "intermediate_steps" + +FUNCTION_RESPONSE_SYSTEM = """You are an assistant that helps to form nice and human +understandable answers based on the provided information from tools. +Do not add any other information that wasn't present in the tools, and use +very concise style in interpreting results! +""" + + +def extract_cypher(text: str) -> str: + """Extract Cypher code from a text. + + Args: + text: Text to extract Cypher code from. + + Returns: + Cypher code extracted from the text. + """ + # The pattern to find Cypher code enclosed in triple backticks + pattern = r"```(.*?)```" + + # Find all matches in the input text + matches = re.findall(pattern, text, re.DOTALL) + + return matches[0] if matches else text + + +def construct_schema( + structured_schema: Dict[str, Any], + include_types: List[str], + exclude_types: List[str], +) -> str: + """Filter the schema based on included or excluded types""" + + def filter_func(x: str) -> bool: + return x in include_types if include_types else x not in exclude_types + + filtered_schema: Dict[str, Any] = { + "node_props": { + k: v + for k, v in structured_schema.get("node_props", {}).items() + if filter_func(k) + }, + "rel_props": { + k: v + for k, v in structured_schema.get("rel_props", {}).items() + if filter_func(k) + }, + "relationships": [ + r + for r in structured_schema.get("relationships", []) + if all(filter_func(r[t]) for t in ["start", "end", "type"]) + ], + } + + # Format node properties + formatted_node_props = [] + for label, properties in filtered_schema["node_props"].items(): + props_str = ", ".join( + [f"{prop['property']}: {prop['type']}" for prop in properties] + ) + formatted_node_props.append(f"{label} {{{props_str}}}") + + # Format relationship properties + formatted_rel_props = [] + for rel_type, properties in filtered_schema["rel_props"].items(): + props_str = ", ".join( + [f"{prop['property']}: {prop['type']}" for prop in properties] + ) + formatted_rel_props.append(f"{rel_type} {{{props_str}}}") + + # Format relationships + formatted_rels = [ + f"(:{el['start']})-[:{el['type']}]->(:{el['end']})" + for el in filtered_schema["relationships"] + ] + + return "\n".join( + [ + "Node properties are the following:", + ",".join(formatted_node_props), + "Relationship properties are the following:", + ",".join(formatted_rel_props), + "The relationships are the following:", + ",".join(formatted_rels), + ] + ) + + +def get_function_response( + question: str, context: List[Dict[str, Any]] +) -> List[BaseMessage]: + TOOL_ID = "call_H7fABDuzEau48T10Qn0Lsh0D" + messages = [ + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": TOOL_ID, + "function": { + "arguments": '{"question":"' + question + '"}', + "name": "GetInformation", + }, + "type": "function", + } + ] + }, + ), + ToolMessage(content=str(context), tool_call_id=TOOL_ID), + ] + return messages + + +class GraphCypherQAChain(Chain): + """Chain for question-answering against a graph by generating Cypher statements. + + *Security note*: Make sure that the database connection uses credentials + that are narrowly-scoped to only include necessary permissions. + Failure to do so may result in data corruption or loss, since the calling + code may attempt commands that would result in deletion, mutation + of data if appropriately prompted or reading sensitive data if such + data is present in the database. + The best way to guard against such negative outcomes is to (as appropriate) + limit the permissions granted to the credentials used with this tool. + + See https://python.langchain.com/docs/security for more information. + """ + + graph: GraphStore = Field(exclude=True) + cypher_generation_chain: LLMChain + qa_chain: Union[LLMChain, Runnable] + graph_schema: str + input_key: str = "query" #: :meta private: + output_key: str = "result" #: :meta private: + top_k: int = 10 + """Number of results to return from the query""" + return_intermediate_steps: bool = False + """Whether or not to return the intermediate steps along with the final answer.""" + return_direct: bool = False + """Whether or not to return the result of querying the graph directly.""" + cypher_query_corrector: Optional[CypherQueryCorrector] = None + """Optional cypher validation tool""" + use_function_response: bool = False + """Whether to wrap the database context as tool/function response""" + allow_dangerous_requests: bool = False + """Forced user opt-in to acknowledge that the chain can make dangerous requests. + + *Security note*: Make sure that the database connection uses credentials + that are narrowly-scoped to only include necessary permissions. + Failure to do so may result in data corruption or loss, since the calling + code may attempt commands that would result in deletion, mutation + of data if appropriately prompted or reading sensitive data if such + data is present in the database. + The best way to guard against such negative outcomes is to (as appropriate) + limit the permissions granted to the credentials used with this tool. + + See https://python.langchain.com/docs/security for more information. + """ + + def __init__(self, **kwargs: Any) -> None: + """Initialize the chain.""" + super().__init__(**kwargs) + if self.allow_dangerous_requests is not True: + raise ValueError( + "In order to use this chain, you must acknowledge that it can make " + "dangerous requests by setting `allow_dangerous_requests` to `True`." + "You must narrowly scope the permissions of the database connection " + "to only include necessary permissions. Failure to do so may result " + "in data corruption or loss or reading sensitive data if such data is " + "present in the database." + "Only use this chain if you understand the risks and have taken the " + "necessary precautions. " + "See https://python.langchain.com/docs/security for more information." + ) + + @property + def input_keys(self) -> List[str]: + """Return the input keys. + + :meta private: + """ + return [self.input_key] + + @property + def output_keys(self) -> List[str]: + """Return the output keys. + + :meta private: + """ + _output_keys = [self.output_key] + return _output_keys + + @property + def _chain_type(self) -> str: + return "graph_cypher_chain" + + @classmethod + def from_llm( + cls, + llm: Optional[BaseLanguageModel] = None, + *, + qa_prompt: Optional[BasePromptTemplate] = None, + cypher_prompt: Optional[BasePromptTemplate] = None, + cypher_llm: Optional[BaseLanguageModel] = None, + qa_llm: Optional[Union[BaseLanguageModel, Any]] = None, + exclude_types: List[str] = [], + include_types: List[str] = [], + validate_cypher: bool = False, + qa_llm_kwargs: Optional[Dict[str, Any]] = None, + cypher_llm_kwargs: Optional[Dict[str, Any]] = None, + use_function_response: bool = False, + function_response_system: str = FUNCTION_RESPONSE_SYSTEM, + **kwargs: Any, + ) -> GraphCypherQAChain: + """Initialize from LLM.""" + + if not cypher_llm and not llm: + raise ValueError("Either `llm` or `cypher_llm` parameters must be provided") + if not qa_llm and not llm: + raise ValueError("Either `llm` or `qa_llm` parameters must be provided") + if cypher_llm and qa_llm and llm: + raise ValueError( + "You can specify up to two of 'cypher_llm', 'qa_llm'" + ", and 'llm', but not all three simultaneously." + ) + if cypher_prompt and cypher_llm_kwargs: + raise ValueError( + "Specifying cypher_prompt and cypher_llm_kwargs together is" + " not allowed. Please pass prompt via cypher_llm_kwargs." + ) + if qa_prompt and qa_llm_kwargs: + raise ValueError( + "Specifying qa_prompt and qa_llm_kwargs together is" + " not allowed. Please pass prompt via qa_llm_kwargs." + ) + use_qa_llm_kwargs = qa_llm_kwargs if qa_llm_kwargs is not None else {} + use_cypher_llm_kwargs = ( + cypher_llm_kwargs if cypher_llm_kwargs is not None else {} + ) + if "prompt" not in use_qa_llm_kwargs: + use_qa_llm_kwargs["prompt"] = ( + qa_prompt if qa_prompt is not None else CYPHER_QA_PROMPT + ) + if "prompt" not in use_cypher_llm_kwargs: + use_cypher_llm_kwargs["prompt"] = ( + cypher_prompt if cypher_prompt is not None else CYPHER_GENERATION_PROMPT + ) + + qa_llm = qa_llm or llm + if use_function_response: + try: + qa_llm.bind_tools({}) # type: ignore[union-attr] + response_prompt = ChatPromptTemplate.from_messages( + [ + SystemMessage(content=function_response_system), + HumanMessagePromptTemplate.from_template("{question}"), + MessagesPlaceholder(variable_name="function_response"), + ] + ) + qa_chain = response_prompt | qa_llm | StrOutputParser() # type: ignore + except (NotImplementedError, AttributeError): + raise ValueError("Provided LLM does not support native tools/functions") + else: + qa_chain = LLMChain(llm=qa_llm, **use_qa_llm_kwargs) # type: ignore[arg-type] + + cypher_generation_chain = LLMChain( + llm=cypher_llm or llm, # type: ignore[arg-type] + **use_cypher_llm_kwargs, # type: ignore[arg-type] + ) + + if exclude_types and include_types: + raise ValueError( + "Either `exclude_types` or `include_types` " + "can be provided, but not both" + ) + graph_schema = construct_schema( + kwargs["graph"].get_structured_schema, include_types, exclude_types + ) + + cypher_query_corrector = None + if validate_cypher: + corrector_schema = [ + Schema(el["start"], el["type"], el["end"]) + for el in kwargs["graph"].structured_schema.get("relationships") + ] + cypher_query_corrector = CypherQueryCorrector(corrector_schema) + + return cls( + graph_schema=graph_schema, + qa_chain=qa_chain, + cypher_generation_chain=cypher_generation_chain, + cypher_query_corrector=cypher_query_corrector, + use_function_response=use_function_response, + **kwargs, + ) + + def _call( + self, + inputs: Dict[str, Any], + run_manager: Optional[CallbackManagerForChainRun] = None, + ) -> Dict[str, Any]: + """Generate Cypher statement, use it to look up in db and answer question.""" + _run_manager = run_manager or CallbackManagerForChainRun.get_noop_manager() + callbacks = _run_manager.get_child() + question = inputs[self.input_key] + args = { + "question": question, + "schema": self.graph_schema, + } + args.update(inputs) + + intermediate_steps: List = [] + + generated_cypher = self.cypher_generation_chain.run(args, callbacks=callbacks) + + # Extract Cypher code if it is wrapped in backticks + generated_cypher = extract_cypher(generated_cypher) + + # Correct Cypher query if enabled + if self.cypher_query_corrector: + generated_cypher = self.cypher_query_corrector(generated_cypher) + + _run_manager.on_text("Generated Cypher:", end="\n", verbose=self.verbose) + _run_manager.on_text( + generated_cypher, color="green", end="\n", verbose=self.verbose + ) + + intermediate_steps.append({"query": generated_cypher}) + + # Retrieve and limit the number of results + # Generated Cypher be null if query corrector identifies invalid schema + if generated_cypher: + context = self.graph.query(generated_cypher)[: self.top_k] + else: + context = [] + + if self.return_direct: + final_result = context + else: + _run_manager.on_text("Full Context:", end="\n", verbose=self.verbose) + _run_manager.on_text( + str(context), color="green", end="\n", verbose=self.verbose + ) + + intermediate_steps.append({"context": context}) + if self.use_function_response: + function_response = get_function_response(question, context) + final_result = self.qa_chain.invoke( # type: ignore + {"question": question, "function_response": function_response}, + ) + else: + result = self.qa_chain.invoke( # type: ignore + {"question": question, "context": context}, + callbacks=callbacks, + ) + final_result = result[self.qa_chain.output_key] # type: ignore + + chain_result: Dict[str, Any] = {self.output_key: final_result} + if self.return_intermediate_steps: + chain_result[INTERMEDIATE_STEPS_KEY] = intermediate_steps + + return chain_result diff --git a/libs/neo4j/langchain_neo4j/chains/graph_qa/cypher_utils.py b/libs/neo4j/langchain_neo4j/chains/graph_qa/cypher_utils.py new file mode 100644 index 0000000..c123cac --- /dev/null +++ b/libs/neo4j/langchain_neo4j/chains/graph_qa/cypher_utils.py @@ -0,0 +1,260 @@ +import re +from collections import namedtuple +from typing import Any, Dict, List, Optional, Tuple + +Schema = namedtuple("Schema", ["left_node", "relation", "right_node"]) + + +class CypherQueryCorrector: + """ + Used to correct relationship direction in generated Cypher statements. + This code is copied from the winner's submission to the Cypher competition: + https://github.com/sakusaku-rich/cypher-direction-competition + """ + + property_pattern = re.compile(r"\{.+?\}") + node_pattern = re.compile(r"\(.+?\)") + path_pattern = re.compile( + r"(\([^\,\(\)]*?(\{.+\})?[^\,\(\)]*?\))(?)(\([^\,\(\)]*?(\{.+\})?[^\,\(\)]*?\))" + ) + node_relation_node_pattern = re.compile( + r"(\()+(?P[^()]*?)\)(?P.*?)\((?P[^()]*?)(\))+" + ) + relation_type_pattern = re.compile(r":(?P.+?)?(\{.+\})?]") + + def __init__(self, schemas: List[Schema]): + """ + Args: + schemas: list of schemas + """ + self.schemas = schemas + + def clean_node(self, node: str) -> str: + """ + Args: + node: node in string format + + """ + node = re.sub(self.property_pattern, "", node) + node = node.replace("(", "") + node = node.replace(")", "") + node = node.strip() + return node + + def detect_node_variables(self, query: str) -> Dict[str, List[str]]: + """ + Args: + query: cypher query + """ + nodes = re.findall(self.node_pattern, query) + nodes = [self.clean_node(node) for node in nodes] + res: Dict[str, Any] = {} + for node in nodes: + parts = node.split(":") + if parts == "": + continue + variable = parts[0] + if variable not in res: + res[variable] = [] + res[variable] += parts[1:] + return res + + def extract_paths(self, query: str) -> "List[str]": + """ + Args: + query: cypher query + """ + paths = [] + idx = 0 + while matched := self.path_pattern.findall(query[idx:]): + matched = matched[0] + matched = [ + m for i, m in enumerate(matched) if i not in [1, len(matched) - 1] + ] + path = "".join(matched) + idx = query.find(path) + len(path) - len(matched[-1]) + paths.append(path) + return paths + + def judge_direction(self, relation: str) -> str: + """ + Args: + relation: relation in string format + """ + direction = "BIDIRECTIONAL" + if relation[0] == "<": + direction = "INCOMING" + if relation[-1] == ">": + direction = "OUTGOING" + return direction + + def extract_node_variable(self, part: str) -> Optional[str]: + """ + Args: + part: node in string format + """ + part = part.lstrip("(").rstrip(")") + idx = part.find(":") + if idx != -1: + part = part[:idx] + return None if part == "" else part + + def detect_labels( + self, str_node: str, node_variable_dict: Dict[str, Any] + ) -> List[str]: + """ + Args: + str_node: node in string format + node_variable_dict: dictionary of node variables + """ + splitted_node = str_node.split(":") + variable = splitted_node[0] + labels = [] + if variable in node_variable_dict: + labels = node_variable_dict[variable] + elif variable == "" and len(splitted_node) > 1: + labels = splitted_node[1:] + return labels + + def verify_schema( + self, + from_node_labels: List[str], + relation_types: List[str], + to_node_labels: List[str], + ) -> bool: + """ + Args: + from_node_labels: labels of the from node + relation_type: type of the relation + to_node_labels: labels of the to node + """ + valid_schemas = self.schemas + if from_node_labels != []: + from_node_labels = [label.strip("`") for label in from_node_labels] + valid_schemas = [ + schema for schema in valid_schemas if schema[0] in from_node_labels + ] + if to_node_labels != []: + to_node_labels = [label.strip("`") for label in to_node_labels] + valid_schemas = [ + schema for schema in valid_schemas if schema[2] in to_node_labels + ] + if relation_types != []: + relation_types = [type.strip("`") for type in relation_types] + valid_schemas = [ + schema for schema in valid_schemas if schema[1] in relation_types + ] + return valid_schemas != [] + + def detect_relation_types(self, str_relation: str) -> Tuple[str, List[str]]: + """ + Args: + str_relation: relation in string format + """ + relation_direction = self.judge_direction(str_relation) + relation_type = self.relation_type_pattern.search(str_relation) + if relation_type is None or relation_type.group("relation_type") is None: + return relation_direction, [] + relation_types = [ + t.strip().strip("!") + for t in relation_type.group("relation_type").split("|") + ] + return relation_direction, relation_types + + def correct_query(self, query: str) -> str: + """ + Args: + query: cypher query + """ + node_variable_dict = self.detect_node_variables(query) + paths = self.extract_paths(query) + for path in paths: + original_path = path + start_idx = 0 + while start_idx < len(path): + match_res = re.match(self.node_relation_node_pattern, path[start_idx:]) + if match_res is None: + break + start_idx += match_res.start() + match_dict = match_res.groupdict() + left_node_labels = self.detect_labels( + match_dict["left_node"], node_variable_dict + ) + right_node_labels = self.detect_labels( + match_dict["right_node"], node_variable_dict + ) + end_idx = ( + start_idx + + 4 + + len(match_dict["left_node"]) + + len(match_dict["relation"]) + + len(match_dict["right_node"]) + ) + original_partial_path = original_path[start_idx : end_idx + 1] + relation_direction, relation_types = self.detect_relation_types( + match_dict["relation"] + ) + + if relation_types != [] and "".join(relation_types).find("*") != -1: + start_idx += ( + len(match_dict["left_node"]) + len(match_dict["relation"]) + 2 + ) + continue + + if relation_direction == "OUTGOING": + is_legal = self.verify_schema( + left_node_labels, relation_types, right_node_labels + ) + if not is_legal: + is_legal = self.verify_schema( + right_node_labels, relation_types, left_node_labels + ) + if is_legal: + corrected_relation = "<" + match_dict["relation"][:-1] + corrected_partial_path = original_partial_path.replace( + match_dict["relation"], corrected_relation + ) + query = query.replace( + original_partial_path, corrected_partial_path + ) + else: + return "" + elif relation_direction == "INCOMING": + is_legal = self.verify_schema( + right_node_labels, relation_types, left_node_labels + ) + if not is_legal: + is_legal = self.verify_schema( + left_node_labels, relation_types, right_node_labels + ) + if is_legal: + corrected_relation = match_dict["relation"][1:] + ">" + corrected_partial_path = original_partial_path.replace( + match_dict["relation"], corrected_relation + ) + query = query.replace( + original_partial_path, corrected_partial_path + ) + else: + return "" + else: + is_legal = self.verify_schema( + left_node_labels, relation_types, right_node_labels + ) + is_legal |= self.verify_schema( + right_node_labels, relation_types, left_node_labels + ) + if not is_legal: + return "" + + start_idx += ( + len(match_dict["left_node"]) + len(match_dict["relation"]) + 2 + ) + return query + + def __call__(self, query: str) -> str: + """Correct the query to make it valid. If + Args: + query: cypher query + """ + return self.correct_query(query) diff --git a/libs/neo4j/langchain_neo4j/chat_message_histories/__init__.py b/libs/neo4j/langchain_neo4j/chat_message_histories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/langchain_neo4j/chat_message_histories/neo4j.py b/libs/neo4j/langchain_neo4j/chat_message_histories/neo4j.py new file mode 100644 index 0000000..f417fb3 --- /dev/null +++ b/libs/neo4j/langchain_neo4j/chat_message_histories/neo4j.py @@ -0,0 +1,134 @@ +from typing import List, Optional, Union + +from langchain_core.chat_history import BaseChatMessageHistory +from langchain_core.messages import BaseMessage, messages_from_dict +from langchain_core.utils import get_from_dict_or_env + +from langchain_neo4j.graphs.neo4j_graph import Neo4jGraph + + +class Neo4jChatMessageHistory(BaseChatMessageHistory): + """Chat message history stored in a Neo4j database.""" + + def __init__( + self, + session_id: Union[str, int], + url: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + database: str = "neo4j", + node_label: str = "Session", + window: int = 3, + *, + graph: Optional[Neo4jGraph] = None, + ): + try: + import neo4j + except ImportError: + raise ImportError( + "Could not import neo4j python package. " + "Please install it with `pip install neo4j`." + ) + + # Make sure session id is not null + if not session_id: + raise ValueError("Please ensure that the session_id parameter is provided") + + # Graph object takes precedent over env or input params + if graph: + self._driver = graph._driver + self._database = graph._database + else: + # Handle if the credentials are environment variables + url = get_from_dict_or_env({"url": url}, "url", "NEO4J_URI") + username = get_from_dict_or_env( + {"username": username}, "username", "NEO4J_USERNAME" + ) + password = get_from_dict_or_env( + {"password": password}, "password", "NEO4J_PASSWORD" + ) + database = get_from_dict_or_env( + {"database": database}, "database", "NEO4J_DATABASE", "neo4j" + ) + + self._driver = neo4j.GraphDatabase.driver(url, auth=(username, password)) + self._database = database + # Verify connection + try: + self._driver.verify_connectivity() + except neo4j.exceptions.ServiceUnavailable: + raise ValueError( + "Could not connect to Neo4j database. " + "Please ensure that the url is correct" + ) + except neo4j.exceptions.AuthError: + raise ValueError( + "Could not connect to Neo4j database. " + "Please ensure that the username and password are correct" + ) + self._session_id = session_id + self._node_label = node_label + self._window = window + # Create session node + self._driver.execute_query( + f"MERGE (s:`{self._node_label}` {{id:$session_id}})", + {"session_id": self._session_id}, + ).summary + + @property + def messages(self) -> List[BaseMessage]: + """Retrieve the messages from Neo4j""" + query = ( + f"MATCH (s:`{self._node_label}`)-[:LAST_MESSAGE]->(last_message) " + "WHERE s.id = $session_id MATCH p=(last_message)<-[:NEXT*0.." + f"{self._window*2}]-() WITH p, length(p) AS length " + "ORDER BY length DESC LIMIT 1 UNWIND reverse(nodes(p)) AS node " + "RETURN {data:{content: node.content}, type:node.type} AS result" + ) + records, _, _ = self._driver.execute_query( + query, {"session_id": self._session_id} + ) + + messages = messages_from_dict([el["result"] for el in records]) + return messages + + @messages.setter + def messages(self, messages: List[BaseMessage]) -> None: + raise NotImplementedError( + "Direct assignment to 'messages' is not allowed." + " Use the 'add_messages' instead." + ) + + def add_message(self, message: BaseMessage) -> None: + """Append the message to the record in Neo4j""" + query = ( + f"MATCH (s:`{self._node_label}`) WHERE s.id = $session_id " + "OPTIONAL MATCH (s)-[lm:LAST_MESSAGE]->(last_message) " + "CREATE (s)-[:LAST_MESSAGE]->(new:Message) " + "SET new += {type:$type, content:$content} " + "WITH new, lm, last_message WHERE last_message IS NOT NULL " + "CREATE (last_message)-[:NEXT]->(new) " + "DELETE lm" + ) + self._driver.execute_query( + query, + { + "type": message.type, + "content": message.content, + "session_id": self._session_id, + }, + ).summary + + def clear(self) -> None: + """Clear session memory from Neo4j""" + query = ( + f"MATCH (s:`{self._node_label}`)-[:LAST_MESSAGE]->(last_message) " + "WHERE s.id = $session_id MATCH p=(last_message)<-[:NEXT]-() " + "WITH p, length(p) AS length ORDER BY length DESC LIMIT 1 " + "UNWIND nodes(p) as node DETACH DELETE node;" + ) + self._driver.execute_query(query, {"session_id": self._session_id}).summary + + def __del__(self) -> None: + if self._driver: + self._driver.close() diff --git a/libs/neo4j/langchain_neo4j/graphs/__init__.py b/libs/neo4j/langchain_neo4j/graphs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/langchain_neo4j/graphs/neo4j_graph.py b/libs/neo4j/langchain_neo4j/graphs/neo4j_graph.py new file mode 100644 index 0000000..06e644b --- /dev/null +++ b/libs/neo4j/langchain_neo4j/graphs/neo4j_graph.py @@ -0,0 +1,811 @@ +from hashlib import md5 +from typing import Any, Dict, List, Optional + +from langchain_community.graphs.graph_document import GraphDocument +from langchain_community.graphs.graph_store import GraphStore +from langchain_core.utils import get_from_dict_or_env + +BASE_ENTITY_LABEL = "__Entity__" +EXCLUDED_LABELS = ["_Bloom_Perspective_", "_Bloom_Scene_"] +EXCLUDED_RELS = ["_Bloom_HAS_SCENE_"] +EXHAUSTIVE_SEARCH_LIMIT = 10000 +LIST_LIMIT = 128 +# Threshold for returning all available prop values in graph schema +DISTINCT_VALUE_LIMIT = 10 + +node_properties_query = """ +CALL apoc.meta.data() +YIELD label, other, elementType, type, property +WHERE NOT type = "RELATIONSHIP" AND elementType = "node" + AND NOT label IN $EXCLUDED_LABELS +WITH label AS nodeLabels, collect({property:property, type:type}) AS properties +RETURN {labels: nodeLabels, properties: properties} AS output + +""" + +rel_properties_query = """ +CALL apoc.meta.data() +YIELD label, other, elementType, type, property +WHERE NOT type = "RELATIONSHIP" AND elementType = "relationship" + AND NOT label in $EXCLUDED_LABELS +WITH label AS nodeLabels, collect({property:property, type:type}) AS properties +RETURN {type: nodeLabels, properties: properties} AS output +""" + +rel_query = """ +CALL apoc.meta.data() +YIELD label, other, elementType, type, property +WHERE type = "RELATIONSHIP" AND elementType = "node" +UNWIND other AS other_node +WITH * WHERE NOT label IN $EXCLUDED_LABELS + AND NOT other_node IN $EXCLUDED_LABELS +RETURN {start: label, type: property, end: toString(other_node)} AS output +""" + +include_docs_query = ( + "MERGE (d:Document {id:$document.metadata.id}) " + "SET d.text = $document.page_content " + "SET d += $document.metadata " + "WITH d " +) + + +def clean_string_values(text: str) -> str: + """Clean string values for schema. + + Cleans the input text by replacing newline and carriage return characters. + + Args: + text (str): The input text to clean. + + Returns: + str: The cleaned text. + """ + return text.replace("\n", " ").replace("\r", " ") + + +def value_sanitize(d: Any) -> Any: + """Sanitize the input dictionary or list. + + Sanitizes the input by removing embedding-like values, + lists with more than 128 elements, that are mostly irrelevant for + generating answers in a LLM context. These properties, if left in + results, can occupy significant context space and detract from + the LLM's performance by introducing unnecessary noise and cost. + + Args: + d (Any): The input dictionary or list to sanitize. + + Returns: + Any: The sanitized dictionary or list. + """ + if isinstance(d, dict): + new_dict = {} + for key, value in d.items(): + if isinstance(value, dict): + sanitized_value = value_sanitize(value) + if ( + sanitized_value is not None + ): # Check if the sanitized value is not None + new_dict[key] = sanitized_value + elif isinstance(value, list): + if len(value) < LIST_LIMIT: + sanitized_value = value_sanitize(value) + if ( + sanitized_value is not None + ): # Check if the sanitized value is not None + new_dict[key] = sanitized_value + # Do not include the key if the list is oversized + else: + new_dict[key] = value + return new_dict + elif isinstance(d, list): + if len(d) < LIST_LIMIT: + return [ + value_sanitize(item) for item in d if value_sanitize(item) is not None + ] + else: + return None + else: + return d + + +def _get_node_import_query(baseEntityLabel: bool, include_source: bool) -> str: + if baseEntityLabel: + return ( + f"{include_docs_query if include_source else ''}" + "UNWIND $data AS row " + f"MERGE (source:`{BASE_ENTITY_LABEL}` {{id: row.id}}) " + "SET source += row.properties " + f"{'MERGE (d)-[:MENTIONS]->(source) ' if include_source else ''}" + "WITH source, row " + "CALL apoc.create.addLabels( source, [row.type] ) YIELD node " + "RETURN distinct 'done' AS result" + ) + else: + return ( + f"{include_docs_query if include_source else ''}" + "UNWIND $data AS row " + "CALL apoc.merge.node([row.type], {id: row.id}, " + "row.properties, {}) YIELD node " + f"{'MERGE (d)-[:MENTIONS]->(node) ' if include_source else ''}" + "RETURN distinct 'done' AS result" + ) + + +def _get_rel_import_query(baseEntityLabel: bool) -> str: + if baseEntityLabel: + return ( + "UNWIND $data AS row " + f"MERGE (source:`{BASE_ENTITY_LABEL}` {{id: row.source}}) " + f"MERGE (target:`{BASE_ENTITY_LABEL}` {{id: row.target}}) " + "WITH source, target, row " + "CALL apoc.merge.relationship(source, row.type, " + "{}, row.properties, target) YIELD rel " + "RETURN distinct 'done'" + ) + else: + return ( + "UNWIND $data AS row " + "CALL apoc.merge.node([row.source_label], {id: row.source}," + "{}, {}) YIELD node as source " + "CALL apoc.merge.node([row.target_label], {id: row.target}," + "{}, {}) YIELD node as target " + "CALL apoc.merge.relationship(source, row.type, " + "{}, row.properties, target) YIELD rel " + "RETURN distinct 'done'" + ) + + +def _format_schema(schema: Dict, is_enhanced: bool) -> str: + formatted_node_props = [] + formatted_rel_props = [] + if is_enhanced: + # Enhanced formatting for nodes + for node_type, properties in schema["node_props"].items(): + formatted_node_props.append(f"- **{node_type}**") + for prop in properties: + example = "" + if prop["type"] == "STRING" and prop.get("values"): + if prop.get("distinct_count", 11) > DISTINCT_VALUE_LIMIT: + example = ( + f'Example: "{clean_string_values(prop["values"][0])}"' + if prop["values"] + else "" + ) + else: # If less than 10 possible values return all + example = ( + ( + "Available options: " + f'{[clean_string_values(el) for el in prop["values"]]}' + ) + if prop["values"] + else "" + ) + + elif prop["type"] in [ + "INTEGER", + "FLOAT", + "DATE", + "DATE_TIME", + "LOCAL_DATE_TIME", + ]: + if prop.get("min") is not None: + example = f'Min: {prop["min"]}, Max: {prop["max"]}' + else: + example = ( + f'Example: "{prop["values"][0]}"' + if prop.get("values") + else "" + ) + elif prop["type"] == "LIST": + # Skip embeddings + if not prop.get("min_size") or prop["min_size"] > LIST_LIMIT: + continue + example = ( + f'Min Size: {prop["min_size"]}, Max Size: {prop["max_size"]}' + ) + formatted_node_props.append( + f" - `{prop['property']}`: {prop['type']} {example}" + ) + + # Enhanced formatting for relationships + for rel_type, properties in schema["rel_props"].items(): + formatted_rel_props.append(f"- **{rel_type}**") + for prop in properties: + example = "" + if prop["type"] == "STRING": + if prop.get("distinct_count", 11) > DISTINCT_VALUE_LIMIT: + example = ( + f'Example: "{clean_string_values(prop["values"][0])}"' + if prop["values"] + else "" + ) + else: # If less than 10 possible values return all + example = ( + ( + "Available options: " + f'{[clean_string_values(el) for el in prop["values"]]}' + ) + if prop["values"] + else "" + ) + elif prop["type"] in [ + "INTEGER", + "FLOAT", + "DATE", + "DATE_TIME", + "LOCAL_DATE_TIME", + ]: + if prop.get("min"): # If we have min/max + example = f'Min: {prop["min"]}, Max: {prop["max"]}' + else: # return a single value + example = ( + f'Example: "{prop["values"][0]}"' if prop["values"] else "" + ) + elif prop["type"] == "LIST": + # Skip embeddings + if not prop.get("min_size") or prop["min_size"] > LIST_LIMIT: + continue + example = ( + f'Min Size: {prop["min_size"]}, Max Size: {prop["max_size"]}' + ) + formatted_rel_props.append( + f" - `{prop['property']}: {prop['type']}` {example}" + ) + else: + # Format node properties + for label, props in schema["node_props"].items(): + props_str = ", ".join( + [f"{prop['property']}: {prop['type']}" for prop in props] + ) + formatted_node_props.append(f"{label} {{{props_str}}}") + + # Format relationship properties using structured_schema + for type, props in schema["rel_props"].items(): + props_str = ", ".join( + [f"{prop['property']}: {prop['type']}" for prop in props] + ) + formatted_rel_props.append(f"{type} {{{props_str}}}") + + # Format relationships + formatted_rels = [ + f"(:{el['start']})-[:{el['type']}]->(:{el['end']})" + for el in schema["relationships"] + ] + + return "\n".join( + [ + "Node properties:", + "\n".join(formatted_node_props), + "Relationship properties:", + "\n".join(formatted_rel_props), + "The relationships:", + "\n".join(formatted_rels), + ] + ) + + +def _remove_backticks(text: str) -> str: + return text.replace("`", "") + + +class Neo4jGraph(GraphStore): + """Neo4j database wrapper for various graph operations. + + Parameters: + url (Optional[str]): The URL of the Neo4j database server. + username (Optional[str]): The username for database authentication. + password (Optional[str]): The password for database authentication. + database (str): The name of the database to connect to. Default is 'neo4j'. + timeout (Optional[float]): The timeout for transactions in seconds. + Useful for terminating long-running queries. + By default, there is no timeout set. + sanitize (bool): A flag to indicate whether to remove lists with + more than 128 elements from results. Useful for removing + embedding-like properties from database responses. Default is False. + refresh_schema (bool): A flag whether to refresh schema information + at initialization. Default is True. + enhanced_schema (bool): A flag whether to scan the database for + example values and use them in the graph schema. Default is False. + driver_config (Dict): Configuration passed to Neo4j Driver. + + *Security note*: Make sure that the database connection uses credentials + that are narrowly-scoped to only include necessary permissions. + Failure to do so may result in data corruption or loss, since the calling + code may attempt commands that would result in deletion, mutation + of data if appropriately prompted or reading sensitive data if such + data is present in the database. + The best way to guard against such negative outcomes is to (as appropriate) + limit the permissions granted to the credentials used with this tool. + + See https://python.langchain.com/docs/security for more information. + """ + + def __init__( + self, + url: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + database: Optional[str] = None, + timeout: Optional[float] = None, + sanitize: bool = False, + refresh_schema: bool = True, + *, + driver_config: Optional[Dict] = None, + enhanced_schema: bool = False, + ) -> None: + """Create a new Neo4j graph wrapper instance.""" + try: + import neo4j + except ImportError: + raise ImportError( + "Could not import neo4j python package. " + "Please install it with `pip install neo4j`." + ) + + url = get_from_dict_or_env({"url": url}, "url", "NEO4J_URI") + # if username and password are "", assume Neo4j auth is disabled + if username == "" and password == "": + auth = None + else: + username = get_from_dict_or_env( + {"username": username}, + "username", + "NEO4J_USERNAME", + ) + password = get_from_dict_or_env( + {"password": password}, + "password", + "NEO4J_PASSWORD", + ) + auth = (username, password) + database = get_from_dict_or_env( + {"database": database}, "database", "NEO4J_DATABASE", "neo4j" + ) + + self._driver = neo4j.GraphDatabase.driver( + url, auth=auth, **(driver_config or {}) + ) + self._database = database + self.timeout = timeout + self.sanitize = sanitize + self._enhanced_schema = enhanced_schema + self.schema: str = "" + self.structured_schema: Dict[str, Any] = {} + # Verify connection + try: + self._driver.verify_connectivity() + except neo4j.exceptions.ServiceUnavailable: + raise ValueError( + "Could not connect to Neo4j database. " + "Please ensure that the url is correct" + ) + except neo4j.exceptions.AuthError: + raise ValueError( + "Could not connect to Neo4j database. " + "Please ensure that the username and password are correct" + ) + # Set schema + if refresh_schema: + try: + self.refresh_schema() + except neo4j.exceptions.ClientError as e: + if e.code == "Neo.ClientError.Procedure.ProcedureNotFound": + raise ValueError( + "Could not use APOC procedures. " + "Please ensure the APOC plugin is installed in Neo4j and that " + "'apoc.meta.data()' is allowed in Neo4j configuration " + ) + raise e + + @property + def get_schema(self) -> str: + """Returns the schema of the Graph""" + return self.schema + + @property + def get_structured_schema(self) -> Dict[str, Any]: + """Returns the structured schema of the Graph""" + return self.structured_schema + + def query( + self, + query: str, + params: dict = {}, + ) -> List[Dict[str, Any]]: + """Query Neo4j database. + + Args: + query (str): The Cypher query to execute. + params (dict): The parameters to pass to the query. + + Returns: + List[Dict[str, Any]]: The list of dictionaries containing the query results. + """ + from neo4j import Query + from neo4j.exceptions import Neo4jError + + try: + data, _, _ = self._driver.execute_query( + Query(text=query, timeout=self.timeout), + database_=self._database, + parameters_=params, + ) + json_data = [r.data() for r in data] + if self.sanitize: + json_data = [value_sanitize(el) for el in json_data] + return json_data + except Neo4jError as e: + if not ( + ( + ( # isCallInTransactionError + e.code == "Neo.DatabaseError.Statement.ExecutionFailed" + or e.code + == "Neo.DatabaseError.Transaction.TransactionStartFailed" + ) + and "in an implicit transaction" in e.message # type: ignore + ) + or ( # isPeriodicCommitError + e.code == "Neo.ClientError.Statement.SemanticError" + and ( + "in an open transaction is not possible" in e.message # type: ignore + or "tried to execute in an explicit transaction" in e.message # type: ignore + ) + ) + ): + raise + # fallback to allow implicit transactions + with self._driver.session(database=self._database) as session: + data = session.run(Query(text=query, timeout=self.timeout), params) # type: ignore + json_data = [r.data() for r in data] + if self.sanitize: + json_data = [value_sanitize(el) for el in json_data] + return json_data + + def refresh_schema(self) -> None: + """ + Refreshes the Neo4j graph schema information. + """ + from neo4j.exceptions import ClientError, CypherTypeError + + node_properties = [ + el["output"] + for el in self.query( + node_properties_query, + params={"EXCLUDED_LABELS": EXCLUDED_LABELS + [BASE_ENTITY_LABEL]}, + ) + ] + rel_properties = [ + el["output"] + for el in self.query( + rel_properties_query, params={"EXCLUDED_LABELS": EXCLUDED_RELS} + ) + ] + relationships = [ + el["output"] + for el in self.query( + rel_query, + params={"EXCLUDED_LABELS": EXCLUDED_LABELS + [BASE_ENTITY_LABEL]}, + ) + ] + + # Get constraints & indexes + try: + constraint = self.query("SHOW CONSTRAINTS") + index = self.query( + "CALL apoc.schema.nodes() YIELD label, properties, type, size, " + "valuesSelectivity WHERE type = 'RANGE' RETURN *, " + "size * valuesSelectivity as distinctValues" + ) + except ( + ClientError + ): # Read-only user might not have access to schema information + constraint = [] + index = [] + + self.structured_schema = { + "node_props": {el["labels"]: el["properties"] for el in node_properties}, + "rel_props": {el["type"]: el["properties"] for el in rel_properties}, + "relationships": relationships, + "metadata": {"constraint": constraint, "index": index}, + } + if self._enhanced_schema: + schema_counts = self.query( + "CALL apoc.meta.graphSample() YIELD nodes, relationships " + "RETURN nodes, [rel in relationships | {name:apoc.any.property" + "(rel, 'type'), count: apoc.any.property(rel, 'count')}]" + " AS relationships" + ) + # Update node info + for node in schema_counts[0]["nodes"]: + # Skip bloom labels + if node["name"] in EXCLUDED_LABELS: + continue + node_props = self.structured_schema["node_props"].get(node["name"]) + if not node_props: # The node has no properties + continue + enhanced_cypher = self._enhanced_schema_cypher( + node["name"], node_props, node["count"] < EXHAUSTIVE_SEARCH_LIMIT + ) + # Due to schema-flexible nature of neo4j errors can happen + try: + enhanced_info = self.query(enhanced_cypher)[0]["output"] + for prop in node_props: + if prop["property"] in enhanced_info: + prop.update(enhanced_info[prop["property"]]) + except CypherTypeError: + continue + # Update rel info + for rel in schema_counts[0]["relationships"]: + # Skip bloom labels + if rel["name"] in EXCLUDED_RELS: + continue + rel_props = self.structured_schema["rel_props"].get(rel["name"]) + if not rel_props: # The rel has no properties + continue + enhanced_cypher = self._enhanced_schema_cypher( + rel["name"], + rel_props, + rel["count"] < EXHAUSTIVE_SEARCH_LIMIT, + is_relationship=True, + ) + try: + enhanced_info = self.query(enhanced_cypher)[0]["output"] + for prop in rel_props: + if prop["property"] in enhanced_info: + prop.update(enhanced_info[prop["property"]]) + # Due to schema-flexible nature of neo4j errors can happen + except CypherTypeError: + continue + + schema = _format_schema(self.structured_schema, self._enhanced_schema) + + self.schema = schema + + def add_graph_documents( + self, + graph_documents: List[GraphDocument], + include_source: bool = False, + baseEntityLabel: bool = False, + ) -> None: + """ + This method constructs nodes and relationships in the graph based on the + provided GraphDocument objects. + + Parameters: + - graph_documents (List[GraphDocument]): A list of GraphDocument objects + that contain the nodes and relationships to be added to the graph. Each + GraphDocument should encapsulate the structure of part of the graph, + including nodes, relationships, and the source document information. + - include_source (bool, optional): If True, stores the source document + and links it to nodes in the graph using the MENTIONS relationship. + This is useful for tracing back the origin of data. Merges source + documents based on the `id` property from the source document metadata + if available; otherwise it calculates the MD5 hash of `page_content` + for merging process. Defaults to False. + - baseEntityLabel (bool, optional): If True, each newly created node + gets a secondary __Entity__ label, which is indexed and improves import + speed and performance. Defaults to False. + """ + if baseEntityLabel: # Check if constraint already exists + constraint_exists = any( + [ + el["labelsOrTypes"] == [BASE_ENTITY_LABEL] + and el["properties"] == ["id"] + for el in self.structured_schema.get("metadata", {}).get( + "constraint", [] + ) + ] + ) + + if not constraint_exists: + # Create constraint + self.query( + f"CREATE CONSTRAINT IF NOT EXISTS FOR (b:{BASE_ENTITY_LABEL}) " + "REQUIRE b.id IS UNIQUE;" + ) + self.refresh_schema() # Refresh constraint information + + node_import_query = _get_node_import_query(baseEntityLabel, include_source) + rel_import_query = _get_rel_import_query(baseEntityLabel) + for document in graph_documents: + if not document.source.metadata.get("id"): + document.source.metadata["id"] = md5( + document.source.page_content.encode("utf-8") + ).hexdigest() + + # Remove backticks from node types + for node in document.nodes: + node.type = _remove_backticks(node.type) + # Import nodes + self.query( + node_import_query, + { + "data": [el.__dict__ for el in document.nodes], + "document": document.source.__dict__, + }, + ) + # Import relationships + self.query( + rel_import_query, + { + "data": [ + { + "source": el.source.id, + "source_label": _remove_backticks(el.source.type), + "target": el.target.id, + "target_label": _remove_backticks(el.target.type), + "type": _remove_backticks( + el.type.replace(" ", "_").upper() + ), + "properties": el.properties, + } + for el in document.relationships + ] + }, + ) + + def _enhanced_schema_cypher( + self, + label_or_type: str, + properties: List[Dict[str, Any]], + exhaustive: bool, + is_relationship: bool = False, + ) -> str: + if is_relationship: + match_clause = f"MATCH ()-[n:`{label_or_type}`]->()" + else: + match_clause = f"MATCH (n:`{label_or_type}`)" + + with_clauses = [] + return_clauses = [] + output_dict = {} + if exhaustive: + for prop in properties: + prop_name = prop["property"] + prop_type = prop["type"] + if prop_type == "STRING": + with_clauses.append( + ( + f"collect(distinct substring(toString(n.`{prop_name}`)" + f", 0, 50)) AS `{prop_name}_values`" + ) + ) + return_clauses.append( + ( + f"values:`{prop_name}_values`[..{DISTINCT_VALUE_LIMIT}]," + f" distinct_count: size(`{prop_name}_values`)" + ) + ) + elif prop_type in [ + "INTEGER", + "FLOAT", + "DATE", + "DATE_TIME", + "LOCAL_DATE_TIME", + ]: + with_clauses.append(f"min(n.`{prop_name}`) AS `{prop_name}_min`") + with_clauses.append(f"max(n.`{prop_name}`) AS `{prop_name}_max`") + with_clauses.append( + f"count(distinct n.`{prop_name}`) AS `{prop_name}_distinct`" + ) + return_clauses.append( + ( + f"min: toString(`{prop_name}_min`), " + f"max: toString(`{prop_name}_max`), " + f"distinct_count: `{prop_name}_distinct`" + ) + ) + elif prop_type == "LIST": + with_clauses.append( + ( + f"min(size(n.`{prop_name}`)) AS `{prop_name}_size_min`, " + f"max(size(n.`{prop_name}`)) AS `{prop_name}_size_max`" + ) + ) + return_clauses.append( + f"min_size: `{prop_name}_size_min`, " + f"max_size: `{prop_name}_size_max`" + ) + elif prop_type in ["BOOLEAN", "POINT", "DURATION"]: + continue + output_dict[prop_name] = "{" + return_clauses.pop() + "}" + else: + # Just sample 5 random nodes + match_clause += " WITH n LIMIT 5" + for prop in properties: + prop_name = prop["property"] + prop_type = prop["type"] + + # Check if indexed property, we can still do exhaustive + prop_index = [ + el + for el in self.structured_schema["metadata"]["index"] + if el["label"] == label_or_type + and el["properties"] == [prop_name] + and el["type"] == "RANGE" + ] + if prop_type == "STRING": + if ( + prop_index + and prop_index[0].get("size") > 0 + and prop_index[0].get("distinctValues") <= DISTINCT_VALUE_LIMIT + ): + distinct_values = self.query( + f"CALL apoc.schema.properties.distinct(" + f"'{label_or_type}', '{prop_name}') YIELD value" + )[0]["value"] + return_clauses.append( + ( + f"values: {distinct_values}," + f" distinct_count: {len(distinct_values)}" + ) + ) + else: + with_clauses.append( + ( + f"collect(distinct substring(toString(n.`{prop_name}`)" + f", 0, 50)) AS `{prop_name}_values`" + ) + ) + return_clauses.append(f"values: `{prop_name}_values`") + elif prop_type in [ + "INTEGER", + "FLOAT", + "DATE", + "DATE_TIME", + "LOCAL_DATE_TIME", + ]: + if not prop_index: + with_clauses.append( + f"collect(distinct toString(n.`{prop_name}`)) " + f"AS `{prop_name}_values`" + ) + return_clauses.append(f"values: `{prop_name}_values`") + else: + with_clauses.append( + f"min(n.`{prop_name}`) AS `{prop_name}_min`" + ) + with_clauses.append( + f"max(n.`{prop_name}`) AS `{prop_name}_max`" + ) + with_clauses.append( + f"count(distinct n.`{prop_name}`) AS `{prop_name}_distinct`" + ) + return_clauses.append( + ( + f"min: toString(`{prop_name}_min`), " + f"max: toString(`{prop_name}_max`), " + f"distinct_count: `{prop_name}_distinct`" + ) + ) + + elif prop_type == "LIST": + with_clauses.append( + ( + f"min(size(n.`{prop_name}`)) AS `{prop_name}_size_min`, " + f"max(size(n.`{prop_name}`)) AS `{prop_name}_size_max`" + ) + ) + return_clauses.append( + ( + f"min_size: `{prop_name}_size_min`, " + f"max_size: `{prop_name}_size_max`" + ) + ) + elif prop_type in ["BOOLEAN", "POINT", "DURATION"]: + continue + + output_dict[prop_name] = "{" + return_clauses.pop() + "}" + + with_clause = "WITH " + ",\n ".join(with_clauses) + return_clause = ( + "RETURN {" + + ", ".join(f"`{k}`: {v}" for k, v in output_dict.items()) + + "} AS output" + ) + + # Combine all parts of the Cypher query + cypher_query = "\n".join([match_clause, with_clause, return_clause]) + return cypher_query diff --git a/libs/neo4j/langchain_neo4j/py.typed b/libs/neo4j/langchain_neo4j/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/langchain_neo4j/query_constructors/__init__.py b/libs/neo4j/langchain_neo4j/query_constructors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/langchain_neo4j/query_constructors/neo4j.py b/libs/neo4j/langchain_neo4j/query_constructors/neo4j.py new file mode 100644 index 0000000..ecb6245 --- /dev/null +++ b/libs/neo4j/langchain_neo4j/query_constructors/neo4j.py @@ -0,0 +1,60 @@ +from typing import Dict, Tuple, Union + +from langchain_core.structured_query import ( + Comparator, + Comparison, + Operation, + Operator, + StructuredQuery, + Visitor, +) + + +class Neo4jTranslator(Visitor): + """Translate `Neo4j` internal query language elements to valid filters.""" + + allowed_operators = [Operator.AND, Operator.OR] + """Subset of allowed logical operators.""" + + allowed_comparators = [ + Comparator.EQ, + Comparator.NE, + Comparator.GTE, + Comparator.LTE, + Comparator.LT, + Comparator.GT, + ] + + def _format_func(self, func: Union[Operator, Comparator]) -> str: + self._validate_func(func) + map_dict = { + Operator.AND: "$and", + Operator.OR: "$or", + Comparator.EQ: "$eq", + Comparator.NE: "$ne", + Comparator.GTE: "$gte", + Comparator.LTE: "$lte", + Comparator.LT: "$lt", + Comparator.GT: "$gt", + } + return map_dict[func] + + def visit_operation(self, operation: Operation) -> Dict: + args = [arg.accept(self) for arg in operation.arguments] + return {self._format_func(operation.operator): args} + + def visit_comparison(self, comparison: Comparison) -> Dict: + return { + comparison.attribute: { + self._format_func(comparison.comparator): comparison.value + } + } + + def visit_structured_query( + self, structured_query: StructuredQuery + ) -> Tuple[str, dict]: + if structured_query.filter is None: + kwargs = {} + else: + kwargs = {"filter": structured_query.filter.accept(self)} + return structured_query.query, kwargs diff --git a/libs/neo4j/langchain_neo4j/vectorstores/__init__.py b/libs/neo4j/langchain_neo4j/vectorstores/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/langchain_neo4j/vectorstores/neo4j_vector.py b/libs/neo4j/langchain_neo4j/vectorstores/neo4j_vector.py new file mode 100644 index 0000000..863de6d --- /dev/null +++ b/libs/neo4j/langchain_neo4j/vectorstores/neo4j_vector.py @@ -0,0 +1,1631 @@ +from __future__ import annotations + +import enum +import logging +import os +from hashlib import md5 +from typing import ( + Any, + Callable, + Dict, + Iterable, + List, + Optional, + Tuple, + Type, +) + +import numpy as np +from langchain_community.vectorstores.utils import ( + DistanceStrategy, + maximal_marginal_relevance, +) +from langchain_core.documents import Document +from langchain_core.embeddings import Embeddings +from langchain_core.utils import get_from_dict_or_env +from langchain_core.vectorstores import VectorStore + +from langchain_neo4j.graphs.neo4j_graph import Neo4jGraph + +DEFAULT_DISTANCE_STRATEGY = DistanceStrategy.COSINE +DISTANCE_MAPPING = { + DistanceStrategy.EUCLIDEAN_DISTANCE: "euclidean", + DistanceStrategy.COSINE: "cosine", +} + +COMPARISONS_TO_NATIVE = { + "$eq": "=", + "$ne": "<>", + "$lt": "<", + "$lte": "<=", + "$gt": ">", + "$gte": ">=", +} + +SPECIAL_CASED_OPERATORS = { + "$in", + "$nin", + "$between", +} + +TEXT_OPERATORS = { + "$like", + "$ilike", +} + +LOGICAL_OPERATORS = {"$and", "$or"} + +SUPPORTED_OPERATORS = ( + set(COMPARISONS_TO_NATIVE) + .union(TEXT_OPERATORS) + .union(LOGICAL_OPERATORS) + .union(SPECIAL_CASED_OPERATORS) +) + + +class SearchType(str, enum.Enum): + """Enumerator of the Distance strategies.""" + + VECTOR = "vector" + HYBRID = "hybrid" + + +DEFAULT_SEARCH_TYPE = SearchType.VECTOR + + +class IndexType(str, enum.Enum): + """Enumerator of the index types.""" + + NODE = "NODE" + RELATIONSHIP = "RELATIONSHIP" + + +DEFAULT_INDEX_TYPE = IndexType.NODE + + +def _get_search_index_query( + search_type: SearchType, index_type: IndexType = DEFAULT_INDEX_TYPE +) -> str: + if index_type == IndexType.NODE: + type_to_query_map = { + SearchType.VECTOR: ( + "CALL db.index.vector.queryNodes($index, $k, $embedding) " + "YIELD node, score " + ), + SearchType.HYBRID: ( + "CALL { " + "CALL db.index.vector.queryNodes($index, $k, $embedding) " + "YIELD node, score " + "WITH collect({node:node, score:score}) AS nodes, max(score) AS max " + "UNWIND nodes AS n " + # We use 0 as min + "RETURN n.node AS node, (n.score / max) AS score UNION " + "CALL db.index.fulltext.queryNodes($keyword_index, $query, " + "{limit: $k}) YIELD node, score " + "WITH collect({node:node, score:score}) AS nodes, max(score) AS max " + "UNWIND nodes AS n " + # We use 0 as min + "RETURN n.node AS node, (n.score / max) AS score " + "} " + # dedup + "WITH node, max(score) AS score ORDER BY score DESC LIMIT $k " + ), + } + return type_to_query_map[search_type] + else: + return ( + "CALL db.index.vector.queryRelationships($index, $k, $embedding) " + "YIELD relationship, score " + ) + + +def check_if_not_null(props: List[str], values: List[Any]) -> None: + """Check if the values are not None or empty string""" + for prop, value in zip(props, values): + if not value: + raise ValueError(f"Parameter `{prop}` must not be None or empty string") + + +def sort_by_index_name( + lst: List[Dict[str, Any]], index_name: str +) -> List[Dict[str, Any]]: + """Sort first element to match the index_name if exists""" + return sorted(lst, key=lambda x: x.get("name") != index_name) + + +def remove_lucene_chars(text: str) -> str: + """Remove Lucene special characters""" + special_chars = [ + "+", + "-", + "&", + "|", + "!", + "(", + ")", + "{", + "}", + "[", + "]", + "^", + '"', + "~", + "*", + "?", + ":", + "\\", + ] + for char in special_chars: + if char in text: + text = text.replace(char, " ") + return text.strip() + + +def dict_to_yaml_str(input_dict: Dict, indent: int = 0) -> str: + """ + Convert a dictionary to a YAML-like string without using external libraries. + + Parameters: + - input_dict (dict): The dictionary to convert. + - indent (int): The current indentation level. + + Returns: + - str: The YAML-like string representation of the input dictionary. + """ + yaml_str = "" + for key, value in input_dict.items(): + padding = " " * indent + if isinstance(value, dict): + yaml_str += f"{padding}{key}:\n{dict_to_yaml_str(value, indent + 1)}" + elif isinstance(value, list): + yaml_str += f"{padding}{key}:\n" + for item in value: + yaml_str += f"{padding}- {item}\n" + else: + yaml_str += f"{padding}{key}: {value}\n" + return yaml_str + + +def combine_queries( + input_queries: List[Tuple[str, Dict[str, Any]]], operator: str +) -> Tuple[str, Dict[str, Any]]: + """Combine multiple queries with an operator.""" + + # Initialize variables to hold the combined query and parameters + combined_query: str = "" + combined_params: Dict = {} + param_counter: Dict = {} + + for query, params in input_queries: + # Process each query fragment and its parameters + new_query = query + for param, value in params.items(): + # Update the parameter name to ensure uniqueness + if param in param_counter: + param_counter[param] += 1 + else: + param_counter[param] = 1 + new_param_name = f"{param}_{param_counter[param]}" + + # Replace the parameter in the query fragment + new_query = new_query.replace(f"${param}", f"${new_param_name}") + # Add the parameter to the combined parameters dictionary + combined_params[new_param_name] = value + + # Combine the query fragments with an AND operator + if combined_query: + combined_query += f" {operator} " + combined_query += f"({new_query})" + + return combined_query, combined_params + + +def collect_params( + input_data: List[Tuple[str, Dict[str, str]]], +) -> Tuple[List[str], Dict[str, Any]]: + """Transform the input data into the desired format. + + Args: + - input_data (list of tuples): Input data to transform. + Each tuple contains a string and a dictionary. + + Returns: + - tuple: A tuple containing a list of strings and a dictionary. + """ + # Initialize variables to hold the output parts + query_parts = [] + params = {} + + # Loop through each item in the input data + for query_part, param in input_data: + # Append the query part to the list + query_parts.append(query_part) + # Update the params dictionary with the param dictionary + params.update(param) + + # Return the transformed data + return (query_parts, params) + + +def _handle_field_filter( + field: str, value: Any, param_number: int = 1 +) -> Tuple[str, Dict]: + """Create a filter for a specific field. + + Args: + field: name of field + value: value to filter + If provided as is then this will be an equality filter + If provided as a dictionary then this will be a filter, the key + will be the operator and the value will be the value to filter by + param_number: sequence number of parameters used to map between param + dict and Cypher snippet + + Returns a tuple of + - Cypher filter snippet + - Dictionary with parameters used in filter snippet + """ + if not isinstance(field, str): + raise ValueError( + f"field should be a string but got: {type(field)} with value: {field}" + ) + + if field.startswith("$"): + raise ValueError( + f"Invalid filter condition. Expected a field but got an operator: " + f"{field}" + ) + + # Allow [a-zA-Z0-9_], disallow $ for now until we support escape characters + if not field.isidentifier(): + raise ValueError(f"Invalid field name: {field}. Expected a valid identifier.") + + if isinstance(value, dict): + # This is a filter specification + if len(value) != 1: + raise ValueError( + "Invalid filter condition. Expected a value which " + "is a dictionary with a single key that corresponds to an operator " + f"but got a dictionary with {len(value)} keys. The first few " + f"keys are: {list(value.keys())[:3]}" + ) + operator, filter_value = list(value.items())[0] + # Verify that that operator is an operator + if operator not in SUPPORTED_OPERATORS: + raise ValueError( + f"Invalid operator: {operator}. " + f"Expected one of {SUPPORTED_OPERATORS}" + ) + else: # Then we assume an equality operator + operator = "$eq" + filter_value = value + + if operator in COMPARISONS_TO_NATIVE: + # Then we implement an equality filter + # native is trusted input + native = COMPARISONS_TO_NATIVE[operator] + query_snippet = f"n.`{field}` {native} $param_{param_number}" + query_param = {f"param_{param_number}": filter_value} + return (query_snippet, query_param) + elif operator == "$between": + low, high = filter_value + query_snippet = ( + f"$param_{param_number}_low <= n.`{field}` <= $param_{param_number}_high" + ) + query_param = { + f"param_{param_number}_low": low, + f"param_{param_number}_high": high, + } + return (query_snippet, query_param) + + elif operator in {"$in", "$nin", "$like", "$ilike"}: + # We'll do force coercion to text + if operator in {"$in", "$nin"}: + for val in filter_value: + if not isinstance(val, (str, int, float)): + raise NotImplementedError( + f"Unsupported type: {type(val)} for value: {val}" + ) + if operator in {"$in"}: + query_snippet = f"n.`{field}` IN $param_{param_number}" + query_param = {f"param_{param_number}": filter_value} + return (query_snippet, query_param) + elif operator in {"$nin"}: + query_snippet = f"n.`{field}` NOT IN $param_{param_number}" + query_param = {f"param_{param_number}": filter_value} + return (query_snippet, query_param) + elif operator in {"$like"}: + query_snippet = f"n.`{field}` CONTAINS $param_{param_number}" + query_param = {f"param_{param_number}": filter_value.rstrip("%")} + return (query_snippet, query_param) + elif operator in {"$ilike"}: + query_snippet = f"toLower(n.`{field}`) CONTAINS $param_{param_number}" + query_param = {f"param_{param_number}": filter_value.rstrip("%")} + return (query_snippet, query_param) + else: + raise NotImplementedError() + else: + raise NotImplementedError() + + +def construct_metadata_filter(filter: Dict[str, Any]) -> Tuple[str, Dict]: + """Construct a metadata filter. + + Args: + filter: A dictionary representing the filter condition. + + Returns: + Tuple[str, Dict] + """ + + if isinstance(filter, dict): + if len(filter) == 1: + # The only operators allowed at the top level are $AND and $OR + # First check if an operator or a field + key, value = list(filter.items())[0] + if key.startswith("$"): + # Then it's an operator + if key.lower() not in ["$and", "$or"]: + raise ValueError( + f"Invalid filter condition. Expected $and or $or " + f"but got: {key}" + ) + else: + # Then it's a field + return _handle_field_filter(key, filter[key]) + + # Here we handle the $and and $or operators + if not isinstance(value, list): + raise ValueError( + f"Expected a list, but got {type(value)} for value: {value}" + ) + if key.lower() == "$and": + and_ = combine_queries( + [construct_metadata_filter(el) for el in value], "AND" + ) + if len(and_) >= 1: + return and_ + else: + raise ValueError( + "Invalid filter condition. Expected a dictionary " + "but got an empty dictionary" + ) + elif key.lower() == "$or": + or_ = combine_queries( + [construct_metadata_filter(el) for el in value], "OR" + ) + if len(or_) >= 1: + return or_ + else: + raise ValueError( + "Invalid filter condition. Expected a dictionary " + "but got an empty dictionary" + ) + else: + raise ValueError( + f"Invalid filter condition. Expected $and or $or " f"but got: {key}" + ) + elif len(filter) > 1: + # Then all keys have to be fields (they cannot be operators) + for key in filter.keys(): + if key.startswith("$"): + raise ValueError( + f"Invalid filter condition. Expected a field but got: {key}" + ) + # These should all be fields and combined using an $and operator + and_multiple = collect_params( + [ + _handle_field_filter(k, v, index) + for index, (k, v) in enumerate(filter.items()) + ] + ) + if len(and_multiple) >= 1: + return " AND ".join(and_multiple[0]), and_multiple[1] + else: + raise ValueError( + "Invalid filter condition. Expected a dictionary " + "but got an empty dictionary" + ) + else: + raise ValueError("Got an empty dictionary for filters.") + + +class Neo4jVector(VectorStore): + """`Neo4j` vector index. + + To use, you should have the ``neo4j`` python package installed. + + Args: + url: Neo4j connection url + username: Neo4j username. + password: Neo4j password + database: Optionally provide Neo4j database + Defaults to "neo4j" + embedding: Any embedding function implementing + `langchain.embeddings.base.Embeddings` interface. + distance_strategy: The distance strategy to use. (default: COSINE) + search_type: The type of search to be performed, either + 'vector' or 'hybrid' + node_label: The label used for nodes in the Neo4j database. + (default: "Chunk") + embedding_node_property: The property name in Neo4j to store embeddings. + (default: "embedding") + text_node_property: The property name in Neo4j to store the text. + (default: "text") + retrieval_query: The Cypher query to be used for customizing retrieval. + If empty, a default query will be used. + index_type: The type of index to be used, either + 'NODE' or 'RELATIONSHIP' + pre_delete_collection: If True, will delete existing data if it exists. + (default: False). Useful for testing. + + Example: + .. code-block:: python + + from langchain_neo4j import Neo4jVector + from langchain_community.embeddings.openai import OpenAIEmbeddings + + url="bolt://localhost:7687" + username="neo4j" + password="pleaseletmein" + embeddings = OpenAIEmbeddings() + vectorestore = Neo4jVector.from_documents( + embedding=embeddings, + documents=docs, + url=url + username=username, + password=password, + ) + + + """ + + def __init__( + self, + embedding: Embeddings, + *, + search_type: SearchType = SearchType.VECTOR, + username: Optional[str] = None, + password: Optional[str] = None, + url: Optional[str] = None, + keyword_index_name: Optional[str] = "keyword", + database: Optional[str] = None, + index_name: str = "vector", + node_label: str = "Chunk", + embedding_node_property: str = "embedding", + text_node_property: str = "text", + distance_strategy: DistanceStrategy = DEFAULT_DISTANCE_STRATEGY, + logger: Optional[logging.Logger] = None, + pre_delete_collection: bool = False, + retrieval_query: str = "", + relevance_score_fn: Optional[Callable[[float], float]] = None, + index_type: IndexType = DEFAULT_INDEX_TYPE, + graph: Optional[Neo4jGraph] = None, + ) -> None: + try: + import neo4j + except ImportError: + raise ImportError( + "Could not import neo4j python package. " + "Please install it with `pip install neo4j`." + ) + + # Allow only cosine and euclidean distance strategies + if distance_strategy not in [ + DistanceStrategy.EUCLIDEAN_DISTANCE, + DistanceStrategy.COSINE, + ]: + raise ValueError( + "distance_strategy must be either 'EUCLIDEAN_DISTANCE' or 'COSINE'" + ) + + # Graph object takes precedent over env or input params + if graph: + self._driver = graph._driver + self._database = graph._database + else: + # Handle if the credentials are environment variables + # Support URL for backwards compatibility + if not url: + url = os.environ.get("NEO4J_URL") + + url = get_from_dict_or_env({"url": url}, "url", "NEO4J_URI") + username = get_from_dict_or_env( + {"username": username}, "username", "NEO4J_USERNAME" + ) + password = get_from_dict_or_env( + {"password": password}, "password", "NEO4J_PASSWORD" + ) + database = get_from_dict_or_env( + {"database": database}, "database", "NEO4J_DATABASE", "neo4j" + ) + + self._driver = neo4j.GraphDatabase.driver(url, auth=(username, password)) + self._database = database + # Verify connection + try: + self._driver.verify_connectivity() + except neo4j.exceptions.ServiceUnavailable: + raise ValueError( + "Could not connect to Neo4j database. " + "Please ensure that the url is correct" + ) + except neo4j.exceptions.AuthError: + raise ValueError( + "Could not connect to Neo4j database. " + "Please ensure that the username and password are correct" + ) + + self.schema = "" + # Verify if the version support vector index + self._is_enterprise = False + self.verify_version() + + # Verify that required values are not null + check_if_not_null( + [ + "index_name", + "node_label", + "embedding_node_property", + "text_node_property", + ], + [index_name, node_label, embedding_node_property, text_node_property], + ) + + self.embedding = embedding + self._distance_strategy = distance_strategy + self.index_name = index_name + self.keyword_index_name = keyword_index_name + self.node_label = node_label + self.embedding_node_property = embedding_node_property + self.text_node_property = text_node_property + self.logger = logger or logging.getLogger(__name__) + self.override_relevance_score_fn = relevance_score_fn + self.retrieval_query = retrieval_query + self.search_type = search_type + self._index_type = index_type + # Calculate embedding dimension + self.embedding_dimension = len(embedding.embed_query("foo")) + + # Delete existing data if flagged + if pre_delete_collection: + from neo4j.exceptions import DatabaseError + + self.query( + f"MATCH (n:`{self.node_label}`) " + "CALL (n) { DETACH DELETE n } " + "IN TRANSACTIONS OF 10000 ROWS;" + ) + # Delete index + try: + self.query(f"DROP INDEX {self.index_name}") + except DatabaseError: # Index didn't exist yet + pass + + def query( + self, + query: str, + *, + params: Optional[dict] = None, + ) -> List[Dict[str, Any]]: + """Query Neo4j database with retries and exponential backoff. + + Args: + query (str): The Cypher query to execute. + params (dict, optional): Dictionary of query parameters. Defaults to {}. + + Returns: + List[Dict[str, Any]]: List of dictionaries containing the query results. + """ + from neo4j import Query + from neo4j.exceptions import Neo4jError + + params = params or {} + try: + data, _, _ = self._driver.execute_query( + query, database_=self._database, parameters_=params + ) + return [r.data() for r in data] + except Neo4jError as e: + if not ( + ( + ( # isCallInTransactionError + e.code == "Neo.DatabaseError.Statement.ExecutionFailed" + or e.code + == "Neo.DatabaseError.Transaction.TransactionStartFailed" + ) + and "in an implicit transaction" in e.message # type: ignore + ) + or ( # isPeriodicCommitError + e.code == "Neo.ClientError.Statement.SemanticError" + and ( + "in an open transaction is not possible" in e.message # type: ignore + or "tried to execute in an explicit transaction" in e.message # type: ignore + ) + ) + ): + raise + # Fallback to allow implicit transactions + with self._driver.session(database=self._database) as session: + data = session.run(Query(text=query), params) # type: ignore + return [r.data() for r in data] + + def verify_version(self) -> None: + """ + Check if the connected Neo4j database version supports vector indexing. + + Queries the Neo4j database to retrieve its version and compares it + against a target version (5.11.0) that is known to support vector + indexing. Raises a ValueError if the connected Neo4j version is + not supported. + """ + db_data = self.query("CALL dbms.components()") + version = db_data[0]["versions"][0] + if "aura" in version: + version_tuple = tuple(map(int, version.split("-")[0].split("."))) + (0,) + else: + version_tuple = tuple(map(int, version.split("."))) + + target_version = (5, 11, 0) + + if version_tuple < target_version: + raise ValueError( + "Version index is only supported in Neo4j version 5.11 or greater" + ) + + # Flag for metadata filtering + metadata_target_version = (5, 18, 0) + if version_tuple < metadata_target_version: + self.support_metadata_filter = False + else: + self.support_metadata_filter = True + # Flag for enterprise + self._is_enterprise = True if db_data[0]["edition"] == "enterprise" else False + + def retrieve_existing_index(self) -> Tuple[Optional[int], Optional[str]]: + """ + Check if the vector index exists in the Neo4j database + and returns its embedding dimension. + + This method queries the Neo4j database for existing indexes + and attempts to retrieve the dimension of the vector index + with the specified name. If the index exists, its dimension is returned. + If the index doesn't exist, `None` is returned. + + Returns: + int or None: The embedding dimension of the existing index if found. + """ + + index_information = self.query( + "SHOW INDEXES YIELD name, type, entityType, labelsOrTypes, " + "properties, options WHERE type = 'VECTOR' AND (name = $index_name " + "OR (labelsOrTypes[0] = $node_label AND " + "properties[0] = $embedding_node_property)) " + "RETURN name, entityType, labelsOrTypes, properties, options ", + params={ + "index_name": self.index_name, + "node_label": self.node_label, + "embedding_node_property": self.embedding_node_property, + }, + ) + # sort by index_name + index_information = sort_by_index_name(index_information, self.index_name) + try: + self.index_name = index_information[0]["name"] + self.node_label = index_information[0]["labelsOrTypes"][0] + self.embedding_node_property = index_information[0]["properties"][0] + self._index_type = index_information[0]["entityType"] + embedding_dimension = None + index_config = index_information[0]["options"]["indexConfig"] + if "vector.dimensions" in index_config: + embedding_dimension = index_config["vector.dimensions"] + + return embedding_dimension, index_information[0]["entityType"] + except IndexError: + return None, None + + def retrieve_existing_fts_index( + self, text_node_properties: List[str] = [] + ) -> Optional[str]: + """ + Check if the fulltext index exists in the Neo4j database + + This method queries the Neo4j database for existing fts indexes + with the specified name. + + Returns: + (Tuple): keyword index information + """ + + index_information = self.query( + "SHOW INDEXES YIELD name, type, labelsOrTypes, properties, options " + "WHERE type = 'FULLTEXT' AND (name = $keyword_index_name " + "OR (labelsOrTypes = [$node_label] AND " + "properties = $text_node_property)) " + "RETURN name, labelsOrTypes, properties, options ", + params={ + "keyword_index_name": self.keyword_index_name, + "node_label": self.node_label, + "text_node_property": text_node_properties or [self.text_node_property], + }, + ) + # sort by index_name + index_information = sort_by_index_name(index_information, self.index_name) + try: + self.keyword_index_name = index_information[0]["name"] + self.text_node_property = index_information[0]["properties"][0] + node_label = index_information[0]["labelsOrTypes"][0] + return node_label + except IndexError: + return None + + def create_new_index(self) -> None: + """ + This method constructs a Cypher query and executes it + to create a new vector index in Neo4j. + """ + index_query = ( + f"CREATE VECTOR INDEX {self.index_name} IF NOT EXISTS " + f"FOR (m:`{self.node_label}`) ON m.`{self.embedding_node_property}` " + "OPTIONS { indexConfig: { " + "`vector.dimensions`: toInteger($embedding_dimension), " + "`vector.similarity_function`: $similarity_metric }}" + ) + + parameters = { + "embedding_dimension": self.embedding_dimension, + "similarity_metric": DISTANCE_MAPPING[self._distance_strategy], + } + self.query(index_query, params=parameters) + + def create_new_keyword_index(self, text_node_properties: List[str] = []) -> None: + """ + This method constructs a Cypher query and executes it + to create a new full text index in Neo4j. + """ + node_props = text_node_properties or [self.text_node_property] + fts_index_query = ( + f"CREATE FULLTEXT INDEX {self.keyword_index_name} " + f"FOR (n:`{self.node_label}`) ON EACH " + f"[{', '.join(['n.`' + el + '`' for el in node_props])}]" + ) + self.query(fts_index_query) + + @property + def embeddings(self) -> Embeddings: + return self.embedding + + @classmethod + def __from( + cls, + texts: List[str], + embeddings: List[List[float]], + embedding: Embeddings, + metadatas: Optional[List[dict]] = None, + ids: Optional[List[str]] = None, + create_id_index: bool = True, + search_type: SearchType = SearchType.VECTOR, + **kwargs: Any, + ) -> Neo4jVector: + if ids is None: + ids = [md5(text.encode("utf-8")).hexdigest() for text in texts] + + if not metadatas: + metadatas = [{} for _ in texts] + + store = cls( + embedding=embedding, + search_type=search_type, + **kwargs, + ) + # Check if the vector index already exists + embedding_dimension, index_type = store.retrieve_existing_index() + + # Raise error if relationship index type + if index_type == "RELATIONSHIP": + raise ValueError( + "Data ingestion is not supported with relationship vector index." + ) + + # If the vector index doesn't exist yet + if not index_type: + store.create_new_index() + # If the index already exists, check if embedding dimensions match + elif ( + embedding_dimension and not store.embedding_dimension == embedding_dimension + ): + raise ValueError( + f"Index with name {store.index_name} already exists." + "The provided embedding function and vector index " + "dimensions do not match.\n" + f"Embedding function dimension: {store.embedding_dimension}\n" + f"Vector index dimension: {embedding_dimension}" + ) + + if search_type == SearchType.HYBRID: + fts_node_label = store.retrieve_existing_fts_index() + # If the FTS index doesn't exist yet + if not fts_node_label: + store.create_new_keyword_index() + else: # Validate that FTS and Vector index use the same information + if not fts_node_label == store.node_label: + raise ValueError( + "Vector and keyword index don't index the same node label" + ) + + # Create unique constraint for faster import + if create_id_index: + store.query( + "CREATE CONSTRAINT IF NOT EXISTS " + f"FOR (n:`{store.node_label}`) REQUIRE n.id IS UNIQUE;" + ) + + store.add_embeddings( + texts=texts, embeddings=embeddings, metadatas=metadatas, ids=ids, **kwargs + ) + + return store + + def add_embeddings( + self, + texts: Iterable[str], + embeddings: List[List[float]], + metadatas: Optional[List[dict]] = None, + ids: Optional[List[str]] = None, + **kwargs: Any, + ) -> List[str]: + """Add embeddings to the vectorstore. + + Args: + texts: Iterable of strings to add to the vectorstore. + embeddings: List of list of embedding vectors. + metadatas: List of metadatas associated with the texts. + kwargs: vectorstore specific parameters + """ + if ids is None: + ids = [md5(text.encode("utf-8")).hexdigest() for text in texts] + + if not metadatas: + metadatas = [{} for _ in texts] + + import_query = ( + "UNWIND $data AS row " + "CALL (row) { WITH row " + f"MERGE (c:`{self.node_label}` {{id: row.id}}) " + "WITH c, row " + f"CALL db.create.setNodeVectorProperty(c, " + f"'{self.embedding_node_property}', row.embedding) " + f"SET c.`{self.text_node_property}` = row.text " + "SET c += row.metadata " + "} IN TRANSACTIONS OF 1000 ROWS " + ) + + parameters = { + "data": [ + {"text": text, "metadata": metadata, "embedding": embedding, "id": id} + for text, metadata, embedding, id in zip( + texts, metadatas, embeddings, ids + ) + ] + } + + self.query(import_query, params=parameters) + + return ids + + def add_texts( + self, + texts: Iterable[str], + metadatas: Optional[List[dict]] = None, + ids: Optional[List[str]] = None, + **kwargs: Any, + ) -> List[str]: + """Run more texts through the embeddings and add to the vectorstore. + + Args: + texts: Iterable of strings to add to the vectorstore. + metadatas: Optional list of metadatas associated with the texts. + kwargs: vectorstore specific parameters + + Returns: + List of ids from adding the texts into the vectorstore. + """ + embeddings = self.embedding.embed_documents(list(texts)) + return self.add_embeddings( + texts=texts, embeddings=embeddings, metadatas=metadatas, ids=ids, **kwargs + ) + + def similarity_search( + self, + query: str, + k: int = 4, + params: Dict[str, Any] = {}, + filter: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> List[Document]: + """Run similarity search with Neo4jVector. + + Args: + query (str): Query text to search for. + k (int): Number of results to return. Defaults to 4. + params (Dict[str, Any]): The search params for the index type. + Defaults to empty dict. + filter (Optional[Dict[str, Any]]): Dictionary of argument(s) to + filter on metadata. + Defaults to None. + + Returns: + List of Documents most similar to the query. + """ + embedding = self.embedding.embed_query(text=query) + return self.similarity_search_by_vector( + embedding=embedding, + k=k, + query=query, + params=params, + filter=filter, + **kwargs, + ) + + def similarity_search_with_score( + self, + query: str, + k: int = 4, + params: Dict[str, Any] = {}, + filter: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> List[Tuple[Document, float]]: + """Return docs most similar to query. + + Args: + query: Text to look up documents similar to. + k: Number of Documents to return. Defaults to 4. + params (Dict[str, Any]): The search params for the index type. + Defaults to empty dict. + filter (Optional[Dict[str, Any]]): Dictionary of argument(s) to + filter on metadata. + Defaults to None. + + Returns: + List of Documents most similar to the query and score for each + """ + embedding = self.embedding.embed_query(query) + docs = self.similarity_search_with_score_by_vector( + embedding=embedding, + k=k, + query=query, + params=params, + filter=filter, + **kwargs, + ) + return docs + + def similarity_search_with_score_by_vector( + self, + embedding: List[float], + k: int = 4, + filter: Optional[Dict[str, Any]] = None, + params: Dict[str, Any] = {}, + **kwargs: Any, + ) -> List[Tuple[Document, float]]: + """ + Perform a similarity search in the Neo4j database using a + given vector and return the top k similar documents with their scores. + + This method uses a Cypher query to find the top k documents that + are most similar to a given embedding. The similarity is measured + using a vector index in the Neo4j database. The results are returned + as a list of tuples, each containing a Document object and + its similarity score. + + Args: + embedding (List[float]): The embedding vector to compare against. + k (int, optional): The number of top similar documents to retrieve. + filter (Optional[Dict[str, Any]]): Dictionary of argument(s) to + filter on metadata. + Defaults to None. + params (Dict[str, Any]): The search params for the index type. + Defaults to empty dict. + + Returns: + List[Tuple[Document, float]]: A list of tuples, each containing + a Document object and its similarity score. + """ + if filter: + # Verify that 5.18 or later is used + if not self.support_metadata_filter: + raise ValueError( + "Metadata filtering is only supported in " + "Neo4j version 5.18 or greater" + ) + # Metadata filtering and hybrid doesn't work + if self.search_type == SearchType.HYBRID: + raise ValueError( + "Metadata filtering can't be use in combination with " + "a hybrid search approach" + ) + parallel_query = ( + "CYPHER runtime = parallel parallelRuntimeSupport=all " + if self._is_enterprise + else "" + ) + base_index_query = parallel_query + ( + f"MATCH (n:`{self.node_label}`) WHERE " + f"n.`{self.embedding_node_property}` IS NOT NULL AND " + f"size(n.`{self.embedding_node_property}`) = " + f"toInteger({self.embedding_dimension}) AND " + ) + base_cosine_query = ( + " WITH n as node, vector.similarity.cosine(" + f"n.`{self.embedding_node_property}`, " + "$embedding) AS score ORDER BY score DESC LIMIT toInteger($k) " + ) + filter_snippets, filter_params = construct_metadata_filter(filter) + index_query = base_index_query + filter_snippets + base_cosine_query + + else: + index_query = _get_search_index_query(self.search_type, self._index_type) + filter_params = {} + + if self._index_type == IndexType.RELATIONSHIP: + if kwargs.get("return_embeddings"): + default_retrieval = ( + f"RETURN relationship.`{self.text_node_property}` AS text, score, " + f"relationship {{.*, `{self.text_node_property}`: Null, " + f"`{self.embedding_node_property}`: Null, id: Null, " + f"_embedding_: relationship.`{self.embedding_node_property}`}} " + "AS metadata" + ) + else: + default_retrieval = ( + f"RETURN relationship.`{self.text_node_property}` AS text, score, " + f"relationship {{.*, `{self.text_node_property}`: Null, " + f"`{self.embedding_node_property}`: Null, id: Null }} AS metadata" + ) + + else: + if kwargs.get("return_embeddings"): + default_retrieval = ( + f"RETURN node.`{self.text_node_property}` AS text, score, " + f"node {{.*, `{self.text_node_property}`: Null, " + f"`{self.embedding_node_property}`: Null, id: Null, " + f"_embedding_: node.`{self.embedding_node_property}`}} AS metadata" + ) + else: + default_retrieval = ( + f"RETURN node.`{self.text_node_property}` AS text, score, " + f"node {{.*, `{self.text_node_property}`: Null, " + f"`{self.embedding_node_property}`: Null, id: Null }} AS metadata" + ) + + retrieval_query = ( + self.retrieval_query if self.retrieval_query else default_retrieval + ) + + read_query = index_query + retrieval_query + parameters = { + "index": self.index_name, + "k": k, + "embedding": embedding, + "keyword_index": self.keyword_index_name, + "query": remove_lucene_chars(kwargs["query"]), + **params, + **filter_params, + } + + results = self.query(read_query, params=parameters) + + if any(result["text"] is None for result in results): + if not self.retrieval_query: + raise ValueError( + f"Make sure that none of the `{self.text_node_property}` " + f"properties on nodes with label `{self.node_label}` " + "are missing or empty" + ) + else: + raise ValueError( + "Inspect the `retrieval_query` and ensure it doesn't " + "return None for the `text` column" + ) + if kwargs.get("return_embeddings") and any( + result["metadata"]["_embedding_"] is None for result in results + ): + if not self.retrieval_query: + raise ValueError( + f"Make sure that none of the `{self.embedding_node_property}` " + f"properties on nodes with label `{self.node_label}` " + "are missing or empty" + ) + else: + raise ValueError( + "Inspect the `retrieval_query` and ensure it doesn't " + "return None for the `_embedding_` metadata column" + ) + + docs = [ + ( + Document( + page_content=dict_to_yaml_str(result["text"]) + if isinstance(result["text"], dict) + else result["text"], + metadata={ + k: v for k, v in result["metadata"].items() if v is not None + }, + ), + result["score"], + ) + for result in results + ] + return docs + + def similarity_search_by_vector( + self, + embedding: List[float], + k: int = 4, + filter: Optional[Dict[str, Any]] = None, + params: Dict[str, Any] = {}, + **kwargs: Any, + ) -> List[Document]: + """Return docs most similar to embedding vector. + + Args: + embedding: Embedding to look up documents similar to. + k: Number of Documents to return. Defaults to 4. + filter (Optional[Dict[str, Any]]): Dictionary of argument(s) to + filter on metadata. + Defaults to None. + params (Dict[str, Any]): The search params for the index type. + Defaults to empty dict. + + Returns: + List of Documents most similar to the query vector. + """ + docs_and_scores = self.similarity_search_with_score_by_vector( + embedding=embedding, k=k, filter=filter, params=params, **kwargs + ) + return [doc for doc, _ in docs_and_scores] + + @classmethod + def from_texts( + cls: Type[Neo4jVector], + texts: List[str], + embedding: Embeddings, + metadatas: Optional[List[dict]] = None, + distance_strategy: DistanceStrategy = DEFAULT_DISTANCE_STRATEGY, + ids: Optional[List[str]] = None, + **kwargs: Any, + ) -> Neo4jVector: + """ + Return Neo4jVector initialized from texts and embeddings. + Neo4j credentials are required in the form of `url`, `username`, + and `password` and optional `database` parameters. + """ + embeddings = embedding.embed_documents(list(texts)) + + return cls.__from( + texts, + embeddings, + embedding, + metadatas=metadatas, + ids=ids, + distance_strategy=distance_strategy, + **kwargs, + ) + + @classmethod + def from_embeddings( + cls, + text_embeddings: List[Tuple[str, List[float]]], + embedding: Embeddings, + metadatas: Optional[List[dict]] = None, + distance_strategy: DistanceStrategy = DEFAULT_DISTANCE_STRATEGY, + ids: Optional[List[str]] = None, + pre_delete_collection: bool = False, + **kwargs: Any, + ) -> Neo4jVector: + """Construct Neo4jVector wrapper from raw documents and pre- + generated embeddings. + + Return Neo4jVector initialized from documents and embeddings. + Neo4j credentials are required in the form of `url`, `username`, + and `password` and optional `database` parameters. + + Example: + .. code-block:: python + + from langchain_neo4j import Neo4jVector + from langchain_community.embeddings import OpenAIEmbeddings + embeddings = OpenAIEmbeddings() + text_embeddings = embeddings.embed_documents(texts) + text_embedding_pairs = list(zip(texts, text_embeddings)) + vectorstore = Neo4jVector.from_embeddings( + text_embedding_pairs, embeddings) + """ + texts = [t[0] for t in text_embeddings] + embeddings = [t[1] for t in text_embeddings] + + return cls.__from( + texts, + embeddings, + embedding, + metadatas=metadatas, + ids=ids, + distance_strategy=distance_strategy, + pre_delete_collection=pre_delete_collection, + **kwargs, + ) + + @classmethod + def from_existing_index( + cls: Type[Neo4jVector], + embedding: Embeddings, + index_name: str, + search_type: SearchType = DEFAULT_SEARCH_TYPE, + keyword_index_name: Optional[str] = None, + **kwargs: Any, + ) -> Neo4jVector: + """ + Get instance of an existing Neo4j vector index. This method will + return the instance of the store without inserting any new + embeddings. + Neo4j credentials are required in the form of `url`, `username`, + and `password` and optional `database` parameters along with + the `index_name` definition. + """ + + if search_type == SearchType.HYBRID and not keyword_index_name: + raise ValueError( + "keyword_index name has to be specified " + "when using hybrid search option" + ) + + store = cls( + embedding=embedding, + index_name=index_name, + keyword_index_name=keyword_index_name, + search_type=search_type, + **kwargs, + ) + + embedding_dimension, index_type = store.retrieve_existing_index() + + # Raise error if relationship index type + if index_type == "RELATIONSHIP": + raise ValueError( + "Relationship vector index is not supported with " + "`from_existing_index` method. Please use the " + "`from_existing_relationship_index` method." + ) + + if not index_type: + raise ValueError( + "The specified vector index name does not exist. " + "Make sure to check if you spelled it correctly" + ) + + # Check if embedding function and vector index dimensions match + if embedding_dimension and not store.embedding_dimension == embedding_dimension: + raise ValueError( + "The provided embedding function and vector index " + "dimensions do not match.\n" + f"Embedding function dimension: {store.embedding_dimension}\n" + f"Vector index dimension: {embedding_dimension}" + ) + + if search_type == SearchType.HYBRID: + fts_node_label = store.retrieve_existing_fts_index() + # If the FTS index doesn't exist yet + if not fts_node_label: + raise ValueError( + "The specified keyword index name does not exist. " + "Make sure to check if you spelled it correctly" + ) + else: # Validate that FTS and Vector index use the same information + if not fts_node_label == store.node_label: + raise ValueError( + "Vector and keyword index don't index the same node label" + ) + + return store + + @classmethod + def from_existing_relationship_index( + cls: Type[Neo4jVector], + embedding: Embeddings, + index_name: str, + search_type: SearchType = DEFAULT_SEARCH_TYPE, + **kwargs: Any, + ) -> Neo4jVector: + """ + Get instance of an existing Neo4j relationship vector index. + This method will return the instance of the store without + inserting any new embeddings. + Neo4j credentials are required in the form of `url`, `username`, + and `password` and optional `database` parameters along with + the `index_name` definition. + """ + + if search_type == SearchType.HYBRID: + raise ValueError( + "Hybrid search is not supported in combination " + "with relationship vector index" + ) + + store = cls( + embedding=embedding, + index_name=index_name, + **kwargs, + ) + + embedding_dimension, index_type = store.retrieve_existing_index() + + if not index_type: + raise ValueError( + "The specified vector index name does not exist. " + "Make sure to check if you spelled it correctly" + ) + # Raise error if relationship index type + if index_type == "NODE": + raise ValueError( + "Node vector index is not supported with " + "`from_existing_relationship_index` method. Please use the " + "`from_existing_index` method." + ) + + # Check if embedding function and vector index dimensions match + if embedding_dimension and not store.embedding_dimension == embedding_dimension: + raise ValueError( + "The provided embedding function and vector index " + "dimensions do not match.\n" + f"Embedding function dimension: {store.embedding_dimension}\n" + f"Vector index dimension: {embedding_dimension}" + ) + + return store + + @classmethod + def from_documents( + cls: Type[Neo4jVector], + documents: List[Document], + embedding: Embeddings, + distance_strategy: DistanceStrategy = DEFAULT_DISTANCE_STRATEGY, + ids: Optional[List[str]] = None, + **kwargs: Any, + ) -> Neo4jVector: + """ + Return Neo4jVector initialized from documents and embeddings. + Neo4j credentials are required in the form of `url`, `username`, + and `password` and optional `database` parameters. + """ + + texts = [d.page_content for d in documents] + metadatas = [d.metadata for d in documents] + + return cls.from_texts( + texts=texts, + embedding=embedding, + distance_strategy=distance_strategy, + metadatas=metadatas, + ids=ids, + **kwargs, + ) + + @classmethod + def from_existing_graph( + cls: Type[Neo4jVector], + embedding: Embeddings, + node_label: str, + embedding_node_property: str, + text_node_properties: List[str], + *, + keyword_index_name: Optional[str] = "keyword", + index_name: str = "vector", + search_type: SearchType = DEFAULT_SEARCH_TYPE, + retrieval_query: str = "", + **kwargs: Any, + ) -> Neo4jVector: + """ + Initialize and return a Neo4jVector instance from an existing graph. + + This method initializes a Neo4jVector instance using the provided + parameters and the existing graph. It validates the existence of + the indices and creates new ones if they don't exist. + + Returns: + Neo4jVector: An instance of Neo4jVector initialized with the provided parameters + and existing graph. + + Example: + >>> neo4j_vector = Neo4jVector.from_existing_graph( + ... embedding=my_embedding, + ... node_label="Document", + ... embedding_node_property="embedding", + ... text_node_properties=["title", "content"] + ... ) + + Note: + Neo4j credentials are required in the form of `url`, `username`, and `password`, + and optional `database` parameters passed as additional keyword arguments. + """ + # Validate the list is not empty + if not text_node_properties: + raise ValueError( + "Parameter `text_node_properties` must not be an empty list" + ) + # Prefer retrieval query from params, otherwise construct it + if not retrieval_query: + retrieval_query = ( + f"RETURN reduce(str='', k IN {text_node_properties} |" + " str + '\\n' + k + ': ' + coalesce(node[k], '')) AS text, " + "node {.*, `" + + embedding_node_property + + "`: Null, id: Null, " + + ", ".join([f"`{prop}`: Null" for prop in text_node_properties]) + + "} AS metadata, score" + ) + store = cls( + embedding=embedding, + index_name=index_name, + keyword_index_name=keyword_index_name, + search_type=search_type, + retrieval_query=retrieval_query, + node_label=node_label, + embedding_node_property=embedding_node_property, + **kwargs, + ) + + # Check if the vector index already exists + embedding_dimension, index_type = store.retrieve_existing_index() + + # Raise error if relationship index type + if index_type == "RELATIONSHIP": + raise ValueError( + "`from_existing_graph` method does not support " + " existing relationship vector index. " + "Please use `from_existing_relationship_index` method" + ) + + # If the vector index doesn't exist yet + if not index_type: + store.create_new_index() + # If the index already exists, check if embedding dimensions match + elif ( + embedding_dimension and not store.embedding_dimension == embedding_dimension + ): + raise ValueError( + f"Index with name {store.index_name} already exists." + "The provided embedding function and vector index " + "dimensions do not match.\n" + f"Embedding function dimension: {store.embedding_dimension}\n" + f"Vector index dimension: {embedding_dimension}" + ) + # FTS index for Hybrid search + if search_type == SearchType.HYBRID: + fts_node_label = store.retrieve_existing_fts_index(text_node_properties) + # If the FTS index doesn't exist yet + if not fts_node_label: + store.create_new_keyword_index(text_node_properties) + else: # Validate that FTS and Vector index use the same information + if not fts_node_label == store.node_label: + raise ValueError( + "Vector and keyword index don't index the same node label" + ) + + # Populate embeddings + while True: + fetch_query = ( + f"MATCH (n:`{node_label}`) " + f"WHERE n.{embedding_node_property} IS null " + "AND any(k in $props WHERE n[k] IS NOT null) " + f"RETURN elementId(n) AS id, reduce(str=''," + "k IN $props | str + '\\n' + k + ':' + coalesce(n[k], '')) AS text " + "LIMIT 1000" + ) + data = store.query(fetch_query, params={"props": text_node_properties}) + if not data: + break + text_embeddings = embedding.embed_documents([el["text"] for el in data]) + + params = { + "data": [ + {"id": el["id"], "embedding": embedding} + for el, embedding in zip(data, text_embeddings) + ] + } + + store.query( + "UNWIND $data AS row " + f"MATCH (n:`{node_label}`) " + "WHERE elementId(n) = row.id " + f"CALL db.create.setNodeVectorProperty(n, " + f"'{embedding_node_property}', row.embedding) " + "RETURN count(*)", + params=params, + ) + # If embedding calculation should be stopped + if len(data) < 1000: + break + return store + + def max_marginal_relevance_search( + self, + query: str, + k: int = 4, + fetch_k: int = 20, + lambda_mult: float = 0.5, + filter: Optional[dict] = None, + **kwargs: Any, + ) -> List[Document]: + """Return docs selected using the maximal marginal relevance. + + Maximal marginal relevance optimizes for similarity to query AND diversity + among selected documents. + + Args: + query: search query text. + k: Number of Documents to return. Defaults to 4. + fetch_k: Number of Documents to fetch to pass to MMR algorithm. + lambda_mult: Number between 0 and 1 that determines the degree + of diversity among the results with 0 corresponding + to maximum diversity and 1 to minimum diversity. + Defaults to 0.5. + filter: Filter on metadata properties, e.g. + { + "str_property": "foo", + "int_property": 123 + } + Returns: + List of Documents selected by maximal marginal relevance. + """ + # Embed the query + query_embedding = self.embedding.embed_query(query) + + # Fetch the initial documents + got_docs = self.similarity_search_with_score_by_vector( + embedding=query_embedding, + query=query, + k=fetch_k, + return_embeddings=True, + filter=filter, + **kwargs, + ) + + # Get the embeddings for the fetched documents + got_embeddings = [doc.metadata["_embedding_"] for doc, _ in got_docs] + + # Select documents using maximal marginal relevance + selected_indices = maximal_marginal_relevance( + np.array(query_embedding), got_embeddings, lambda_mult=lambda_mult, k=k + ) + selected_docs = [got_docs[i][0] for i in selected_indices] + + # Remove embedding values from metadata + for doc in selected_docs: + del doc.metadata["_embedding_"] + + return selected_docs + + def _select_relevance_score_fn(self) -> Callable[[float], float]: + """ + The 'correct' relevance function + may differ depending on a few things, including: + - the distance / similarity metric used by the VectorStore + - the scale of your embeddings (OpenAI's are unit normed. Many others are not!) + - embedding dimensionality + - etc. + """ + if self.override_relevance_score_fn is not None: + return self.override_relevance_score_fn + + # Default strategy is to rely on distance strategy provided + # in vectorstore constructor + if self._distance_strategy == DistanceStrategy.COSINE: + return lambda x: x + elif self._distance_strategy == DistanceStrategy.EUCLIDEAN_DISTANCE: + return lambda x: x + else: + raise ValueError( + "No supported normalization function" + f" for distance_strategy of {self._distance_strategy}." + "Consider providing relevance_score_fn to PGVector constructor." + ) diff --git a/libs/neo4j/poetry.lock b/libs/neo4j/poetry.lock new file mode 100644 index 0000000..db1f398 --- /dev/null +++ b/libs/neo4j/poetry.lock @@ -0,0 +1,1901 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "aiohappyeyeballs" +version = "2.4.3" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohappyeyeballs-2.4.3-py3-none-any.whl", hash = "sha256:8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572"}, + {file = "aiohappyeyeballs-2.4.3.tar.gz", hash = "sha256:75cf88a15106a5002a8eb1dab212525c00d1f4c0fa96e551c9fbe6f09a621586"}, +] + +[[package]] +name = "aiohttp" +version = "3.10.10" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-3.10.10-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:be7443669ae9c016b71f402e43208e13ddf00912f47f623ee5994e12fc7d4b3f"}, + {file = "aiohttp-3.10.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b06b7843929e41a94ea09eb1ce3927865387e3e23ebe108e0d0d09b08d25be9"}, + {file = "aiohttp-3.10.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:333cf6cf8e65f6a1e06e9eb3e643a0c515bb850d470902274239fea02033e9a8"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:274cfa632350225ce3fdeb318c23b4a10ec25c0e2c880eff951a3842cf358ac1"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9e5e4a85bdb56d224f412d9c98ae4cbd032cc4f3161818f692cd81766eee65a"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b606353da03edcc71130b52388d25f9a30a126e04caef1fd637e31683033abd"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab5a5a0c7a7991d90446a198689c0535be89bbd6b410a1f9a66688f0880ec026"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:578a4b875af3e0daaf1ac6fa983d93e0bbfec3ead753b6d6f33d467100cdc67b"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8105fd8a890df77b76dd3054cddf01a879fc13e8af576805d667e0fa0224c35d"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3bcd391d083f636c06a68715e69467963d1f9600f85ef556ea82e9ef25f043f7"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fbc6264158392bad9df19537e872d476f7c57adf718944cc1e4495cbabf38e2a"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e48d5021a84d341bcaf95c8460b152cfbad770d28e5fe14a768988c461b821bc"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2609e9ab08474702cc67b7702dbb8a80e392c54613ebe80db7e8dbdb79837c68"}, + {file = "aiohttp-3.10.10-cp310-cp310-win32.whl", hash = "sha256:84afcdea18eda514c25bc68b9af2a2b1adea7c08899175a51fe7c4fb6d551257"}, + {file = "aiohttp-3.10.10-cp310-cp310-win_amd64.whl", hash = "sha256:9c72109213eb9d3874f7ac8c0c5fa90e072d678e117d9061c06e30c85b4cf0e6"}, + {file = "aiohttp-3.10.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c30a0eafc89d28e7f959281b58198a9fa5e99405f716c0289b7892ca345fe45f"}, + {file = "aiohttp-3.10.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:258c5dd01afc10015866114e210fb7365f0d02d9d059c3c3415382ab633fcbcb"}, + {file = "aiohttp-3.10.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:15ecd889a709b0080f02721255b3f80bb261c2293d3c748151274dfea93ac871"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3935f82f6f4a3820270842e90456ebad3af15810cf65932bd24da4463bc0a4c"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:413251f6fcf552a33c981c4709a6bba37b12710982fec8e558ae944bfb2abd38"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1720b4f14c78a3089562b8875b53e36b51c97c51adc53325a69b79b4b48ebcb"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:679abe5d3858b33c2cf74faec299fda60ea9de62916e8b67e625d65bf069a3b7"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79019094f87c9fb44f8d769e41dbb664d6e8fcfd62f665ccce36762deaa0e911"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2fb38c2ed905a2582948e2de560675e9dfbee94c6d5ccdb1301c6d0a5bf092"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a3f00003de6eba42d6e94fabb4125600d6e484846dbf90ea8e48a800430cc142"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1bbb122c557a16fafc10354b9d99ebf2f2808a660d78202f10ba9d50786384b9"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:30ca7c3b94708a9d7ae76ff281b2f47d8eaf2579cd05971b5dc681db8caac6e1"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df9270660711670e68803107d55c2b5949c2e0f2e4896da176e1ecfc068b974a"}, + {file = "aiohttp-3.10.10-cp311-cp311-win32.whl", hash = "sha256:aafc8ee9b742ce75044ae9a4d3e60e3d918d15a4c2e08a6c3c3e38fa59b92d94"}, + {file = "aiohttp-3.10.10-cp311-cp311-win_amd64.whl", hash = "sha256:362f641f9071e5f3ee6f8e7d37d5ed0d95aae656adf4ef578313ee585b585959"}, + {file = "aiohttp-3.10.10-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9294bbb581f92770e6ed5c19559e1e99255e4ca604a22c5c6397b2f9dd3ee42c"}, + {file = "aiohttp-3.10.10-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a8fa23fe62c436ccf23ff930149c047f060c7126eae3ccea005f0483f27b2e28"}, + {file = "aiohttp-3.10.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c6a5b8c7926ba5d8545c7dd22961a107526562da31a7a32fa2456baf040939f"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:007ec22fbc573e5eb2fb7dec4198ef8f6bf2fe4ce20020798b2eb5d0abda6138"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9627cc1a10c8c409b5822a92d57a77f383b554463d1884008e051c32ab1b3742"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50edbcad60d8f0e3eccc68da67f37268b5144ecc34d59f27a02f9611c1d4eec7"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a45d85cf20b5e0d0aa5a8dca27cce8eddef3292bc29d72dcad1641f4ed50aa16"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b00807e2605f16e1e198f33a53ce3c4523114059b0c09c337209ae55e3823a8"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f2d4324a98062be0525d16f768a03e0bbb3b9fe301ceee99611dc9a7953124e6"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:438cd072f75bb6612f2aca29f8bd7cdf6e35e8f160bc312e49fbecab77c99e3a"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:baa42524a82f75303f714108fea528ccacf0386af429b69fff141ffef1c534f9"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a7d8d14fe962153fc681f6366bdec33d4356f98a3e3567782aac1b6e0e40109a"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1277cd707c465cd09572a774559a3cc7c7a28802eb3a2a9472588f062097205"}, + {file = "aiohttp-3.10.10-cp312-cp312-win32.whl", hash = "sha256:59bb3c54aa420521dc4ce3cc2c3fe2ad82adf7b09403fa1f48ae45c0cbde6628"}, + {file = "aiohttp-3.10.10-cp312-cp312-win_amd64.whl", hash = "sha256:0e1b370d8007c4ae31ee6db7f9a2fe801a42b146cec80a86766e7ad5c4a259cf"}, + {file = "aiohttp-3.10.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ad7593bb24b2ab09e65e8a1d385606f0f47c65b5a2ae6c551db67d6653e78c28"}, + {file = "aiohttp-3.10.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1eb89d3d29adaf533588f209768a9c02e44e4baf832b08118749c5fad191781d"}, + {file = "aiohttp-3.10.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3fe407bf93533a6fa82dece0e74dbcaaf5d684e5a51862887f9eaebe6372cd79"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50aed5155f819873d23520919e16703fc8925e509abbb1a1491b0087d1cd969e"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f05e9727ce409358baa615dbeb9b969db94324a79b5a5cea45d39bdb01d82e6"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dffb610a30d643983aeb185ce134f97f290f8935f0abccdd32c77bed9388b42"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa6658732517ddabe22c9036479eabce6036655ba87a0224c612e1ae6af2087e"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:741a46d58677d8c733175d7e5aa618d277cd9d880301a380fd296975a9cdd7bc"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e00e3505cd80440f6c98c6d69269dcc2a119f86ad0a9fd70bccc59504bebd68a"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ffe595f10566f8276b76dc3a11ae4bb7eba1aac8ddd75811736a15b0d5311414"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdfcf6443637c148c4e1a20c48c566aa694fa5e288d34b20fcdc58507882fed3"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d183cf9c797a5291e8301790ed6d053480ed94070637bfaad914dd38b0981f67"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:77abf6665ae54000b98b3c742bc6ea1d1fb31c394bcabf8b5d2c1ac3ebfe7f3b"}, + {file = "aiohttp-3.10.10-cp313-cp313-win32.whl", hash = "sha256:4470c73c12cd9109db8277287d11f9dd98f77fc54155fc71a7738a83ffcc8ea8"}, + {file = "aiohttp-3.10.10-cp313-cp313-win_amd64.whl", hash = "sha256:486f7aabfa292719a2753c016cc3a8f8172965cabb3ea2e7f7436c7f5a22a151"}, + {file = "aiohttp-3.10.10-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:1b66ccafef7336a1e1f0e389901f60c1d920102315a56df85e49552308fc0486"}, + {file = "aiohttp-3.10.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:acd48d5b80ee80f9432a165c0ac8cbf9253eaddb6113269a5e18699b33958dbb"}, + {file = "aiohttp-3.10.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3455522392fb15ff549d92fbf4b73b559d5e43dc522588f7eb3e54c3f38beee7"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45c3b868724137f713a38376fef8120c166d1eadd50da1855c112fe97954aed8"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da1dee8948d2137bb51fbb8a53cce6b1bcc86003c6b42565f008438b806cccd8"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c5ce2ce7c997e1971b7184ee37deb6ea9922ef5163c6ee5aa3c274b05f9e12fa"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28529e08fde6f12eba8677f5a8608500ed33c086f974de68cc65ab218713a59d"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7db54c7914cc99d901d93a34704833568d86c20925b2762f9fa779f9cd2e70f"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03a42ac7895406220124c88911ebee31ba8b2d24c98507f4a8bf826b2937c7f2"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:7e338c0523d024fad378b376a79faff37fafb3c001872a618cde1d322400a572"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:038f514fe39e235e9fef6717fbf944057bfa24f9b3db9ee551a7ecf584b5b480"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:64f6c17757251e2b8d885d728b6433d9d970573586a78b78ba8929b0f41d045a"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:93429602396f3383a797a2a70e5f1de5df8e35535d7806c9f91df06f297e109b"}, + {file = "aiohttp-3.10.10-cp38-cp38-win32.whl", hash = "sha256:c823bc3971c44ab93e611ab1a46b1eafeae474c0c844aff4b7474287b75fe49c"}, + {file = "aiohttp-3.10.10-cp38-cp38-win_amd64.whl", hash = "sha256:54ca74df1be3c7ca1cf7f4c971c79c2daf48d9aa65dea1a662ae18926f5bc8ce"}, + {file = "aiohttp-3.10.10-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:01948b1d570f83ee7bbf5a60ea2375a89dfb09fd419170e7f5af029510033d24"}, + {file = "aiohttp-3.10.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9fc1500fd2a952c5c8e3b29aaf7e3cc6e27e9cfc0a8819b3bce48cc1b849e4cc"}, + {file = "aiohttp-3.10.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f614ab0c76397661b90b6851a030004dac502e48260ea10f2441abd2207fbcc7"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00819de9e45d42584bed046314c40ea7e9aea95411b38971082cad449392b08c"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05646ebe6b94cc93407b3bf34b9eb26c20722384d068eb7339de802154d61bc5"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:998f3bd3cfc95e9424a6acd7840cbdd39e45bc09ef87533c006f94ac47296090"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9010c31cd6fa59438da4e58a7f19e4753f7f264300cd152e7f90d4602449762"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ea7ffc6d6d6f8a11e6f40091a1040995cdff02cfc9ba4c2f30a516cb2633554"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ef9c33cc5cbca35808f6c74be11eb7f5f6b14d2311be84a15b594bd3e58b5527"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ce0cdc074d540265bfeb31336e678b4e37316849d13b308607efa527e981f5c2"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:597a079284b7ee65ee102bc3a6ea226a37d2b96d0418cc9047490f231dc09fe8"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:7789050d9e5d0c309c706953e5e8876e38662d57d45f936902e176d19f1c58ab"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e7f8b04d83483577fd9200461b057c9f14ced334dcb053090cea1da9c8321a91"}, + {file = "aiohttp-3.10.10-cp39-cp39-win32.whl", hash = "sha256:c02a30b904282777d872266b87b20ed8cc0d1501855e27f831320f471d54d983"}, + {file = "aiohttp-3.10.10-cp39-cp39-win_amd64.whl", hash = "sha256:edfe3341033a6b53a5c522c802deb2079eee5cbfbb0af032a55064bd65c73a23"}, + {file = "aiohttp-3.10.10.tar.gz", hash = "sha256:0631dd7c9f0822cc61c88586ca76d5b5ada26538097d0f1df510b082bad3411a"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.3.0" +aiosignal = ">=1.1.2" +async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.12.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +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 = "anyio" +version = "4.6.2.post1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.9" +files = [ + {file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"}, + {file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +trio = ["trio (>=0.26.1)"] + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] + +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, +] + +[[package]] +name = "codespell" +version = "2.3.0" +description = "Codespell" +optional = false +python-versions = ">=3.8" +files = [ + {file = "codespell-2.3.0-py3-none-any.whl", hash = "sha256:a9c7cef2501c9cfede2110fd6d4e5e62296920efe9abfb84648df866e47f58d1"}, + {file = "codespell-2.3.0.tar.gz", hash = "sha256:360c7d10f75e65f67bad720af7007e1060a5d395670ec11a7ed1fed9dd17471f"}, +] + +[package.extras] +dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] +hard-encoding-detection = ["chardet"] +toml = ["tomli"] +types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "dataclasses-json" +version = "0.6.7" +description = "Easily serialize dataclasses to and from JSON." +optional = false +python-versions = "<4.0,>=3.7" +files = [ + {file = "dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a"}, + {file = "dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0"}, +] + +[package.dependencies] +marshmallow = ">=3.18.0,<4.0.0" +typing-inspect = ">=0.4.0,<1" + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "frozenlist" +version = "1.5.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.8" +files = [ + {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a"}, + {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb"}, + {file = "frozenlist-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5"}, + {file = "frozenlist-1.5.0-cp310-cp310-win32.whl", hash = "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb"}, + {file = "frozenlist-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4"}, + {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30"}, + {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5"}, + {file = "frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf"}, + {file = "frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942"}, + {file = "frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d"}, + {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21"}, + {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d"}, + {file = "frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f"}, + {file = "frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8"}, + {file = "frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f"}, + {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953"}, + {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0"}, + {file = "frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03"}, + {file = "frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c"}, + {file = "frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28"}, + {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dd94994fc91a6177bfaafd7d9fd951bc8689b0a98168aa26b5f543868548d3ca"}, + {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d0da8bbec082bf6bf18345b180958775363588678f64998c2b7609e34719b10"}, + {file = "frozenlist-1.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73f2e31ea8dd7df61a359b731716018c2be196e5bb3b74ddba107f694fbd7604"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:828afae9f17e6de596825cf4228ff28fbdf6065974e5ac1410cecc22f699d2b3"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1577515d35ed5649d52ab4319db757bb881ce3b2b796d7283e6634d99ace307"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2150cc6305a2c2ab33299453e2968611dacb970d2283a14955923062c8d00b10"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a72b7a6e3cd2725eff67cd64c8f13335ee18fc3c7befc05aed043d24c7b9ccb9"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c16d2fa63e0800723139137d667e1056bee1a1cf7965153d2d104b62855e9b99"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:17dcc32fc7bda7ce5875435003220a457bcfa34ab7924a49a1c19f55b6ee185c"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:97160e245ea33d8609cd2b8fd997c850b56db147a304a262abc2b3be021a9171"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f1e6540b7fa044eee0bb5111ada694cf3dc15f2b0347ca125ee9ca984d5e9e6e"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:91d6c171862df0a6c61479d9724f22efb6109111017c87567cfeb7b5d1449fdf"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c1fac3e2ace2eb1052e9f7c7db480818371134410e1f5c55d65e8f3ac6d1407e"}, + {file = "frozenlist-1.5.0-cp38-cp38-win32.whl", hash = "sha256:b97f7b575ab4a8af9b7bc1d2ef7f29d3afee2226bd03ca3875c16451ad5a7723"}, + {file = "frozenlist-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:374ca2dabdccad8e2a76d40b1d037f5bd16824933bf7bcea3e59c891fd4a0923"}, + {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972"}, + {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336"}, + {file = "frozenlist-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c"}, + {file = "frozenlist-1.5.0-cp39-cp39-win32.whl", hash = "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3"}, + {file = "frozenlist-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0"}, + {file = "frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3"}, + {file = "frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817"}, +] + +[[package]] +name = "greenlet" +version = "3.1.1" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.7" +files = [ + {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617"}, + {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7"}, + {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6"}, + {file = "greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80"}, + {file = "greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a"}, + {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511"}, + {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395"}, + {file = "greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39"}, + {file = "greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9"}, + {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0"}, + {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942"}, + {file = "greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01"}, + {file = "greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e"}, + {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1"}, + {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c"}, + {file = "greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822"}, + {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01"}, + {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de"}, + {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa"}, + {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af"}, + {file = "greenlet-3.1.1-cp37-cp37m-win32.whl", hash = "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798"}, + {file = "greenlet-3.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef"}, + {file = "greenlet-3.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1"}, + {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd"}, + {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7"}, + {file = "greenlet-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef"}, + {file = "greenlet-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d"}, + {file = "greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c"}, + {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e"}, + {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e"}, + {file = "greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c"}, + {file = "greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22"}, + {file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.6" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f"}, + {file = "httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.27.2" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +description = "Consume Server-Sent Event (SSE) messages with HTTPX." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"}, + {file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"}, +] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +description = "Apply JSON-Patches (RFC 6902)" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" +files = [ + {file = "jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade"}, + {file = "jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c"}, +] + +[package.dependencies] +jsonpointer = ">=1.9" + +[[package]] +name = "jsonpointer" +version = "3.0.0" +description = "Identify specific nodes in a JSON document (RFC 6901)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"}, + {file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"}, +] + +[[package]] +name = "langchain" +version = "0.3.6" +description = "Building applications with LLMs through composability" +optional = false +python-versions = "<4.0,>=3.9" +files = [ + {file = "langchain-0.3.6-py3-none-any.whl", hash = "sha256:6e453f6c26dfd8f800ba5eb3ecbd21d283d5b6ccad422179b2933a962c1a6563"}, + {file = "langchain-0.3.6.tar.gz", hash = "sha256:0b0e2dc3be7b49eb3ca2aa21341bb204ed74450e34b3041345820454e21bcdc8"}, +] + +[package.dependencies] +aiohttp = ">=3.8.3,<4.0.0" +async-timeout = {version = ">=4.0.0,<5.0.0", markers = "python_version < \"3.11\""} +langchain-core = ">=0.3.14,<0.4.0" +langchain-text-splitters = ">=0.3.0,<0.4.0" +langsmith = ">=0.1.17,<0.2.0" +numpy = [ + {version = ">=1,<2", markers = "python_version < \"3.12\""}, + {version = ">=1.26.0,<2.0.0", markers = "python_version >= \"3.12\""}, +] +pydantic = ">=2.7.4,<3.0.0" +PyYAML = ">=5.3" +requests = ">=2,<3" +SQLAlchemy = ">=1.4,<3" +tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10" + +[[package]] +name = "langchain-community" +version = "0.3.4" +description = "Community contributed LangChain integrations." +optional = false +python-versions = "<4.0,>=3.9" +files = [ + {file = "langchain_community-0.3.4-py3-none-any.whl", hash = "sha256:67a44d3db8ba14a8abae67c8f611e6dc20002446439e761f673c7dffa506fb85"}, + {file = "langchain_community-0.3.4.tar.gz", hash = "sha256:80c7e6491788449b8a6e7a31444ff8ebb5c32242f67a65aa33d56ad35a7b5b5c"}, +] + +[package.dependencies] +aiohttp = ">=3.8.3,<4.0.0" +dataclasses-json = ">=0.5.7,<0.7" +httpx-sse = ">=0.4.0,<0.5.0" +langchain = ">=0.3.6,<0.4.0" +langchain-core = ">=0.3.14,<0.4.0" +langsmith = ">=0.1.125,<0.2.0" +numpy = [ + {version = ">=1,<2", markers = "python_version < \"3.12\""}, + {version = ">=1.26.0,<2.0.0", markers = "python_version >= \"3.12\""}, +] +pydantic-settings = ">=2.4.0,<3.0.0" +PyYAML = ">=5.3" +requests = ">=2,<3" +SQLAlchemy = ">=1.4,<3" +tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10" + +[[package]] +name = "langchain-core" +version = "0.3.15" +description = "Building applications with LLMs through composability" +optional = false +python-versions = ">=3.9,<4.0" +files = [] +develop = false + +[package.dependencies] +jsonpatch = "^1.33" +langsmith = "^0.1.125" +packaging = ">=23.2,<25" +pydantic = [ + {version = ">=2.5.2,<3.0.0", markers = "python_full_version < \"3.12.4\""}, + {version = ">=2.7.4,<3.0.0", markers = "python_full_version >= \"3.12.4\""}, +] +PyYAML = ">=5.3" +tenacity = ">=8.1.0,!=8.4.0,<10.0.0" +typing-extensions = ">=4.7" + +[package.source] +type = "git" +url = "https://github.com/langchain-ai/langchain.git" +reference = "HEAD" +resolved_reference = "c3c638cd7be15632804693a684887f0a9d5dc0c6" +subdirectory = "libs/core" + +[[package]] +name = "langchain-text-splitters" +version = "0.3.1" +description = "LangChain text splitting utilities" +optional = false +python-versions = "<4.0,>=3.9" +files = [ + {file = "langchain_text_splitters-0.3.1-py3-none-any.whl", hash = "sha256:842c9b342dc4b1a810f911a262f0daa772beb83d6e4f13a87ae7a3de015cb2a9"}, + {file = "langchain_text_splitters-0.3.1.tar.gz", hash = "sha256:5d2a749d0e70c7c6a3e0a0a3996648fd06cb9fe8e316c3040ca6541811d8ecaf"}, +] + +[package.dependencies] +langchain-core = ">=0.3.13,<0.4.0" + +[[package]] +name = "langsmith" +version = "0.1.139" +description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform." +optional = false +python-versions = "<4.0,>=3.8.1" +files = [ + {file = "langsmith-0.1.139-py3-none-any.whl", hash = "sha256:2a4a541bfbd0a9727255df28a60048c85bc8c4c6a276975923785c3fd82dc879"}, + {file = "langsmith-0.1.139.tar.gz", hash = "sha256:2f9e4d32fef3ad7ef42c8506448cce3a31ad6b78bb4f3310db04ddaa1e9d744d"}, +] + +[package.dependencies] +httpx = ">=0.23.0,<1" +orjson = ">=3.9.14,<4.0.0" +pydantic = [ + {version = ">=1,<3", markers = "python_full_version < \"3.12.4\""}, + {version = ">=2.7.4,<3.0.0", markers = "python_full_version >= \"3.12.4\""}, +] +requests = ">=2,<3" +requests-toolbelt = ">=1.0.0,<2.0.0" + +[[package]] +name = "marshmallow" +version = "3.23.1" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +optional = false +python-versions = ">=3.9" +files = [ + {file = "marshmallow-3.23.1-py3-none-any.whl", hash = "sha256:fece2eb2c941180ea1b7fcbd4a83c51bfdd50093fdd3ad2585ee5e1df2508491"}, + {file = "marshmallow-3.23.1.tar.gz", hash = "sha256:3a8dfda6edd8dcdbf216c0ede1d1e78d230a6dc9c5a088f58c4083b974a0d468"}, +] + +[package.dependencies] +packaging = ">=17.0" + +[package.extras] +dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] +docs = ["alabaster (==1.0.0)", "autodocsumm (==0.2.14)", "sphinx (==8.1.3)", "sphinx-issues (==5.0.0)", "sphinx-version-warning (==1.1.2)"] +tests = ["pytest", "simplejson"] + +[[package]] +name = "multidict" +version = "6.1.0" +description = "multidict implementation" +optional = false +python-versions = ">=3.8" +files = [ + {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, + {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, + {file = "multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7"}, + {file = "multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0"}, + {file = "multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753"}, + {file = "multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80"}, + {file = "multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3"}, + {file = "multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133"}, + {file = "multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6"}, + {file = "multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81"}, + {file = "multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd"}, + {file = "multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167"}, + {file = "multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43"}, + {file = "multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada"}, + {file = "multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a"}, + {file = "multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506"}, + {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + +[[package]] +name = "mypy" +version = "1.13.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, + {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, + {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, + {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, + {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, + {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, + {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, + {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, + {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, + {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, + {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, + {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, + {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, + {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, + {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, + {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, + {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, + {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, + {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, + {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, + {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "neo4j" +version = "5.26.0" +description = "Neo4j Bolt driver for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "neo4j-5.26.0-py3-none-any.whl", hash = "sha256:511a6a9468ca89b521bf686f885a2070acc462b1d09821d43710bd477acdf11e"}, + {file = "neo4j-5.26.0.tar.gz", hash = "sha256:51b25ba127b7b9fdae1ddf48ae697ddfab331e60f4b6d8488d1fc1f74ec60dcc"}, +] + +[package.dependencies] +pytz = "*" + +[package.extras] +numpy = ["numpy (>=1.7.0,<2.0.0)"] +pandas = ["numpy (>=1.7.0,<2.0.0)", "pandas (>=1.1.0,<3.0.0)"] +pyarrow = ["pyarrow (>=1.0.0)"] + +[[package]] +name = "numpy" +version = "1.26.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, + {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, + {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, + {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, + {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, + {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, + {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, + {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, + {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, + {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, +] + +[[package]] +name = "orjson" +version = "3.10.10" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +optional = false +python-versions = ">=3.8" +files = [ + {file = "orjson-3.10.10-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b788a579b113acf1c57e0a68e558be71d5d09aa67f62ca1f68e01117e550a998"}, + {file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:804b18e2b88022c8905bb79bd2cbe59c0cd014b9328f43da8d3b28441995cda4"}, + {file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9972572a1d042ec9ee421b6da69f7cc823da5962237563fa548ab17f152f0b9b"}, + {file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc6993ab1c2ae7dd0711161e303f1db69062955ac2668181bfdf2dd410e65258"}, + {file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d78e4cacced5781b01d9bc0f0cd8b70b906a0e109825cb41c1b03f9c41e4ce86"}, + {file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6eb2598df518281ba0cbc30d24c5b06124ccf7e19169e883c14e0831217a0bc"}, + {file = "orjson-3.10.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23776265c5215ec532de6238a52707048401a568f0fa0d938008e92a147fe2c7"}, + {file = "orjson-3.10.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cc2a654c08755cef90b468ff17c102e2def0edd62898b2486767204a7f5cc9c"}, + {file = "orjson-3.10.10-cp310-none-win32.whl", hash = "sha256:081b3fc6a86d72efeb67c13d0ea7c030017bd95f9868b1e329a376edc456153b"}, + {file = "orjson-3.10.10-cp310-none-win_amd64.whl", hash = "sha256:ff38c5fb749347768a603be1fb8a31856458af839f31f064c5aa74aca5be9efe"}, + {file = "orjson-3.10.10-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:879e99486c0fbb256266c7c6a67ff84f46035e4f8749ac6317cc83dacd7f993a"}, + {file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:019481fa9ea5ff13b5d5d95e6fd5ab25ded0810c80b150c2c7b1cc8660b662a7"}, + {file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0dd57eff09894938b4c86d4b871a479260f9e156fa7f12f8cad4b39ea8028bb5"}, + {file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbde6d70cd95ab4d11ea8ac5e738e30764e510fc54d777336eec09bb93b8576c"}, + {file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2625cb37b8fb42e2147404e5ff7ef08712099197a9cd38895006d7053e69d6"}, + {file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbf3c20c6a7db69df58672a0d5815647ecf78c8e62a4d9bd284e8621c1fe5ccb"}, + {file = "orjson-3.10.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:75c38f5647e02d423807d252ce4528bf6a95bd776af999cb1fb48867ed01d1f6"}, + {file = "orjson-3.10.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23458d31fa50ec18e0ec4b0b4343730928296b11111df5f547c75913714116b2"}, + {file = "orjson-3.10.10-cp311-none-win32.whl", hash = "sha256:2787cd9dedc591c989f3facd7e3e86508eafdc9536a26ec277699c0aa63c685b"}, + {file = "orjson-3.10.10-cp311-none-win_amd64.whl", hash = "sha256:6514449d2c202a75183f807bc755167713297c69f1db57a89a1ef4a0170ee269"}, + {file = "orjson-3.10.10-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8564f48f3620861f5ef1e080ce7cd122ee89d7d6dacf25fcae675ff63b4d6e05"}, + {file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5bf161a32b479034098c5b81f2608f09167ad2fa1c06abd4e527ea6bf4837a9"}, + {file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b65c93617bcafa7f04b74ae8bc2cc214bd5cb45168a953256ff83015c6747d"}, + {file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8e28406f97fc2ea0c6150f4c1b6e8261453318930b334abc419214c82314f85"}, + {file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4d0d9fe174cc7a5bdce2e6c378bcdb4c49b2bf522a8f996aa586020e1b96cee"}, + {file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3be81c42f1242cbed03cbb3973501fcaa2675a0af638f8be494eaf37143d999"}, + {file = "orjson-3.10.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65f9886d3bae65be026219c0a5f32dbbe91a9e6272f56d092ab22561ad0ea33b"}, + {file = "orjson-3.10.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:730ed5350147db7beb23ddaf072f490329e90a1d059711d364b49fe352ec987b"}, + {file = "orjson-3.10.10-cp312-none-win32.whl", hash = "sha256:a8f4bf5f1c85bea2170800020d53a8877812892697f9c2de73d576c9307a8a5f"}, + {file = "orjson-3.10.10-cp312-none-win_amd64.whl", hash = "sha256:384cd13579a1b4cd689d218e329f459eb9ddc504fa48c5a83ef4889db7fd7a4f"}, + {file = "orjson-3.10.10-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44bffae68c291f94ff5a9b4149fe9d1bdd4cd0ff0fb575bcea8351d48db629a1"}, + {file = "orjson-3.10.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e27b4c6437315df3024f0835887127dac2a0a3ff643500ec27088d2588fa5ae1"}, + {file = "orjson-3.10.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca84df16d6b49325a4084fd8b2fe2229cb415e15c46c529f868c3387bb1339d"}, + {file = "orjson-3.10.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c14ce70e8f39bd71f9f80423801b5d10bf93d1dceffdecd04df0f64d2c69bc01"}, + {file = "orjson-3.10.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:24ac62336da9bda1bd93c0491eff0613003b48d3cb5d01470842e7b52a40d5b4"}, + {file = "orjson-3.10.10-cp313-none-win32.whl", hash = "sha256:eb0a42831372ec2b05acc9ee45af77bcaccbd91257345f93780a8e654efc75db"}, + {file = "orjson-3.10.10-cp313-none-win_amd64.whl", hash = "sha256:f0c4f37f8bf3f1075c6cc8dd8a9f843689a4b618628f8812d0a71e6968b95ffd"}, + {file = "orjson-3.10.10-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:829700cc18503efc0cf502d630f612884258020d98a317679cd2054af0259568"}, + {file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0ceb5e0e8c4f010ac787d29ae6299846935044686509e2f0f06ed441c1ca949"}, + {file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c25908eb86968613216f3db4d3003f1c45d78eb9046b71056ca327ff92bdbd4"}, + {file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:218cb0bc03340144b6328a9ff78f0932e642199ac184dd74b01ad691f42f93ff"}, + {file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2277ec2cea3775640dc81ab5195bb5b2ada2fe0ea6eee4677474edc75ea6785"}, + {file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:848ea3b55ab5ccc9d7bbd420d69432628b691fba3ca8ae3148c35156cbd282aa"}, + {file = "orjson-3.10.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:e3e67b537ac0c835b25b5f7d40d83816abd2d3f4c0b0866ee981a045287a54f3"}, + {file = "orjson-3.10.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:7948cfb909353fce2135dcdbe4521a5e7e1159484e0bb024c1722f272488f2b8"}, + {file = "orjson-3.10.10-cp38-none-win32.whl", hash = "sha256:78bee66a988f1a333dc0b6257503d63553b1957889c17b2c4ed72385cd1b96ae"}, + {file = "orjson-3.10.10-cp38-none-win_amd64.whl", hash = "sha256:f1d647ca8d62afeb774340a343c7fc023efacfd3a39f70c798991063f0c681dd"}, + {file = "orjson-3.10.10-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5a059afddbaa6dd733b5a2d76a90dbc8af790b993b1b5cb97a1176ca713b5df8"}, + {file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f9b5c59f7e2a1a410f971c5ebc68f1995822837cd10905ee255f96074537ee6"}, + {file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d5ef198bafdef4aa9d49a4165ba53ffdc0a9e1c7b6f76178572ab33118afea25"}, + {file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf29ce0bb5d3320824ec3d1508652421000ba466abd63bdd52c64bcce9eb1fa"}, + {file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dddd5516bcc93e723d029c1633ae79c4417477b4f57dad9bfeeb6bc0315e654a"}, + {file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12f2003695b10817f0fa8b8fca982ed7f5761dcb0d93cff4f2f9f6709903fd7"}, + {file = "orjson-3.10.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:672f9874a8a8fb9bb1b771331d31ba27f57702c8106cdbadad8bda5d10bc1019"}, + {file = "orjson-3.10.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1dcbb0ca5fafb2b378b2c74419480ab2486326974826bbf6588f4dc62137570a"}, + {file = "orjson-3.10.10-cp39-none-win32.whl", hash = "sha256:d9bbd3a4b92256875cb058c3381b782649b9a3c68a4aa9a2fff020c2f9cfc1be"}, + {file = "orjson-3.10.10-cp39-none-win_amd64.whl", hash = "sha256:766f21487a53aee8524b97ca9582d5c6541b03ab6210fbaf10142ae2f3ced2aa"}, + {file = "orjson-3.10.10.tar.gz", hash = "sha256:37949383c4df7b4337ce82ee35b6d7471e55195efa7dcb45ab8226ceadb0fe3b"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "propcache" +version = "0.2.0" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.8" +files = [ + {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5869b8fd70b81835a6f187c5fdbe67917a04d7e52b6e7cc4e5fe39d55c39d58"}, + {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:952e0d9d07609d9c5be361f33b0d6d650cd2bae393aabb11d9b719364521984b"}, + {file = "propcache-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33ac8f098df0585c0b53009f039dfd913b38c1d2edafed0cedcc0c32a05aa110"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e48e8875e6c13909c800fa344cd54cc4b2b0db1d5f911f840458a500fde2c2"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:388f3217649d6d59292b722d940d4d2e1e6a7003259eb835724092a1cca0203a"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f571aea50ba5623c308aa146eb650eebf7dbe0fd8c5d946e28343cb3b5aad577"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dfafb44f7bb35c0c06eda6b2ab4bfd58f02729e7c4045e179f9a861b07c9850"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3ebe9a75be7ab0b7da2464a77bb27febcb4fab46a34f9288f39d74833db7f61"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2f0d0f976985f85dfb5f3d685697ef769faa6b71993b46b295cdbbd6be8cc37"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a3dc1a4b165283bd865e8f8cb5f0c64c05001e0718ed06250d8cac9bec115b48"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e0f07b42d2a50c7dd2d8675d50f7343d998c64008f1da5fef888396b7f84630"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e63e3e1e0271f374ed489ff5ee73d4b6e7c60710e1f76af5f0e1a6117cd26394"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:56bb5c98f058a41bb58eead194b4db8c05b088c93d94d5161728515bd52b052b"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7665f04d0c7f26ff8bb534e1c65068409bf4687aa2534faf7104d7182debb336"}, + {file = "propcache-0.2.0-cp310-cp310-win32.whl", hash = "sha256:7cf18abf9764746b9c8704774d8b06714bcb0a63641518a3a89c7f85cc02c2ad"}, + {file = "propcache-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:cfac69017ef97db2438efb854edf24f5a29fd09a536ff3a992b75990720cdc99"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b"}, + {file = "propcache-0.2.0-cp311-cp311-win32.whl", hash = "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1"}, + {file = "propcache-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348"}, + {file = "propcache-0.2.0-cp312-cp312-win32.whl", hash = "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5"}, + {file = "propcache-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544"}, + {file = "propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032"}, + {file = "propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:53d1bd3f979ed529f0805dd35ddaca330f80a9a6d90bc0121d2ff398f8ed8861"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:83928404adf8fb3d26793665633ea79b7361efa0287dfbd372a7e74311d51ee6"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:77a86c261679ea5f3896ec060be9dc8e365788248cc1e049632a1be682442063"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:218db2a3c297a3768c11a34812e63b3ac1c3234c3a086def9c0fee50d35add1f"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7735e82e3498c27bcb2d17cb65d62c14f1100b71723b68362872bca7d0913d90"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:20a617c776f520c3875cf4511e0d1db847a076d720714ae35ffe0df3e440be68"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67b69535c870670c9f9b14a75d28baa32221d06f6b6fa6f77a0a13c5a7b0a5b9"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4569158070180c3855e9c0791c56be3ceeb192defa2cdf6a3f39e54319e56b89"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:db47514ffdbd91ccdc7e6f8407aac4ee94cc871b15b577c1c324236b013ddd04"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:2a60ad3e2553a74168d275a0ef35e8c0a965448ffbc3b300ab3a5bb9956c2162"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:662dd62358bdeaca0aee5761de8727cfd6861432e3bb828dc2a693aa0471a563"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:25a1f88b471b3bc911d18b935ecb7115dff3a192b6fef46f0bfaf71ff4f12418"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:f60f0ac7005b9f5a6091009b09a419ace1610e163fa5deaba5ce3484341840e7"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:74acd6e291f885678631b7ebc85d2d4aec458dd849b8c841b57ef04047833bed"}, + {file = "propcache-0.2.0-cp38-cp38-win32.whl", hash = "sha256:d9b6ddac6408194e934002a69bcaadbc88c10b5f38fb9307779d1c629181815d"}, + {file = "propcache-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:676135dcf3262c9c5081cc8f19ad55c8a64e3f7282a21266d05544450bffc3a5"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:25c8d773a62ce0451b020c7b29a35cfbc05de8b291163a7a0f3b7904f27253e6"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:375a12d7556d462dc64d70475a9ee5982465fbb3d2b364f16b86ba9135793638"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ec43d76b9677637a89d6ab86e1fef70d739217fefa208c65352ecf0282be957"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45eec587dafd4b2d41ac189c2156461ebd0c1082d2fe7013571598abb8505d1"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc092ba439d91df90aea38168e11f75c655880c12782facf5cf9c00f3d42b562"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa1076244f54bb76e65e22cb6910365779d5c3d71d1f18b275f1dfc7b0d71b4d"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:682a7c79a2fbf40f5dbb1eb6bfe2cd865376deeac65acf9beb607505dced9e12"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e40876731f99b6f3c897b66b803c9e1c07a989b366c6b5b475fafd1f7ba3fb8"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:363ea8cd3c5cb6679f1c2f5f1f9669587361c062e4899fce56758efa928728f8"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:140fbf08ab3588b3468932974a9331aff43c0ab8a2ec2c608b6d7d1756dbb6cb"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e70fac33e8b4ac63dfc4c956fd7d85a0b1139adcfc0d964ce288b7c527537fea"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b33d7a286c0dc1a15f5fc864cc48ae92a846df287ceac2dd499926c3801054a6"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f6d5749fdd33d90e34c2efb174c7e236829147a2713334d708746e94c4bde40d"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22aa8f2272d81d9317ff5756bb108021a056805ce63dd3630e27d042c8092798"}, + {file = "propcache-0.2.0-cp39-cp39-win32.whl", hash = "sha256:73e4b40ea0eda421b115248d7e79b59214411109a5bc47d0d48e4c73e3b8fcf9"}, + {file = "propcache-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:9517d5e9e0731957468c29dbfd0f976736a0e55afaea843726e887f36fe017df"}, + {file = "propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036"}, + {file = "propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70"}, +] + +[[package]] +name = "pydantic" +version = "2.9.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.23.4" +typing-extensions = [ + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata"] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pydantic-settings" +version = "2.6.1" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87"}, + {file = "pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" + +[package.extras] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + +[[package]] +name = "pytest" +version = "7.4.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.8" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-socket" +version = "0.7.0" +description = "Pytest Plugin to disable socket calls during tests" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45"}, + {file = "pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[[package]] +name = "pytest-watcher" +version = "0.3.5" +description = "Automatically rerun your tests on file modifications" +optional = false +python-versions = ">=3.7.0,<4.0.0" +files = [ + {file = "pytest_watcher-0.3.5-py3-none-any.whl", hash = "sha256:af00ca52c7be22dc34c0fd3d7ffef99057207a73b05dc5161fe3b2fe91f58130"}, + {file = "pytest_watcher-0.3.5.tar.gz", hash = "sha256:8896152460ba2b1a8200c12117c6611008ec96c8b2d811f0a05ab8a82b043ff8"}, +] + +[package.dependencies] +tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""} +watchdog = ">=2.0.0" + +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pytz" +version = "2024.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, + {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] +name = "ruff" +version = "0.5.7" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, + {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, + {file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"}, + {file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"}, + {file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"}, + {file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"}, + {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.36" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "SQLAlchemy-2.0.36-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c245b1fbade9c35e5bd3b64270ab49ce990369018289ecfde3f9c318411aaa07"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f9511d8dd4a6e9271d07d150fb2f81874a3c8c95e11ff9af3a2dfc35fe42ee44"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-win32.whl", hash = "sha256:c3f3631693003d8e585d4200730616b78fafd5a01ef8b698f6967da5c605b3fa"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-win_amd64.whl", hash = "sha256:a86bfab2ef46d63300c0f06936bd6e6c0105faa11d509083ba8f2f9d237fb5b5"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2519f3a5d0517fc159afab1015e54bb81b4406c278749779be57a569d8d1bb0d"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39769a115f730d683b0eb7b694db9789267bcd027326cccc3125e862eb03bfd8"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-win32.whl", hash = "sha256:66bffbad8d6271bb1cc2f9a4ea4f86f80fe5e2e3e501a5ae2a3dc6a76e604e6f"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-win_amd64.whl", hash = "sha256:23623166bfefe1487d81b698c423f8678e80df8b54614c2bf4b4cfcd7c711959"}, + {file = "SQLAlchemy-2.0.36-py3-none-any.whl", hash = "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e"}, + {file = "sqlalchemy-2.0.36.tar.gz", hash = "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5"}, +] + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] +aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] +aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "tenacity" +version = "9.0.0" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539"}, + {file = "tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + +[[package]] +name = "tomli" +version = "2.0.2" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, + {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20240917" +description = "Typing stubs for PyYAML" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-PyYAML-6.0.12.20240917.tar.gz", hash = "sha256:d1405a86f9576682234ef83bcb4e6fff7c9305c8b1fbad5e0bcd4f7dbdc9c587"}, + {file = "types_PyYAML-6.0.12.20240917-py3-none-any.whl", hash = "sha256:392b267f1c0fe6022952462bf5d6523f31e37f6cea49b14cee7ad634b6301570"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +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"}, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +description = "Runtime inspection utilities for typing module." +optional = false +python-versions = "*" +files = [ + {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"}, + {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"}, +] + +[package.dependencies] +mypy-extensions = ">=0.3.0" +typing-extensions = ">=3.7.4" + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "watchdog" +version = "6.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.9" +files = [ + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, + {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, + {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, + {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, + {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "yarl" +version = "1.17.1" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +files = [ + {file = "yarl-1.17.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1794853124e2f663f0ea54efb0340b457f08d40a1cef78edfa086576179c91"}, + {file = "yarl-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fbea1751729afe607d84acfd01efd95e3b31db148a181a441984ce9b3d3469da"}, + {file = "yarl-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ee427208c675f1b6e344a1f89376a9613fc30b52646a04ac0c1f6587c7e46ec"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b74ff4767d3ef47ffe0cd1d89379dc4d828d4873e5528976ced3b44fe5b0a21"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62a91aefff3d11bf60e5956d340eb507a983a7ec802b19072bb989ce120cd948"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:846dd2e1243407133d3195d2d7e4ceefcaa5f5bf7278f0a9bda00967e6326b04"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e844be8d536afa129366d9af76ed7cb8dfefec99f5f1c9e4f8ae542279a6dc3"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc7c92c1baa629cb03ecb0c3d12564f172218fb1739f54bf5f3881844daadc6d"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae3476e934b9d714aa8000d2e4c01eb2590eee10b9d8cd03e7983ad65dfbfcba"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c7e177c619342e407415d4f35dec63d2d134d951e24b5166afcdfd1362828e17"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64cc6e97f14cf8a275d79c5002281f3040c12e2e4220623b5759ea7f9868d6a5"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:84c063af19ef5130084db70ada40ce63a84f6c1ef4d3dbc34e5e8c4febb20822"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:482c122b72e3c5ec98f11457aeb436ae4aecca75de19b3d1de7cf88bc40db82f"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:380e6c38ef692b8fd5a0f6d1fa8774d81ebc08cfbd624b1bca62a4d4af2f9931"}, + {file = "yarl-1.17.1-cp310-cp310-win32.whl", hash = "sha256:16bca6678a83657dd48df84b51bd56a6c6bd401853aef6d09dc2506a78484c7b"}, + {file = "yarl-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:561c87fea99545ef7d692403c110b2f99dced6dff93056d6e04384ad3bc46243"}, + {file = "yarl-1.17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cbad927ea8ed814622305d842c93412cb47bd39a496ed0f96bfd42b922b4a217"}, + {file = "yarl-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fca4b4307ebe9c3ec77a084da3a9d1999d164693d16492ca2b64594340999988"}, + {file = "yarl-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff5c6771c7e3511a06555afa317879b7db8d640137ba55d6ab0d0c50425cab75"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b29beab10211a746f9846baa39275e80034e065460d99eb51e45c9a9495bcca"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a52a1ffdd824fb1835272e125385c32fd8b17fbdefeedcb4d543cc23b332d74"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58c8e9620eb82a189c6c40cb6b59b4e35b2ee68b1f2afa6597732a2b467d7e8f"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d216e5d9b8749563c7f2c6f7a0831057ec844c68b4c11cb10fc62d4fd373c26d"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:881764d610e3269964fc4bb3c19bb6fce55422828e152b885609ec176b41cf11"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8c79e9d7e3d8a32d4824250a9c6401194fb4c2ad9a0cec8f6a96e09a582c2cc0"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:299f11b44d8d3a588234adbe01112126010bd96d9139c3ba7b3badd9829261c3"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cc7d768260f4ba4ea01741c1b5fe3d3a6c70eb91c87f4c8761bbcce5181beafe"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:de599af166970d6a61accde358ec9ded821234cbbc8c6413acfec06056b8e860"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2b24ec55fad43e476905eceaf14f41f6478780b870eda5d08b4d6de9a60b65b4"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9fb815155aac6bfa8d86184079652c9715c812d506b22cfa369196ef4e99d1b4"}, + {file = "yarl-1.17.1-cp311-cp311-win32.whl", hash = "sha256:7615058aabad54416ddac99ade09a5510cf77039a3b903e94e8922f25ed203d7"}, + {file = "yarl-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:14bc88baa44e1f84164a392827b5defb4fa8e56b93fecac3d15315e7c8e5d8b3"}, + {file = "yarl-1.17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:327828786da2006085a4d1feb2594de6f6d26f8af48b81eb1ae950c788d97f61"}, + {file = "yarl-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cc353841428d56b683a123a813e6a686e07026d6b1c5757970a877195f880c2d"}, + {file = "yarl-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73df5b6e8fabe2ddb74876fb82d9dd44cbace0ca12e8861ce9155ad3c886139"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bdff5e0995522706c53078f531fb586f56de9c4c81c243865dd5c66c132c3b5"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06157fb3c58f2736a5e47c8fcbe1afc8b5de6fb28b14d25574af9e62150fcaac"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1654ec814b18be1af2c857aa9000de7a601400bd4c9ca24629b18486c2e35463"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f6595c852ca544aaeeb32d357e62c9c780eac69dcd34e40cae7b55bc4fb1147"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:459e81c2fb920b5f5df744262d1498ec2c8081acdcfe18181da44c50f51312f7"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e48cdb8226644e2fbd0bdb0a0f87906a3db07087f4de77a1b1b1ccfd9e93685"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d9b6b28a57feb51605d6ae5e61a9044a31742db557a3b851a74c13bc61de5172"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e594b22688d5747b06e957f1ef822060cb5cb35b493066e33ceac0cf882188b7"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5f236cb5999ccd23a0ab1bd219cfe0ee3e1c1b65aaf6dd3320e972f7ec3a39da"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a2a64e62c7a0edd07c1c917b0586655f3362d2c2d37d474db1a509efb96fea1c"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d0eea830b591dbc68e030c86a9569826145df485b2b4554874b07fea1275a199"}, + {file = "yarl-1.17.1-cp312-cp312-win32.whl", hash = "sha256:46ddf6e0b975cd680eb83318aa1d321cb2bf8d288d50f1754526230fcf59ba96"}, + {file = "yarl-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:117ed8b3732528a1e41af3aa6d4e08483c2f0f2e3d3d7dca7cf538b3516d93df"}, + {file = "yarl-1.17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d1d42556b063d579cae59e37a38c61f4402b47d70c29f0ef15cee1acaa64488"}, + {file = "yarl-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0167540094838ee9093ef6cc2c69d0074bbf84a432b4995835e8e5a0d984374"}, + {file = "yarl-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f0a6423295a0d282d00e8701fe763eeefba8037e984ad5de44aa349002562ac"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b078134f48552c4d9527db2f7da0b5359abd49393cdf9794017baec7506170"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d401f07261dc5aa36c2e4efc308548f6ae943bfff20fcadb0a07517a26b196d8"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5f1ac7359e17efe0b6e5fec21de34145caef22b260e978336f325d5c84e6938"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f63d176a81555984e91f2c84c2a574a61cab7111cc907e176f0f01538e9ff6e"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e275792097c9f7e80741c36de3b61917aebecc08a67ae62899b074566ff8556"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81713b70bea5c1386dc2f32a8f0dab4148a2928c7495c808c541ee0aae614d67"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:aa46dce75078fceaf7cecac5817422febb4355fbdda440db55206e3bd288cfb8"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ce36ded585f45b1e9bb36d0ae94765c6608b43bd2e7f5f88079f7a85c61a4d3"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2d374d70fdc36f5863b84e54775452f68639bc862918602d028f89310a034ab0"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2d9f0606baaec5dd54cb99667fcf85183a7477f3766fbddbe3f385e7fc253299"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0341e6d9a0c0e3cdc65857ef518bb05b410dbd70d749a0d33ac0f39e81a4258"}, + {file = "yarl-1.17.1-cp313-cp313-win32.whl", hash = "sha256:2e7ba4c9377e48fb7b20dedbd473cbcbc13e72e1826917c185157a137dac9df2"}, + {file = "yarl-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:949681f68e0e3c25377462be4b658500e85ca24323d9619fdc41f68d46a1ffda"}, + {file = "yarl-1.17.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8994b29c462de9a8fce2d591028b986dbbe1b32f3ad600b2d3e1c482c93abad6"}, + {file = "yarl-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f9cbfbc5faca235fbdf531b93aa0f9f005ec7d267d9d738761a4d42b744ea159"}, + {file = "yarl-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b40d1bf6e6f74f7c0a567a9e5e778bbd4699d1d3d2c0fe46f4b717eef9e96b95"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5efe0661b9fcd6246f27957f6ae1c0eb29bc60552820f01e970b4996e016004"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5c4804e4039f487e942c13381e6c27b4b4e66066d94ef1fae3f6ba8b953f383"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5d6a6c9602fd4598fa07e0389e19fe199ae96449008d8304bf5d47cb745462e"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4c9156c4d1eb490fe374fb294deeb7bc7eaccda50e23775b2354b6a6739934"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6324274b4e0e2fa1b3eccb25997b1c9ed134ff61d296448ab8269f5ac068c4c"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d8a8b74d843c2638f3864a17d97a4acda58e40d3e44b6303b8cc3d3c44ae2d29"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:7fac95714b09da9278a0b52e492466f773cfe37651cf467a83a1b659be24bf71"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c180ac742a083e109c1a18151f4dd8675f32679985a1c750d2ff806796165b55"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:578d00c9b7fccfa1745a44f4eddfdc99d723d157dad26764538fbdda37209857"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1a3b91c44efa29e6c8ef8a9a2b583347998e2ba52c5d8280dbd5919c02dfc3b5"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7ac5b4984c468ce4f4a553df281450df0a34aefae02e58d77a0847be8d1e11f"}, + {file = "yarl-1.17.1-cp39-cp39-win32.whl", hash = "sha256:7294e38f9aa2e9f05f765b28ffdc5d81378508ce6dadbe93f6d464a8c9594473"}, + {file = "yarl-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:eb6dce402734575e1a8cc0bb1509afca508a400a57ce13d306ea2c663bad1138"}, + {file = "yarl-1.17.1-py3-none-any.whl", hash = "sha256:f1790a4b1e8e8e028c391175433b9c8122c39b46e1663228158e61e6f915bf06"}, + {file = "yarl-1.17.1.tar.gz", hash = "sha256:067a63fcfda82da6b198fa73079b1ca40b7c9b7994995b6ee38acda728b64d47"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.0" + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9,<4.0" +content-hash = "6a3c20fe481faa89027314a34c5a9f4d321e7ae4db9a0ab8bfa02e1bd73cbb0b" diff --git a/libs/neo4j/pyproject.toml b/libs/neo4j/pyproject.toml new file mode 100644 index 0000000..6f9eab9 --- /dev/null +++ b/libs/neo4j/pyproject.toml @@ -0,0 +1,91 @@ +[tool.poetry] +name = "langchain-neo4j" +version = "0.1.0" +description = "An integration package connecting Neo4j and LangChain" +authors = [] +readme = "README.md" +repository = "https://github.com/langchain-ai/langchain-neo4j" +license = "MIT" + +[tool.poetry.urls] +"Source Code" = "https://github.com/langchain-ai/langchain-neo4j/tree/main/libs/neo4j" +"Release Notes" = "https://github.com/langchain-ai/langchain-neo4j/releases" + +[tool.poetry.dependencies] +python = ">=3.9,<4.0" +langchain-core = "^0.3.0" +neo4j = "^5.25.0" +langchain-community = "^0.3.3" + +[tool.poetry.group.test] +optional = true + +[tool.poetry.group.test.dependencies] +pytest = "^7.4.3" +pytest-asyncio = "^0.23.2" +pytest-socket = "^0.7.0" +pytest-watcher = "^0.3.4" +langchain-core = {git = "https://github.com/langchain-ai/langchain.git", subdirectory = "libs/core"} + +[tool.poetry.group.codespell] +optional = true + +[tool.poetry.group.codespell.dependencies] +codespell = "^2.2.6" + +[tool.poetry.group.test_integration] +optional = true + +[tool.poetry.group.test_integration.dependencies] + +[tool.poetry.group.lint] +optional = true + +[tool.poetry.group.lint.dependencies] +ruff = "^0.5" + +[tool.poetry.group.typing.dependencies] +mypy = "^1.10" +types-pyyaml = "^6.0.12.20240917" +langchain-core = {git = "https://github.com/langchain-ai/langchain.git", subdirectory = "libs/core"} + +[tool.poetry.group.dev] +optional = true + +[tool.poetry.group.dev.dependencies] +langchain-core = {git = "https://github.com/langchain-ai/langchain.git", subdirectory = "libs/core"} + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "F", # pyflakes + "I", # isort + "T201", # print +] + +[tool.mypy] +disallow_untyped_defs = "True" + +[tool.coverage.run] +omit = ["tests/*"] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +# --strict-markers will raise errors on unknown marks. +# https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks +# +# https://docs.pytest.org/en/7.1.x/reference/reference.html +# --strict-config any warnings encountered while parsing the `pytest` +# section of the configuration file raise errors. +# +# https://github.com/tophat/syrupy +addopts = "--strict-markers --strict-config --durations=5" +# Registering custom markers. +# https://docs.pytest.org/en/7.1.x/example/markers.html#registering-markers +markers = [ + "compile: mark placeholder test used to compile integration tests without running them", +] +asyncio_mode = "auto" diff --git a/libs/neo4j/scripts/check_imports.py b/libs/neo4j/scripts/check_imports.py new file mode 100644 index 0000000..58a460c --- /dev/null +++ b/libs/neo4j/scripts/check_imports.py @@ -0,0 +1,17 @@ +import sys +import traceback +from importlib.machinery import SourceFileLoader + +if __name__ == "__main__": + files = sys.argv[1:] + has_failure = False + for file in files: + try: + SourceFileLoader("x", file).load_module() + except Exception: + has_failure = True + print(file) # noqa: T201 + traceback.print_exc() + print() # noqa: T201 + + sys.exit(1 if has_failure else 0) diff --git a/libs/neo4j/scripts/lint_imports.sh b/libs/neo4j/scripts/lint_imports.sh new file mode 100755 index 0000000..19ccec1 --- /dev/null +++ b/libs/neo4j/scripts/lint_imports.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -eu + +# Initialize a variable to keep track of errors +errors=0 + +# make sure not importing from langchain, langchain_experimental, or langchain_community +git --no-pager grep '^from langchain\.' . && errors=$((errors+1)) +git --no-pager grep '^from langchain_experimental\.' . && errors=$((errors+1)) +git --no-pager grep '^from langchain_community\.' . && errors=$((errors+1)) + +# Decide on an exit status based on the errors +if [ "$errors" -gt 0 ]; then + exit 1 +else + exit 0 +fi diff --git a/libs/neo4j/tests/__init__.py b/libs/neo4j/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/tests/integration_tests/__init__.py b/libs/neo4j/tests/integration_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/tests/integration_tests/chains/test_graph_database.py b/libs/neo4j/tests/integration_tests/chains/test_graph_database.py new file mode 100644 index 0000000..ecfd2e9 --- /dev/null +++ b/libs/neo4j/tests/integration_tests/chains/test_graph_database.py @@ -0,0 +1,370 @@ +"""Test Graph Database Chain.""" + +import os +from unittest.mock import MagicMock + +import pytest +from langchain.chains.loading import load_chain +from langchain_core.language_models import BaseLanguageModel +from langchain_core.outputs import Generation, LLMResult + +from langchain_neo4j.chains.graph_qa.cypher import GraphCypherQAChain +from langchain_neo4j.graphs.neo4j_graph import Neo4jGraph + + +def test_connect_neo4j() -> None: + """Test that Neo4j database is correctly instantiated and connected.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + + graph = Neo4jGraph( + url=url, + username=username, + password=password, + ) + + output = graph.query('RETURN "test" AS output') + expected_output = [{"output": "test"}] + assert output == expected_output + + +def test_connect_neo4j_env() -> None: + """Test that Neo4j database environment variables.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + os.environ["NEO4J_URI"] = url + os.environ["NEO4J_USERNAME"] = username + os.environ["NEO4J_PASSWORD"] = password + graph = Neo4jGraph() + + output = graph.query('RETURN "test" AS output') + expected_output = [{"output": "test"}] + assert output == expected_output + del os.environ["NEO4J_URI"] + del os.environ["NEO4J_USERNAME"] + del os.environ["NEO4J_PASSWORD"] + + +def test_cypher_generating_run() -> None: + """Test that Cypher statement is correctly generated and executed.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + + graph = Neo4jGraph( + url=url, + username=username, + password=password, + ) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Create two nodes and a relationship + graph.query( + "CREATE (a:Actor {name:'Bruce Willis'})" + "-[:ACTED_IN]->(:Movie {title: 'Pulp Fiction'})" + ) + # Refresh schema information + graph.refresh_schema() + + query = ( + "MATCH (a:Actor)-[:ACTED_IN]->(m:Movie) " + "WHERE m.title = 'Pulp Fiction' " + "RETURN a.name" + ) + llm = MagicMock(spec=BaseLanguageModel) + llm.generate_prompt.side_effect = [ + LLMResult(generations=[[Generation(text=query)]]), + LLMResult(generations=[[Generation(text="Bruce Willis")]]), + ] + chain = GraphCypherQAChain.from_llm( + llm=llm, + graph=graph, + allow_dangerous_requests=True, + ) + output = chain.run("Who starred in Pulp Fiction?") + expected_output = "Bruce Willis" + assert output == expected_output + + +def test_cypher_top_k() -> None: + """Test top_k parameter correctly limits the number of results in the context.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + + TOP_K = 1 + + graph = Neo4jGraph( + url=url, + username=username, + password=password, + ) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Create two nodes and a relationship + graph.query( + "CREATE (a:Actor {name:'Bruce Willis'})" + "-[:ACTED_IN]->(:Movie {title: 'Pulp Fiction'})" + "<-[:ACTED_IN]-(:Actor {name:'Foo'})" + ) + # Refresh schema information + graph.refresh_schema() + + query = ( + "MATCH (a:Actor)-[:ACTED_IN]->(m:Movie) " + "WHERE m.title = 'Pulp Fiction' " + "RETURN a.name" + ) + llm = MagicMock(spec=BaseLanguageModel) + llm.generate_prompt.side_effect = [ + LLMResult(generations=[[Generation(text=query)]]) + ] + chain = GraphCypherQAChain.from_llm( + llm=llm, + graph=graph, + return_direct=True, + top_k=TOP_K, + allow_dangerous_requests=True, + ) + output = chain.run("Who starred in Pulp Fiction?") + assert len(output) == TOP_K + + +def test_cypher_intermediate_steps() -> None: + """Test the returning of the intermediate steps.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + + graph = Neo4jGraph( + url=url, + username=username, + password=password, + ) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Create two nodes and a relationship + graph.query( + "CREATE (a:Actor {name:'Bruce Willis'})" + "-[:ACTED_IN]->(:Movie {title: 'Pulp Fiction'})" + ) + # Refresh schema information + graph.refresh_schema() + + query = ( + "MATCH (a:Actor)-[:ACTED_IN]->(m:Movie) " + "WHERE m.title = 'Pulp Fiction' " + "RETURN a.name" + ) + llm = MagicMock(spec=BaseLanguageModel) + llm.generate_prompt.side_effect = [ + LLMResult(generations=[[Generation(text=query)]]), + LLMResult(generations=[[Generation(text="Bruce Willis")]]), + ] + chain = GraphCypherQAChain.from_llm( + llm=llm, + graph=graph, + return_intermediate_steps=True, + allow_dangerous_requests=True, + ) + output = chain("Who starred in Pulp Fiction?") + + expected_output = "Bruce Willis" + assert output["result"] == expected_output + + assert output["intermediate_steps"][0]["query"] == query + + context = output["intermediate_steps"][1]["context"] + expected_context = [{"a.name": "Bruce Willis"}] + assert context == expected_context + + +def test_cypher_return_direct() -> None: + """Test that chain returns direct results.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + + graph = Neo4jGraph( + url=url, + username=username, + password=password, + ) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Create two nodes and a relationship + graph.query( + "CREATE (a:Actor {name:'Bruce Willis'})" + "-[:ACTED_IN]->(:Movie {title: 'Pulp Fiction'})" + ) + # Refresh schema information + graph.refresh_schema() + + query = ( + "MATCH (a:Actor)-[:ACTED_IN]->(m:Movie) " + "WHERE m.title = 'Pulp Fiction' " + "RETURN a.name" + ) + llm = MagicMock(spec=BaseLanguageModel) + llm.generate_prompt.side_effect = [ + LLMResult(generations=[[Generation(text=query)]]) + ] + chain = GraphCypherQAChain.from_llm( + llm=llm, + graph=graph, + return_direct=True, + allow_dangerous_requests=True, + ) + output = chain.run("Who starred in Pulp Fiction?") + expected_output = [{"a.name": "Bruce Willis"}] + assert output == expected_output + + +@pytest.mark.skip(reason="load_chain is failing and is due to be deprecated") +def test_cypher_save_load() -> None: + """Test saving and loading.""" + + FILE_PATH = "cypher.yaml" + url = os.environ.get("NEO4J_URI") + username = os.environ.get("NEO4J_USERNAME") + password = os.environ.get("NEO4J_PASSWORD") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph( + url=url, + username=username, + password=password, + ) + llm = MagicMock(spec=BaseLanguageModel) + chain = GraphCypherQAChain.from_llm( + llm=llm, + graph=graph, + return_direct=True, + allow_dangerous_requests=True, + ) + + chain.save(file_path=FILE_PATH) + qa_loaded = load_chain(FILE_PATH, graph=graph) + + assert qa_loaded == chain + + +def test_exclude_types() -> None: + """Test exclude types from schema.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + + graph = Neo4jGraph( + url=url, + username=username, + password=password, + ) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Create two nodes and a relationship + graph.query( + "CREATE (a:Actor {name:'Bruce Willis'})" + "-[:ACTED_IN]->(:Movie {title: 'Pulp Fiction'})" + "<-[:DIRECTED]-(p:Person {name:'John'})" + ) + # Refresh schema information + graph.refresh_schema() + + llm = MagicMock(spec=BaseLanguageModel) + chain = GraphCypherQAChain.from_llm( + llm=llm, + graph=graph, + exclude_types=["Person", "DIRECTED"], + allow_dangerous_requests=True, + ) + expected_schema = ( + "Node properties are the following:\n" + "Actor {name: STRING},Movie {title: STRING}\n" + "Relationship properties are the following:\n\n" + "The relationships are the following:\n" + "(:Actor)-[:ACTED_IN]->(:Movie)" + ) + assert chain.graph_schema == expected_schema + + +def test_include_types() -> None: + """Test include types from schema.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + + graph = Neo4jGraph( + url=url, + username=username, + password=password, + ) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Create two nodes and a relationship + graph.query( + "CREATE (a:Actor {name:'Bruce Willis'})" + "-[:ACTED_IN]->(:Movie {title: 'Pulp Fiction'})" + "<-[:DIRECTED]-(p:Person {name:'John'})" + ) + # Refresh schema information + graph.refresh_schema() + + llm = MagicMock(spec=BaseLanguageModel) + chain = GraphCypherQAChain.from_llm( + llm=llm, + graph=graph, + include_types=["Movie", "Actor", "ACTED_IN"], + allow_dangerous_requests=True, + ) + expected_schema = ( + "Node properties are the following:\n" + "Actor {name: STRING},Movie {title: STRING}\n" + "Relationship properties are the following:\n\n" + "The relationships are the following:\n" + "(:Actor)-[:ACTED_IN]->(:Movie)" + ) + + assert chain.graph_schema == expected_schema + + +def test_include_types2() -> None: + """Test include types from schema.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + + graph = Neo4jGraph( + url=url, + username=username, + password=password, + ) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Create two nodes and a relationship + graph.query( + "CREATE (a:Actor {name:'Bruce Willis'})" + "-[:ACTED_IN]->(:Movie {title: 'Pulp Fiction'})" + "<-[:DIRECTED]-(p:Person {name:'John'})" + ) + # Refresh schema information + graph.refresh_schema() + + llm = MagicMock(spec=BaseLanguageModel) + chain = GraphCypherQAChain.from_llm( + llm=llm, + graph=graph, + include_types=["Movie", "ACTED_IN"], + allow_dangerous_requests=True, + ) + expected_schema = ( + "Node properties are the following:\n" + "Movie {title: STRING}\n" + "Relationship properties are the following:\n\n" + "The relationships are the following:\n" + ) + assert chain.graph_schema == expected_schema diff --git a/libs/neo4j/tests/integration_tests/chat_message_histories/__init__.py b/libs/neo4j/tests/integration_tests/chat_message_histories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/tests/integration_tests/chat_message_histories/test_neo4j.py b/libs/neo4j/tests/integration_tests/chat_message_histories/test_neo4j.py new file mode 100644 index 0000000..ebc7f53 --- /dev/null +++ b/libs/neo4j/tests/integration_tests/chat_message_histories/test_neo4j.py @@ -0,0 +1,84 @@ +import os + +from langchain_core.messages import AIMessage, HumanMessage + +from langchain_neo4j.chat_message_histories.neo4j import Neo4jChatMessageHistory +from langchain_neo4j.graphs.neo4j_graph import Neo4jGraph + +url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") +username = os.environ.get("NEO4J_USERNAME", "neo4j") +password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + + +def test_add_messages() -> None: + """Basic testing: adding messages to the Neo4jChatMessageHistory.""" + os.environ["NEO4J_URI"] = url + os.environ["NEO4J_USERNAME"] = username + os.environ["NEO4J_PASSWORD"] = password + assert os.environ.get("NEO4J_URI") is not None + assert os.environ.get("NEO4J_USERNAME") is not None + assert os.environ.get("NEO4J_PASSWORD") is not None + message_store = Neo4jChatMessageHistory("23334") + message_store.clear() + assert len(message_store.messages) == 0 + message_store.add_user_message("Hello! Language Chain!") + message_store.add_ai_message("Hi Guys!") + + # create another message store to check if the messages are stored correctly + message_store_another = Neo4jChatMessageHistory("46666") + message_store_another.clear() + assert len(message_store_another.messages) == 0 + message_store_another.add_user_message("Hello! Bot!") + message_store_another.add_ai_message("Hi there!") + message_store_another.add_user_message("How's this pr going?") + + # Now check if the messages are stored in the database correctly + assert len(message_store.messages) == 2 + assert isinstance(message_store.messages[0], HumanMessage) + assert isinstance(message_store.messages[1], AIMessage) + assert message_store.messages[0].content == "Hello! Language Chain!" + assert message_store.messages[1].content == "Hi Guys!" + + assert len(message_store_another.messages) == 3 + assert isinstance(message_store_another.messages[0], HumanMessage) + assert isinstance(message_store_another.messages[1], AIMessage) + assert isinstance(message_store_another.messages[2], HumanMessage) + assert message_store_another.messages[0].content == "Hello! Bot!" + assert message_store_another.messages[1].content == "Hi there!" + assert message_store_another.messages[2].content == "How's this pr going?" + + # Now clear the first history + message_store.clear() + assert len(message_store.messages) == 0 + assert len(message_store_another.messages) == 3 + message_store_another.clear() + assert len(message_store.messages) == 0 + assert len(message_store_another.messages) == 0 + + del os.environ["NEO4J_URI"] + del os.environ["NEO4J_USERNAME"] + del os.environ["NEO4J_PASSWORD"] + + +def test_add_messages_graph_object() -> None: + """Basic testing: Passing driver through graph object.""" + os.environ["NEO4J_URI"] = url + os.environ["NEO4J_USERNAME"] = username + os.environ["NEO4J_PASSWORD"] = password + assert os.environ.get("NEO4J_URI") is not None + assert os.environ.get("NEO4J_USERNAME") is not None + assert os.environ.get("NEO4J_PASSWORD") is not None + graph = Neo4jGraph() + # rewrite env for testing + os.environ["NEO4J_USERNAME"] = "foo" + message_store = Neo4jChatMessageHistory("23334", graph=graph) + message_store.clear() + assert len(message_store.messages) == 0 + message_store.add_user_message("Hello! Language Chain!") + message_store.add_ai_message("Hi Guys!") + # Now check if the messages are stored in the database correctly + assert len(message_store.messages) == 2 + + del os.environ["NEO4J_URI"] + del os.environ["NEO4J_USERNAME"] + del os.environ["NEO4J_PASSWORD"] diff --git a/libs/neo4j/tests/integration_tests/docker-compose/neo4j.yml b/libs/neo4j/tests/integration_tests/docker-compose/neo4j.yml new file mode 100644 index 0000000..eda9f8b --- /dev/null +++ b/libs/neo4j/tests/integration_tests/docker-compose/neo4j.yml @@ -0,0 +1,14 @@ +version: "3.8" +services: + neo4j: + image: neo4j:5.24.2 + restart: on-failure:0 + hostname: neo4j-test + container_name: neo4j-test + ports: + - 7474:7474 + - 7687:7687 + environment: + NEO4J_AUTH: neo4j/pleaseletmein + NEO4J_PLUGINS: "[\"apoc\"]" + diff --git a/libs/neo4j/tests/integration_tests/graphs/__init__.py b/libs/neo4j/tests/integration_tests/graphs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/tests/integration_tests/graphs/test_neo4j.py b/libs/neo4j/tests/integration_tests/graphs/test_neo4j.py new file mode 100644 index 0000000..a02cffa --- /dev/null +++ b/libs/neo4j/tests/integration_tests/graphs/test_neo4j.py @@ -0,0 +1,400 @@ +import os + +from langchain_community.graphs.graph_document import GraphDocument, Node, Relationship +from langchain_core.documents import Document + +from langchain_neo4j import Neo4jGraph +from langchain_neo4j.graphs.neo4j_graph import ( + BASE_ENTITY_LABEL, + node_properties_query, + rel_properties_query, + rel_query, +) + +test_data = [ + GraphDocument( + nodes=[Node(id="foo", type="foo"), Node(id="bar", type="bar")], + relationships=[ + Relationship( + source=Node(id="foo", type="foo"), + target=Node(id="bar", type="bar"), + type="REL", + ) + ], + source=Document(page_content="source document"), + ) +] + +test_data_backticks = [ + GraphDocument( + nodes=[Node(id="foo", type="foo`"), Node(id="bar", type="`bar")], + relationships=[ + Relationship( + source=Node(id="foo", type="f`oo"), + target=Node(id="bar", type="ba`r"), + type="`REL`", + ) + ], + source=Document(page_content="source document"), + ) +] + + +def test_cypher_return_correct_schema() -> None: + """Test that chain returns direct results.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph( + url=url, + username=username, + password=password, + ) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Create two nodes and a relationship + graph.query( + """ + CREATE (la:LabelA {property_a: 'a'}) + CREATE (lb:LabelB) + CREATE (lc:LabelC) + MERGE (la)-[:REL_TYPE]-> (lb) + MERGE (la)-[:REL_TYPE {rel_prop: 'abc'}]-> (lc) + """ + ) + # Refresh schema information + graph.refresh_schema() + + node_properties = graph.query( + node_properties_query, params={"EXCLUDED_LABELS": [BASE_ENTITY_LABEL]} + ) + relationships_properties = graph.query( + rel_properties_query, params={"EXCLUDED_LABELS": [BASE_ENTITY_LABEL]} + ) + relationships = graph.query( + rel_query, params={"EXCLUDED_LABELS": [BASE_ENTITY_LABEL]} + ) + + expected_node_properties = [ + { + "output": { + "properties": [{"property": "property_a", "type": "STRING"}], + "labels": "LabelA", + } + } + ] + expected_relationships_properties = [ + { + "output": { + "type": "REL_TYPE", + "properties": [{"property": "rel_prop", "type": "STRING"}], + } + } + ] + expected_relationships = [ + {"output": {"start": "LabelA", "type": "REL_TYPE", "end": "LabelB"}}, + {"output": {"start": "LabelA", "type": "REL_TYPE", "end": "LabelC"}}, + ] + + assert node_properties == expected_node_properties + assert relationships_properties == expected_relationships_properties + # Order is not guaranteed with Neo4j returns + assert ( + sorted(relationships, key=lambda x: x["output"]["end"]) + == expected_relationships + ) + + +def test_neo4j_timeout() -> None: + """Test that neo4j uses the timeout correctly.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph(url=url, username=username, password=password, timeout=0.1) + try: + graph.query("UNWIND range(0,100000,1) AS i MERGE (:Foo {id:i})") + except Exception as e: + assert ( + e.code # type: ignore[attr-defined] + == "Neo.ClientError.Transaction.TransactionTimedOutClientConfiguration" + ) + + +def test_neo4j_sanitize_values() -> None: + """Test that neo4j uses the timeout correctly.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph(url=url, username=username, password=password, sanitize=True) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Create two nodes and a relationship + graph.query( + """ + CREATE (la:LabelA {property_a: 'a'}) + CREATE (lb:LabelB) + CREATE (lc:LabelC) + MERGE (la)-[:REL_TYPE]-> (lb) + MERGE (la)-[:REL_TYPE {rel_prop: 'abc'}]-> (lc) + """ + ) + graph.refresh_schema() + + output = graph.query("RETURN range(0,130,1) AS result") + assert output == [{}] + + +def test_neo4j_add_data() -> None: + """Test that neo4j correctly import graph document.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph(url=url, username=username, password=password, sanitize=True) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Remove all constraints + graph.query("CALL apoc.schema.assert({}, {})") + graph.refresh_schema() + # Create two nodes and a relationship + graph.add_graph_documents(test_data) + output = graph.query( + "MATCH (n) RETURN labels(n) AS label, count(*) AS count ORDER BY label" + ) + assert output == [{"label": ["bar"], "count": 1}, {"label": ["foo"], "count": 1}] + assert graph.structured_schema["metadata"]["constraint"] == [] + + +def test_neo4j_add_data_source() -> None: + """Test that neo4j correctly import graph document with source.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph(url=url, username=username, password=password, sanitize=True) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Remove all constraints + graph.query("CALL apoc.schema.assert({}, {})") + graph.refresh_schema() + # Create two nodes and a relationship + graph.add_graph_documents(test_data, include_source=True) + output = graph.query( + "MATCH (n) RETURN labels(n) AS label, count(*) AS count ORDER BY label" + ) + assert output == [ + {"label": ["Document"], "count": 1}, + {"label": ["bar"], "count": 1}, + {"label": ["foo"], "count": 1}, + ] + assert graph.structured_schema["metadata"]["constraint"] == [] + + +def test_neo4j_add_data_base() -> None: + """Test that neo4j correctly import graph document with base_entity.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph(url=url, username=username, password=password, sanitize=True) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Remove all constraints + graph.query("CALL apoc.schema.assert({}, {})") + graph.refresh_schema() + # Create two nodes and a relationship + graph.add_graph_documents(test_data, baseEntityLabel=True) + output = graph.query( + "MATCH (n) RETURN apoc.coll.sort(labels(n)) AS label, " + "count(*) AS count ORDER BY label" + ) + assert output == [ + {"label": [BASE_ENTITY_LABEL, "bar"], "count": 1}, + {"label": [BASE_ENTITY_LABEL, "foo"], "count": 1}, + ] + assert graph.structured_schema["metadata"]["constraint"] != [] + + +def test_neo4j_add_data_base_source() -> None: + """Test that neo4j correctly import graph document with base_entity and source.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph(url=url, username=username, password=password, sanitize=True) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Remove all constraints + graph.query("CALL apoc.schema.assert({}, {})") + graph.refresh_schema() + # Create two nodes and a relationship + graph.add_graph_documents(test_data, baseEntityLabel=True, include_source=True) + output = graph.query( + "MATCH (n) RETURN apoc.coll.sort(labels(n)) AS label, " + "count(*) AS count ORDER BY label" + ) + assert output == [ + {"label": ["Document"], "count": 1}, + {"label": [BASE_ENTITY_LABEL, "bar"], "count": 1}, + {"label": [BASE_ENTITY_LABEL, "foo"], "count": 1}, + ] + assert graph.structured_schema["metadata"]["constraint"] != [] + + +def test_neo4j_filtering_labels() -> None: + """Test that neo4j correctly filters excluded labels.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph(url=url, username=username, password=password, sanitize=True) + # Delete all nodes in the graph + graph.query("MATCH (n) DETACH DELETE n") + # Remove all constraints + graph.query("CALL apoc.schema.assert({}, {})") + graph.query( + """ + CREATE (:_Bloom_Scene_ {property_a: 'a'}) + -[:_Bloom_HAS_SCENE_ {property_b: 'b'}] + ->(:_Bloom_Perspective_) + """ + ) + graph.refresh_schema() + + # Assert all are empty + assert graph.structured_schema["node_props"] == {} + assert graph.structured_schema["rel_props"] == {} + assert graph.structured_schema["relationships"] == [] + + +def test_driver_config() -> None: + """Test that neo4j works with driver config.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph( + url=url, + username=username, + password=password, + driver_config={"max_connection_pool_size": 1}, + ) + graph.query("RETURN 'foo'") + + +def test_enhanced_schema() -> None: + """Test that neo4j works with driver config.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph( + url=url, username=username, password=password, enhanced_schema=True + ) + graph.query("MATCH (n) DETACH DELETE n") + graph.add_graph_documents(test_data) + graph.refresh_schema() + expected_output = { + "node_props": { + "foo": [ + { + "property": "id", + "type": "STRING", + "values": ["foo"], + "distinct_count": 1, + } + ], + "bar": [ + { + "property": "id", + "type": "STRING", + "values": ["bar"], + "distinct_count": 1, + } + ], + }, + "rel_props": {}, + "relationships": [{"start": "foo", "type": "REL", "end": "bar"}], + } + # remove metadata portion of schema + del graph.structured_schema["metadata"] + assert graph.structured_schema == expected_output + + +def test_enhanced_schema_exception() -> None: + """Test no error with weird schema.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph( + url=url, username=username, password=password, enhanced_schema=True + ) + graph.query("MATCH (n) DETACH DELETE n") + graph.query("CREATE (:Node {foo:'bar'})," "(:Node {foo: 1}), (:Node {foo: [1,2]})") + graph.refresh_schema() + expected_output = { + "node_props": {"Node": [{"property": "foo", "type": "STRING"}]}, + "rel_props": {}, + "relationships": [], + } + # remove metadata portion of schema + del graph.structured_schema["metadata"] + assert graph.structured_schema == expected_output + + +def test_backticks() -> None: + """Test that backticks are correctly removed.""" + url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") + username = os.environ.get("NEO4J_USERNAME", "neo4j") + password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + assert url is not None + assert username is not None + assert password is not None + + graph = Neo4jGraph(url=url, username=username, password=password) + graph.query("MATCH (n) DETACH DELETE n") + graph.add_graph_documents(test_data_backticks) + nodes = graph.query("MATCH (n) RETURN labels(n) AS labels ORDER BY n.id") + rels = graph.query("MATCH ()-[r]->() RETURN type(r) AS type") + expected_nodes = [{"labels": ["bar"]}, {"labels": ["foo"]}] + expected_rels = [{"type": "REL"}] + + assert nodes == expected_nodes + assert rels == expected_rels diff --git a/libs/neo4j/tests/integration_tests/test_compile.py b/libs/neo4j/tests/integration_tests/test_compile.py new file mode 100644 index 0000000..33ecccd --- /dev/null +++ b/libs/neo4j/tests/integration_tests/test_compile.py @@ -0,0 +1,7 @@ +import pytest + + +@pytest.mark.compile +def test_placeholder() -> None: + """Used for compiling integration tests without running any real tests.""" + pass diff --git a/libs/neo4j/tests/integration_tests/vectorstores/__init__.py b/libs/neo4j/tests/integration_tests/vectorstores/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/tests/integration_tests/vectorstores/fake_embeddings.py b/libs/neo4j/tests/integration_tests/vectorstores/fake_embeddings.py new file mode 100644 index 0000000..63394e7 --- /dev/null +++ b/libs/neo4j/tests/integration_tests/vectorstores/fake_embeddings.py @@ -0,0 +1,82 @@ +"""Fake Embedding class for testing purposes.""" + +import math +from typing import List + +from langchain_core.embeddings import Embeddings + +fake_texts = ["foo", "bar", "baz"] + + +class FakeEmbeddings(Embeddings): + """Fake embeddings functionality for testing.""" + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + """Return simple embeddings. + Embeddings encode each text as its index.""" + return [[float(1.0)] * 9 + [float(i)] for i in range(len(texts))] + + async def aembed_documents(self, texts: List[str]) -> List[List[float]]: + return self.embed_documents(texts) + + def embed_query(self, text: str) -> List[float]: + """Return constant query embeddings. + Embeddings are identical to embed_documents(texts)[0]. + Distance to each text will be that text's index, + as it was passed to embed_documents.""" + return [float(1.0)] * 9 + [float(0.0)] + + async def aembed_query(self, text: str) -> List[float]: + return self.embed_query(text) + + +class ConsistentFakeEmbeddings(FakeEmbeddings): + """Fake embeddings which remember all the texts seen so far to return consistent + vectors for the same texts.""" + + def __init__(self, dimensionality: int = 10) -> None: + self.known_texts: List[str] = [] + self.dimensionality = dimensionality + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + """Return consistent embeddings for each text seen so far.""" + out_vectors = [] + for text in texts: + if text not in self.known_texts: + self.known_texts.append(text) + vector = [float(1.0)] * (self.dimensionality - 1) + [ + float(self.known_texts.index(text)) + ] + out_vectors.append(vector) + return out_vectors + + def embed_query(self, text: str) -> List[float]: + """Return consistent embeddings for the text, if seen before, or a constant + one if the text is unknown.""" + return self.embed_documents([text])[0] + + +class AngularTwoDimensionalEmbeddings(Embeddings): + """ + From angles (as strings in units of pi) to unit embedding vectors on a circle. + """ + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + """ + Make a list of texts into a list of embedding vectors. + """ + return [self.embed_query(text) for text in texts] + + def embed_query(self, text: str) -> List[float]: + """ + Convert input text to a 'vector' (list of floats). + If the text is a number, use it as the angle for the + unit vector in units of pi. + Any other input text becomes the singular result [0, 0] ! + """ + try: + angle = float(text) + return [math.cos(angle * math.pi), math.sin(angle * math.pi)] + except ValueError: + # Assume: just test string, no attention is paid to values. + return [0.0, 0.0] diff --git a/libs/neo4j/tests/integration_tests/vectorstores/fixtures/__init__.py b/libs/neo4j/tests/integration_tests/vectorstores/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/tests/integration_tests/vectorstores/fixtures/filtering_test_cases.py b/libs/neo4j/tests/integration_tests/vectorstores/fixtures/filtering_test_cases.py new file mode 100644 index 0000000..e3d3d0f --- /dev/null +++ b/libs/neo4j/tests/integration_tests/vectorstores/fixtures/filtering_test_cases.py @@ -0,0 +1,219 @@ +"""Module contains test cases for testing filtering of documents in vector stores.""" + +from langchain_core.documents import Document + +metadatas = [ + { + "name": "adam", + "date": "2021-01-01", + "count": 1, + "is_active": True, + "tags": ["a", "b"], + "location": [1.0, 2.0], + "id": 1, + "height": 10.0, # Float column + "happiness": 0.9, # Float column + "sadness": 0.1, # Float column + }, + { + "name": "bob", + "date": "2021-01-02", + "count": 2, + "is_active": False, + "tags": ["b", "c"], + "location": [2.0, 3.0], + "id": 2, + "height": 5.7, # Float column + "happiness": 0.8, # Float column + "sadness": 0.1, # Float column + }, + { + "name": "jane", + "date": "2021-01-01", + "count": 3, + "is_active": True, + "tags": ["b", "d"], + "location": [3.0, 4.0], + "id": 3, + "height": 2.4, # Float column + "happiness": None, + # Sadness missing intentionally + }, +] +texts = ["id {id}".format(id=metadata["id"]) for metadata in metadatas] + +DOCUMENTS = [ + Document(page_content=text, metadata=metadata) + for text, metadata in zip(texts, metadatas) +] + + +TYPE_1_FILTERING_TEST_CASES = [ + # These tests only involve equality checks + ( + {"id": 1}, + [1], + ), + # String field + ( + # check name + {"name": "adam"}, + [1], + ), + # Boolean fields + ( + {"is_active": True}, + [1, 3], + ), + ( + {"is_active": False}, + [2], + ), + # And semantics for top level filtering + ( + {"id": 1, "is_active": True}, + [1], + ), + ( + {"id": 1, "is_active": False}, + [], + ), +] + +TYPE_2_FILTERING_TEST_CASES = [ + # These involve equality checks and other operators + # like $ne, $gt, $gte, $lt, $lte, $not + ( + {"id": 1}, + [1], + ), + ( + {"id": {"$ne": 1}}, + [2, 3], + ), + ( + {"id": {"$gt": 1}}, + [2, 3], + ), + ( + {"id": {"$gte": 1}}, + [1, 2, 3], + ), + ( + {"id": {"$lt": 1}}, + [], + ), + ( + {"id": {"$lte": 1}}, + [1], + ), + # Repeat all the same tests with name (string column) + ( + {"name": "adam"}, + [1], + ), + ( + {"name": "bob"}, + [2], + ), + ( + {"name": {"$eq": "adam"}}, + [1], + ), + ( + {"name": {"$ne": "adam"}}, + [2, 3], + ), + # And also gt, gte, lt, lte relying on lexicographical ordering + ( + {"name": {"$gt": "jane"}}, + [], + ), + ( + {"name": {"$gte": "jane"}}, + [3], + ), + ( + {"name": {"$lt": "jane"}}, + [1, 2], + ), + ( + {"name": {"$lte": "jane"}}, + [1, 2, 3], + ), + ( + {"is_active": {"$eq": True}}, + [1, 3], + ), + ( + {"is_active": {"$ne": True}}, + [2], + ), + # Test float column. + ( + {"height": {"$gt": 5.0}}, + [1, 2], + ), + ( + {"height": {"$gte": 5.0}}, + [1, 2], + ), + ( + {"height": {"$lt": 5.0}}, + [3], + ), + ( + {"height": {"$lte": 5.8}}, + [2, 3], + ), +] + +TYPE_3_FILTERING_TEST_CASES = [ + # These involve usage of AND and OR operators + ( + {"$or": [{"id": 1}, {"id": 2}]}, + [1, 2], + ), + ( + {"$or": [{"id": 1}, {"name": "bob"}]}, + [1, 2], + ), + ( + {"$and": [{"id": 1}, {"id": 2}]}, + [], + ), + ( + {"$or": [{"id": 1}, {"id": 2}, {"id": 3}]}, + [1, 2, 3], + ), +] + +TYPE_4_FILTERING_TEST_CASES = [ + # These involve special operators like $in, $nin, $between + # Test between + ( + {"id": {"$between": (1, 2)}}, + [1, 2], + ), + ( + {"id": {"$between": (1, 1)}}, + [1], + ), + ( + {"name": {"$in": ["adam", "bob"]}}, + [1, 2], + ), +] + +TYPE_5_FILTERING_TEST_CASES = [ + # These involve special operators like $like, $ilike that + # may be specified to certain databases. + ( + {"name": {"$like": "a%"}}, + [1], + ), + ( + {"name": {"$like": "%a%"}}, # adam and jane + [1, 3], + ), +] diff --git a/libs/neo4j/tests/integration_tests/vectorstores/test_neo4jvector.py b/libs/neo4j/tests/integration_tests/vectorstores/test_neo4jvector.py new file mode 100644 index 0000000..f9c92c2 --- /dev/null +++ b/libs/neo4j/tests/integration_tests/vectorstores/test_neo4jvector.py @@ -0,0 +1,1014 @@ +"""Test Neo4jVector functionality.""" + +import os +from math import isclose +from typing import Any, Dict, List, cast + +from langchain_community.vectorstores.utils import DistanceStrategy +from langchain_core.documents import Document +from yaml import safe_load + +from langchain_neo4j import Neo4jGraph +from langchain_neo4j.vectorstores.neo4j_vector import ( + Neo4jVector, + SearchType, + _get_search_index_query, +) +from tests.integration_tests.vectorstores.fake_embeddings import ( + AngularTwoDimensionalEmbeddings, + FakeEmbeddings, +) +from tests.integration_tests.vectorstores.fixtures.filtering_test_cases import ( + DOCUMENTS, + TYPE_1_FILTERING_TEST_CASES, + TYPE_2_FILTERING_TEST_CASES, + TYPE_3_FILTERING_TEST_CASES, + TYPE_4_FILTERING_TEST_CASES, +) + +url = os.environ.get("NEO4J_URI", "bolt://localhost:7687") +username = os.environ.get("NEO4J_USERNAME", "neo4j") +password = os.environ.get("NEO4J_PASSWORD", "pleaseletmein") + +OS_TOKEN_COUNT = 1536 + +texts = ["foo", "bar", "baz", "It is the end of the world. Take shelter!"] + +""" +cd tests/integration_tests/vectorstores/docker-compose +docker-compose -f neo4j.yml up +""" + + +def drop_vector_indexes(store: Neo4jVector) -> None: + """Cleanup all vector indexes""" + all_indexes = store.query( + """ + SHOW INDEXES YIELD name, type + WHERE type IN ["VECTOR", "FULLTEXT"] + RETURN name + """ + ) + for index in all_indexes: + store.query(f"DROP INDEX `{index['name']}`") + + store.query("MATCH (n) DETACH DELETE n;") + + +class FakeEmbeddingsWithOsDimension(FakeEmbeddings): + """Fake embeddings functionality for testing.""" + + def embed_documents(self, embedding_texts: List[str]) -> List[List[float]]: + """Return simple embeddings.""" + return [ + [float(1.0)] * (OS_TOKEN_COUNT - 1) + [float(i + 1)] + for i in range(len(embedding_texts)) + ] + + def embed_query(self, text: str) -> List[float]: + """Return simple embeddings.""" + return [float(1.0)] * (OS_TOKEN_COUNT - 1) + [float(texts.index(text) + 1)] + + +def test_neo4jvector() -> None: + """Test end to end construction and search.""" + docsearch = Neo4jVector.from_texts( + texts=texts, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + output = docsearch.similarity_search("foo", k=1) + assert output == [Document(page_content="foo")] + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_euclidean() -> None: + """Test euclidean distance""" + docsearch = Neo4jVector.from_texts( + texts=texts, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + distance_strategy=DistanceStrategy.EUCLIDEAN_DISTANCE, + ) + output = docsearch.similarity_search("foo", k=1) + assert output == [Document(page_content="foo")] + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_embeddings() -> None: + """Test end to end construction with embeddings and search.""" + text_embeddings = FakeEmbeddingsWithOsDimension().embed_documents(texts) + text_embedding_pairs = list(zip(texts, text_embeddings)) + docsearch = Neo4jVector.from_embeddings( + text_embeddings=text_embedding_pairs, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + output = docsearch.similarity_search("foo", k=1) + assert output == [Document(page_content="foo")] + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_catch_wrong_index_name() -> None: + """Test if index name is misspelled, but node label and property are correct.""" + text_embeddings = FakeEmbeddingsWithOsDimension().embed_documents(texts) + text_embedding_pairs = list(zip(texts, text_embeddings)) + Neo4jVector.from_embeddings( + text_embeddings=text_embedding_pairs, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + existing = Neo4jVector.from_existing_index( + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="test", + ) + output = existing.similarity_search("foo", k=1) + assert output == [Document(page_content="foo")] + + drop_vector_indexes(existing) + + +def test_neo4jvector_catch_wrong_node_label() -> None: + """Test if node label is misspelled, but index name is correct.""" + text_embeddings = FakeEmbeddingsWithOsDimension().embed_documents(texts) + text_embedding_pairs = list(zip(texts, text_embeddings)) + Neo4jVector.from_embeddings( + text_embeddings=text_embedding_pairs, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + existing = Neo4jVector.from_existing_index( + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="vector", + node_label="test", + ) + output = existing.similarity_search("foo", k=1) + assert output == [Document(page_content="foo")] + + drop_vector_indexes(existing) + + +def test_neo4jvector_with_metadatas() -> None: + """Test end to end construction and search.""" + metadatas = [{"page": str(i)} for i in range(len(texts))] + docsearch = Neo4jVector.from_texts( + texts=texts, + embedding=FakeEmbeddingsWithOsDimension(), + metadatas=metadatas, + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + output = docsearch.similarity_search("foo", k=1) + assert output == [Document(page_content="foo", metadata={"page": "0"})] + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_with_metadatas_with_scores() -> None: + """Test end to end construction and search.""" + metadatas = [{"page": str(i)} for i in range(len(texts))] + docsearch = Neo4jVector.from_texts( + texts=texts, + embedding=FakeEmbeddingsWithOsDimension(), + metadatas=metadatas, + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + output = [ + (doc, round(score, 1)) + for doc, score in docsearch.similarity_search_with_score("foo", k=1) + ] + assert output == [(Document(page_content="foo", metadata={"page": "0"}), 1.0)] + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_relevance_score() -> None: + """Test to make sure the relevance score is scaled to 0-1.""" + metadatas = [{"page": str(i)} for i in range(len(texts))] + docsearch = Neo4jVector.from_texts( + texts=texts, + embedding=FakeEmbeddingsWithOsDimension(), + metadatas=metadatas, + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + + output = docsearch.similarity_search_with_relevance_scores("foo", k=3) + expected_output = [ + (Document(page_content="foo", metadata={"page": "0"}), 1.0), + (Document(page_content="bar", metadata={"page": "1"}), 0.9998160600662231), + (Document(page_content="baz", metadata={"page": "2"}), 0.9996607303619385), + ] + + # Check if the length of the outputs matches + assert len(output) == len(expected_output) + + # Check if each document and its relevance score is close to the expected value + for (doc, score), (expected_doc, expected_score) in zip(output, expected_output): + assert doc.page_content == expected_doc.page_content + assert doc.metadata == expected_doc.metadata + assert isclose(score, expected_score, rel_tol=1e-5) + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_retriever_search_threshold() -> None: + """Test using retriever for searching with threshold.""" + metadatas = [{"page": str(i)} for i in range(len(texts))] + docsearch = Neo4jVector.from_texts( + texts=texts, + embedding=FakeEmbeddingsWithOsDimension(), + metadatas=metadatas, + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + + retriever = docsearch.as_retriever( + search_type="similarity_score_threshold", + search_kwargs={"k": 3, "score_threshold": 0.9999}, + ) + output = retriever.invoke("foo") + assert output == [ + Document(page_content="foo", metadata={"page": "0"}), + ] + + drop_vector_indexes(docsearch) + + +def test_custom_return_neo4jvector() -> None: + """Test end to end construction and search.""" + docsearch = Neo4jVector.from_texts( + texts=["test"], + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + retrieval_query="RETURN 'foo' AS text, score, {test: 'test'} AS metadata", + ) + output = docsearch.similarity_search("foo", k=1) + assert output == [Document(page_content="foo", metadata={"test": "test"})] + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_prefer_indexname() -> None: + """Test using when two indexes are found, prefer by index_name.""" + Neo4jVector.from_texts( + texts=["foo"], + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + + Neo4jVector.from_texts( + texts=["bar"], + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="foo", + node_label="Test", + embedding_node_property="vector", + text_node_property="info", + pre_delete_collection=True, + ) + + existing_index = Neo4jVector.from_existing_index( + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="foo", + text_node_property="info", + ) + + output = existing_index.similarity_search("bar", k=1) + assert output == [Document(page_content="bar", metadata={})] + drop_vector_indexes(existing_index) + + +def test_neo4jvector_prefer_indexname_insert() -> None: + """Test using when two indexes are found, prefer by index_name.""" + Neo4jVector.from_texts( + texts=["baz"], + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + + Neo4jVector.from_texts( + texts=["foo"], + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="foo", + node_label="Test", + embedding_node_property="vector", + text_node_property="info", + pre_delete_collection=True, + ) + + existing_index = Neo4jVector.from_existing_index( + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="foo", + text_node_property="info", + ) + + existing_index.add_documents([Document(page_content="bar", metadata={})]) + + output = existing_index.similarity_search("bar", k=2) + assert output == [ + Document(page_content="bar", metadata={}), + Document(page_content="foo", metadata={}), + ] + drop_vector_indexes(existing_index) + + +def test_neo4jvector_hybrid() -> None: + """Test end to end construction with hybrid search.""" + text_embeddings = FakeEmbeddingsWithOsDimension().embed_documents(texts) + text_embedding_pairs = list(zip(texts, text_embeddings)) + docsearch = Neo4jVector.from_embeddings( + text_embeddings=text_embedding_pairs, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + search_type=SearchType.HYBRID, + ) + output = docsearch.similarity_search("foo", k=1) + assert output == [Document(page_content="foo")] + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_hybrid_deduplicate() -> None: + """Test result deduplication with hybrid search.""" + text_embeddings = FakeEmbeddingsWithOsDimension().embed_documents(texts) + text_embedding_pairs = list(zip(texts, text_embeddings)) + docsearch = Neo4jVector.from_embeddings( + text_embeddings=text_embedding_pairs, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + search_type=SearchType.HYBRID, + ) + output = docsearch.similarity_search("foo", k=3) + assert output == [ + Document(page_content="foo"), + Document(page_content="bar"), + Document(page_content="baz"), + ] + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_hybrid_retrieval_query() -> None: + """Test custom retrieval_query with hybrid search.""" + text_embeddings = FakeEmbeddingsWithOsDimension().embed_documents(texts) + text_embedding_pairs = list(zip(texts, text_embeddings)) + docsearch = Neo4jVector.from_embeddings( + text_embeddings=text_embedding_pairs, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + search_type=SearchType.HYBRID, + retrieval_query="RETURN 'moo' AS text, score, {test: 'test'} AS metadata", + ) + output = docsearch.similarity_search("foo", k=1) + assert output == [Document(page_content="moo", metadata={"test": "test"})] + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_hybrid_retrieval_query2() -> None: + """Test custom retrieval_query with hybrid search.""" + text_embeddings = FakeEmbeddingsWithOsDimension().embed_documents(texts) + text_embedding_pairs = list(zip(texts, text_embeddings)) + docsearch = Neo4jVector.from_embeddings( + text_embeddings=text_embedding_pairs, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + search_type=SearchType.HYBRID, + retrieval_query="RETURN node.text AS text, score, {test: 'test'} AS metadata", + ) + output = docsearch.similarity_search("foo", k=1) + assert output == [Document(page_content="foo", metadata={"test": "test"})] + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_missing_keyword() -> None: + """Test hybrid search with missing keyword_index_search.""" + text_embeddings = FakeEmbeddingsWithOsDimension().embed_documents(texts) + text_embedding_pairs = list(zip(texts, text_embeddings)) + docsearch = Neo4jVector.from_embeddings( + text_embeddings=text_embedding_pairs, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + try: + Neo4jVector.from_existing_index( + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="vector", + search_type=SearchType.HYBRID, + ) + except ValueError as e: + assert str(e) == ( + "keyword_index name has to be specified when " "using hybrid search option" + ) + drop_vector_indexes(docsearch) + + +def test_neo4jvector_hybrid_from_existing() -> None: + """Test hybrid search with missing keyword_index_search.""" + text_embeddings = FakeEmbeddingsWithOsDimension().embed_documents(texts) + text_embedding_pairs = list(zip(texts, text_embeddings)) + Neo4jVector.from_embeddings( + text_embeddings=text_embedding_pairs, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + search_type=SearchType.HYBRID, + ) + existing = Neo4jVector.from_existing_index( + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="vector", + keyword_index_name="keyword", + search_type=SearchType.HYBRID, + ) + + output = existing.similarity_search("foo", k=1) + assert output == [Document(page_content="foo")] + + drop_vector_indexes(existing) + + +def test_neo4jvector_from_existing_graph() -> None: + """Test from_existing_graph with a single property.""" + graph = Neo4jVector.from_texts( + texts=["test"], + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="foo", + node_label="Foo", + embedding_node_property="vector", + text_node_property="info", + pre_delete_collection=True, + ) + + graph.query("MATCH (n) DETACH DELETE n") + + graph.query("CREATE (:Test {name:'Foo'})," "(:Test {name:'Bar'})") + + existing = Neo4jVector.from_existing_graph( + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="vector", + node_label="Test", + text_node_properties=["name"], + embedding_node_property="embedding", + ) + + output = existing.similarity_search("foo", k=1) + assert output == [Document(page_content="\nname: Foo")] + + drop_vector_indexes(existing) + + +def test_neo4jvector_from_existing_graph_hybrid() -> None: + """Test from_existing_graph hybrid with a single property.""" + graph = Neo4jVector.from_texts( + texts=["test"], + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="foo", + node_label="Foo", + embedding_node_property="vector", + text_node_property="info", + pre_delete_collection=True, + ) + + graph.query("MATCH (n) DETACH DELETE n") + + graph.query("CREATE (:Test {name:'foo'})," "(:Test {name:'Bar'})") + + existing = Neo4jVector.from_existing_graph( + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="vector", + node_label="Test", + text_node_properties=["name"], + embedding_node_property="embedding", + search_type=SearchType.HYBRID, + ) + + output = existing.similarity_search("foo", k=1) + assert output == [Document(page_content="\nname: foo")] + + drop_vector_indexes(existing) + + +def test_neo4jvector_from_existing_graph_multiple_properties() -> None: + """Test from_existing_graph with a two property.""" + graph = Neo4jVector.from_texts( + texts=["test"], + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="foo", + node_label="Foo", + embedding_node_property="vector", + text_node_property="info", + pre_delete_collection=True, + ) + graph.query("MATCH (n) DETACH DELETE n") + + graph.query("CREATE (:Test {name:'Foo', name2: 'Fooz'})," "(:Test {name:'Bar'})") + + existing = Neo4jVector.from_existing_graph( + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="vector", + node_label="Test", + text_node_properties=["name", "name2"], + embedding_node_property="embedding", + ) + + output = existing.similarity_search("foo", k=1) + assert output == [Document(page_content="\nname: Foo\nname2: Fooz")] + + drop_vector_indexes(existing) + + +def test_neo4jvector_from_existing_graph_multiple_properties_hybrid() -> None: + """Test from_existing_graph with a two property.""" + graph = Neo4jVector.from_texts( + texts=["test"], + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="foo", + node_label="Foo", + embedding_node_property="vector", + text_node_property="info", + pre_delete_collection=True, + ) + graph.query("MATCH (n) DETACH DELETE n") + + graph.query("CREATE (:Test {name:'Foo', name2: 'Fooz'})," "(:Test {name:'Bar'})") + + existing = Neo4jVector.from_existing_graph( + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + index_name="vector", + node_label="Test", + text_node_properties=["name", "name2"], + embedding_node_property="embedding", + search_type=SearchType.HYBRID, + ) + + output = existing.similarity_search("foo", k=1) + assert output == [Document(page_content="\nname: Foo\nname2: Fooz")] + + drop_vector_indexes(existing) + + +def test_neo4jvector_special_character() -> None: + """Test removing lucene.""" + text_embeddings = FakeEmbeddingsWithOsDimension().embed_documents(texts) + text_embedding_pairs = list(zip(texts, text_embeddings)) + docsearch = Neo4jVector.from_embeddings( + text_embeddings=text_embedding_pairs, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + search_type=SearchType.HYBRID, + ) + docsearch.similarity_search( + "It is the end of the world. Take shelter!", + k=1, + ) + # assert output == [ + # Document( + # page_content="It is the end of the world. Take shelter!", metadata={} + # ) + # ] + + drop_vector_indexes(docsearch) + + +def test_hybrid_score_normalization() -> None: + """Test if we can get two 1.0 documents with RRF""" + text_embeddings = FakeEmbeddingsWithOsDimension().embed_documents(texts) + text_embedding_pairs = list(zip(["foo"], text_embeddings)) + docsearch = Neo4jVector.from_embeddings( + text_embeddings=text_embedding_pairs, + embedding=FakeEmbeddingsWithOsDimension(), + url=url, + username=username, + password=password, + pre_delete_collection=True, + search_type=SearchType.HYBRID, + ) + # Remove deduplication part of the query + rrf_query = ( + _get_search_index_query(SearchType.HYBRID) + .rstrip("WITH node, max(score) AS score ORDER BY score DESC LIMIT $k") + .replace("UNION", "UNION ALL") + + "RETURN node.text AS text, score LIMIT 2" + ) + + output = docsearch.query( + rrf_query, + params={ + "index": "vector", + "k": 1, + "embedding": FakeEmbeddingsWithOsDimension().embed_query("foo"), + "query": "foo", + "keyword_index": "keyword", + }, + ) + # Both FT and Vector must return 1.0 score + assert output == [{"text": "foo", "score": 1.0}, {"text": "foo", "score": 1.0}] + drop_vector_indexes(docsearch) + + +def test_index_fetching() -> None: + """testing correct index creation and fetching""" + embeddings = FakeEmbeddings() + + def create_store( + node_label: str, index: str, text_properties: List[str] + ) -> Neo4jVector: + return Neo4jVector.from_existing_graph( + embedding=embeddings, + url=url, + username=username, + password=password, + index_name=index, + node_label=node_label, + text_node_properties=text_properties, + embedding_node_property="embedding", + ) + + def fetch_store(index_name: str) -> Neo4jVector: + store = Neo4jVector.from_existing_index( + embedding=embeddings, + url=url, + username=username, + password=password, + index_name=index_name, + ) + return store + + # create index 0 + index_0_str = "index0" + create_store("label0", index_0_str, ["text"]) + + # create index 1 + index_1_str = "index1" + create_store("label1", index_1_str, ["text"]) + + index_1_store = fetch_store(index_1_str) + assert index_1_store.index_name == index_1_str + + index_0_store = fetch_store(index_0_str) + assert index_0_store.index_name == index_0_str + drop_vector_indexes(index_1_store) + drop_vector_indexes(index_0_store) + + +def test_retrieval_params() -> None: + """Test if we use parameters in retrieval query""" + docsearch = Neo4jVector.from_texts( + texts=texts, + embedding=FakeEmbeddings(), + pre_delete_collection=True, + retrieval_query=""" + RETURN $test as text, score, {test: $test1} AS metadata + """, + url=url, + username=username, + password=password, + ) + + output = docsearch.similarity_search( + "Foo", k=2, params={"test": "test", "test1": "test1"} + ) + assert output == [ + Document(page_content="test", metadata={"test": "test1"}), + Document(page_content="test", metadata={"test": "test1"}), + ] + drop_vector_indexes(docsearch) + + +def test_retrieval_dictionary() -> None: + """Test if we use parameters in retrieval query""" + docsearch = Neo4jVector.from_texts( + url=url, + username=username, + password=password, + texts=texts, + embedding=FakeEmbeddings(), + pre_delete_collection=True, + retrieval_query=""" + RETURN { + name:'John', + age: 30, + skills: ["Python", "Data Analysis", "Machine Learning"]} as text, + score, {} AS metadata + """, + ) + expected_output = [ + Document( + page_content=( + "skills:\n- Python\n- Data Analysis\n- " + "Machine Learning\nage: 30\nname: John\n" + ) + ) + ] + + output = docsearch.similarity_search("Foo", k=1) + + def parse_document(doc: Document) -> Any: + return safe_load(doc.page_content) + + parsed_expected = [parse_document(doc) for doc in expected_output] + parsed_output = [parse_document(doc) for doc in output] + + assert parsed_output == parsed_expected + drop_vector_indexes(docsearch) + + +def test_metadata_filters_type1() -> None: + """Test metadata filters""" + docsearch = Neo4jVector.from_documents( + DOCUMENTS, + embedding=FakeEmbeddings(), + pre_delete_collection=True, + url=url, + username=username, + password=password, + ) + # We don't test type 5, because LIKE has very SQL specific examples + for example in ( + TYPE_1_FILTERING_TEST_CASES + + TYPE_2_FILTERING_TEST_CASES + + TYPE_3_FILTERING_TEST_CASES + + TYPE_4_FILTERING_TEST_CASES + ): + filter_dict = cast(Dict[str, Any], example[0]) + output = docsearch.similarity_search("Foo", filter=filter_dict) + indices = cast(List[int], example[1]) + adjusted_indices = [index - 1 for index in indices] + expected_output = [DOCUMENTS[index] for index in adjusted_indices] + # We don't return id properties from similarity search by default + # Also remove any key where the value is None + for doc in expected_output: + if "id" in doc.metadata: + del doc.metadata["id"] + keys_with_none = [ + key for key, value in doc.metadata.items() if value is None + ] + for key in keys_with_none: + del doc.metadata[key] + + assert output == expected_output + drop_vector_indexes(docsearch) + + +def test_neo4jvector_relationship_index() -> None: + """Test end to end construction and search.""" + embeddings = FakeEmbeddingsWithOsDimension() + docsearch = Neo4jVector.from_texts( + texts=texts, + embedding=embeddings, + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + # Ingest data + docsearch.query( + ( + "CREATE ()-[:REL {text: 'foo', embedding: $e1}]->()" + ", ()-[:REL {text: 'far', embedding: $e2}]->()" + ), + params={ + "e1": embeddings.embed_query("foo"), + "e2": embeddings.embed_query("bar"), + }, + ) + # Create relationship index + docsearch.query( + """CREATE VECTOR INDEX `relationship` +FOR ()-[r:REL]-() ON (r.embedding) +OPTIONS {indexConfig: { + `vector.dimensions`: 1536, + `vector.similarity_function`: 'cosine' +}} +""" + ) + relationship_index = Neo4jVector.from_existing_relationship_index( + embeddings, + index_name="relationship", + url=url, + username=username, + password=password, + ) + + output = relationship_index.similarity_search("foo", k=1) + assert output == [Document(page_content="foo")] + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_relationship_index_retrieval() -> None: + """Test end to end construction and search.""" + embeddings = FakeEmbeddingsWithOsDimension() + docsearch = Neo4jVector.from_texts( + texts=texts, + embedding=embeddings, + url=url, + username=username, + password=password, + pre_delete_collection=True, + ) + # Ingest data + docsearch.query( + ( + "CREATE ({node:'text'})-[:REL {text: 'foo', embedding: $e1}]->()" + ", ({node:'text'})-[:REL {text: 'far', embedding: $e2}]->()" + ), + params={ + "e1": embeddings.embed_query("foo"), + "e2": embeddings.embed_query("bar"), + }, + ) + # Create relationship index + docsearch.query( + """CREATE VECTOR INDEX `relationship` +FOR ()-[r:REL]-() ON (r.embedding) +OPTIONS {indexConfig: { + `vector.dimensions`: 1536, + `vector.similarity_function`: 'cosine' +}} +""" + ) + retrieval_query = ( + "RETURN relationship.text + '-' + startNode(relationship).node " + "AS text, score, {foo:'bar'} AS metadata" + ) + relationship_index = Neo4jVector.from_existing_relationship_index( + embeddings, + index_name="relationship", + retrieval_query=retrieval_query, + url=url, + username=username, + password=password, + ) + + output = relationship_index.similarity_search("foo", k=1) + assert output == [Document(page_content="foo-text", metadata={"foo": "bar"})] + + drop_vector_indexes(docsearch) + + +def test_neo4j_max_marginal_relevance_search() -> None: + """ + Test end to end construction and MMR search. + The embedding function used here ensures `texts` become + the following vectors on a circle (numbered v0 through v3): + + ______ v2 + / \ + / | v1 + v3 | . | query + | / v0 + |______/ (N.B. very crude drawing) + + With fetch_k==3 and k==2, when query is at (1, ), + one expects that v2 and v0 are returned (in some order). + """ + texts = ["-0.124", "+0.127", "+0.25", "+1.0"] + metadatas = [{"page": i} for i in range(len(texts))] + docsearch = Neo4jVector.from_texts( + texts, + metadatas=metadatas, + embedding=AngularTwoDimensionalEmbeddings(), + pre_delete_collection=True, + url=url, + username=username, + password=password, + ) + + expected_set = { + ("+0.25", 2), + ("-0.124", 0), + } + + output = docsearch.max_marginal_relevance_search("0.0", k=2, fetch_k=3) + output_set = { + (mmr_doc.page_content, mmr_doc.metadata["page"]) for mmr_doc in output + } + assert output_set == expected_set + + drop_vector_indexes(docsearch) + + +def test_neo4jvector_passing_graph_object() -> None: + """Test end to end construction and search with passing graph object.""" + graph = Neo4jGraph(url=url, username=username, password=password) + # Rewrite env vars to make sure it fails if env is used + os.environ["NEO4J_URI"] = "foo" + docsearch = Neo4jVector.from_texts( + texts=texts, + embedding=FakeEmbeddingsWithOsDimension(), + graph=graph, + pre_delete_collection=True, + url=url, + username=username, + password=password, + ) + output = docsearch.similarity_search("foo", k=1) + assert output == [Document(page_content="foo")] + + drop_vector_indexes(docsearch) diff --git a/libs/neo4j/tests/unit_tests/__init__.py b/libs/neo4j/tests/unit_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/tests/unit_tests/chains/__init__.py b/libs/neo4j/tests/unit_tests/chains/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/tests/unit_tests/chains/test_graph_qa.py b/libs/neo4j/tests/unit_tests/chains/test_graph_qa.py new file mode 100644 index 0000000..4450983 --- /dev/null +++ b/libs/neo4j/tests/unit_tests/chains/test_graph_qa.py @@ -0,0 +1,341 @@ +import pathlib +from csv import DictReader +from typing import Any, Dict, List + +from langchain.chains.graph_qa.prompts import CYPHER_GENERATION_PROMPT, CYPHER_QA_PROMPT +from langchain.memory import ConversationBufferMemory, ReadOnlySharedMemory +from langchain_community.graphs.graph_document import GraphDocument +from langchain_community.graphs.graph_store import GraphStore +from langchain_core.prompts import PromptTemplate + +from langchain_neo4j.chains.graph_qa.cypher import ( + GraphCypherQAChain, + construct_schema, + extract_cypher, +) +from langchain_neo4j.chains.graph_qa.cypher_utils import ( + CypherQueryCorrector, + Schema, +) +from tests.unit_tests.llms.fake_llm import FakeLLM + + +class FakeGraphStore(GraphStore): + @property + def get_schema(self) -> str: + """Returns the schema of the Graph database""" + return "" + + @property + def get_structured_schema(self) -> Dict[str, Any]: + """Returns the schema of the Graph database""" + return {} + + def query(self, query: str, params: dict = {}) -> List[Dict[str, Any]]: + """Query the graph.""" + return [] + + def refresh_schema(self) -> None: + """Refreshes the graph schema information.""" + pass + + def add_graph_documents( + self, graph_documents: List[GraphDocument], include_source: bool = False + ) -> None: + """Take GraphDocument as input as uses it to construct a graph.""" + pass + + +def test_graph_cypher_qa_chain_prompt_selection_1() -> None: + # Pass prompts directly. No kwargs is specified. + qa_prompt_template = "QA Prompt" + cypher_prompt_template = "Cypher Prompt" + qa_prompt = PromptTemplate(template=qa_prompt_template, input_variables=[]) + cypher_prompt = PromptTemplate(template=cypher_prompt_template, input_variables=[]) + chain = GraphCypherQAChain.from_llm( + llm=FakeLLM(), + graph=FakeGraphStore(), + verbose=True, + return_intermediate_steps=False, + qa_prompt=qa_prompt, + cypher_prompt=cypher_prompt, + allow_dangerous_requests=True, + ) + assert chain.qa_chain.prompt == qa_prompt # type: ignore[union-attr] + assert chain.cypher_generation_chain.prompt == cypher_prompt + + +def test_graph_cypher_qa_chain_prompt_selection_2() -> None: + # Default case. Pass nothing + chain = GraphCypherQAChain.from_llm( + llm=FakeLLM(), + graph=FakeGraphStore(), + verbose=True, + return_intermediate_steps=False, + allow_dangerous_requests=True, + ) + assert chain.qa_chain.prompt == CYPHER_QA_PROMPT # type: ignore[union-attr] + assert chain.cypher_generation_chain.prompt == CYPHER_GENERATION_PROMPT + + +def test_graph_cypher_qa_chain_prompt_selection_3() -> None: + # Pass non-prompt args only to sub-chains via kwargs + memory = ConversationBufferMemory(memory_key="chat_history") + readonlymemory = ReadOnlySharedMemory(memory=memory) + chain = GraphCypherQAChain.from_llm( + llm=FakeLLM(), + graph=FakeGraphStore(), + verbose=True, + return_intermediate_steps=False, + cypher_llm_kwargs={"memory": readonlymemory}, + qa_llm_kwargs={"memory": readonlymemory}, + allow_dangerous_requests=True, + ) + assert chain.qa_chain.prompt == CYPHER_QA_PROMPT # type: ignore[union-attr] + assert chain.cypher_generation_chain.prompt == CYPHER_GENERATION_PROMPT + + +def test_graph_cypher_qa_chain_prompt_selection_4() -> None: + # Pass prompt, non-prompt args to subchains via kwargs + qa_prompt_template = "QA Prompt" + cypher_prompt_template = "Cypher Prompt" + memory = ConversationBufferMemory(memory_key="chat_history") + readonlymemory = ReadOnlySharedMemory(memory=memory) + qa_prompt = PromptTemplate(template=qa_prompt_template, input_variables=[]) + cypher_prompt = PromptTemplate(template=cypher_prompt_template, input_variables=[]) + chain = GraphCypherQAChain.from_llm( + llm=FakeLLM(), + graph=FakeGraphStore(), + verbose=True, + return_intermediate_steps=False, + cypher_llm_kwargs={"prompt": cypher_prompt, "memory": readonlymemory}, + qa_llm_kwargs={"prompt": qa_prompt, "memory": readonlymemory}, + allow_dangerous_requests=True, + ) + assert chain.qa_chain.prompt == qa_prompt # type: ignore[union-attr] + assert chain.cypher_generation_chain.prompt == cypher_prompt + + +def test_graph_cypher_qa_chain_prompt_selection_5() -> None: + # Can't pass both prompt and kwargs at the same time + qa_prompt_template = "QA Prompt" + cypher_prompt_template = "Cypher Prompt" + memory = ConversationBufferMemory(memory_key="chat_history") + readonlymemory = ReadOnlySharedMemory(memory=memory) + qa_prompt = PromptTemplate(template=qa_prompt_template, input_variables=[]) + cypher_prompt = PromptTemplate(template=cypher_prompt_template, input_variables=[]) + try: + GraphCypherQAChain.from_llm( + llm=FakeLLM(), + graph=FakeGraphStore(), + verbose=True, + return_intermediate_steps=False, + qa_prompt=qa_prompt, + cypher_prompt=cypher_prompt, + cypher_llm_kwargs={"memory": readonlymemory}, + qa_llm_kwargs={"memory": readonlymemory}, + allow_dangerous_requests=True, + ) + assert False + except ValueError: + assert True + + +def test_graph_cypher_qa_chain() -> None: + template = """You are a nice chatbot having a conversation with a human. + + Schema: + {schema} + + Previous conversation: + {chat_history} + + New human question: {question} + Response:""" + + prompt = PromptTemplate( + input_variables=["schema", "question", "chat_history"], template=template + ) + + memory = ConversationBufferMemory(memory_key="chat_history") + readonlymemory = ReadOnlySharedMemory(memory=memory) + prompt1 = ( + "You are a nice chatbot having a conversation with a human.\n\n " + "Schema:\n Node properties are the following:\n\nRelationship " + "properties are the following:\n\nThe relationships are the " + "following:\n\n\n " + "Previous conversation:\n \n\n New human question: " + "Test question\n Response:" + ) + + prompt2 = ( + "You are a nice chatbot having a conversation with a human.\n\n " + "Schema:\n Node properties are the following:\n\nRelationship " + "properties are the following:\n\nThe relationships are the " + "following:\n\n\n " + "Previous conversation:\n Human: Test question\nAI: foo\n\n " + "New human question: Test new question\n Response:" + ) + + llm = FakeLLM(queries={prompt1: "answer1", prompt2: "answer2"}) + chain = GraphCypherQAChain.from_llm( + cypher_llm=llm, + qa_llm=FakeLLM(), + graph=FakeGraphStore(), + verbose=True, + return_intermediate_steps=False, + cypher_llm_kwargs={"prompt": prompt, "memory": readonlymemory}, + memory=memory, + allow_dangerous_requests=True, + ) + chain.run("Test question") + chain.run("Test new question") + # If we get here without a key error, that means memory + # was used properly to create prompts. + assert True + + +def test_no_backticks() -> None: + """Test if there are no backticks, so the original text should be returned.""" + query = "MATCH (n) RETURN n" + output = extract_cypher(query) + assert output == query + + +def test_backticks() -> None: + """Test if there are backticks. Query from within backticks should be returned.""" + query = "You can use the following query: ```MATCH (n) RETURN n```" + output = extract_cypher(query) + assert output == "MATCH (n) RETURN n" + + +def test_exclude_types() -> None: + structured_schema = { + "node_props": { + "Movie": [{"property": "title", "type": "STRING"}], + "Actor": [{"property": "name", "type": "STRING"}], + "Person": [{"property": "name", "type": "STRING"}], + }, + "rel_props": {}, + "relationships": [ + {"start": "Actor", "end": "Movie", "type": "ACTED_IN"}, + {"start": "Person", "end": "Movie", "type": "DIRECTED"}, + ], + } + exclude_types = ["Person", "DIRECTED"] + output = construct_schema(structured_schema, [], exclude_types) + expected_schema = ( + "Node properties are the following:\n" + "Movie {title: STRING},Actor {name: STRING}\n" + "Relationship properties are the following:\n\n" + "The relationships are the following:\n" + "(:Actor)-[:ACTED_IN]->(:Movie)" + ) + assert output == expected_schema + + +def test_include_types() -> None: + structured_schema = { + "node_props": { + "Movie": [{"property": "title", "type": "STRING"}], + "Actor": [{"property": "name", "type": "STRING"}], + "Person": [{"property": "name", "type": "STRING"}], + }, + "rel_props": {}, + "relationships": [ + {"start": "Actor", "end": "Movie", "type": "ACTED_IN"}, + {"start": "Person", "end": "Movie", "type": "DIRECTED"}, + ], + } + include_types = ["Movie", "Actor", "ACTED_IN"] + output = construct_schema(structured_schema, include_types, []) + expected_schema = ( + "Node properties are the following:\n" + "Movie {title: STRING},Actor {name: STRING}\n" + "Relationship properties are the following:\n\n" + "The relationships are the following:\n" + "(:Actor)-[:ACTED_IN]->(:Movie)" + ) + assert output == expected_schema + + +def test_include_types2() -> None: + structured_schema = { + "node_props": { + "Movie": [{"property": "title", "type": "STRING"}], + "Actor": [{"property": "name", "type": "STRING"}], + "Person": [{"property": "name", "type": "STRING"}], + }, + "rel_props": {}, + "relationships": [ + {"start": "Actor", "end": "Movie", "type": "ACTED_IN"}, + {"start": "Person", "end": "Movie", "type": "DIRECTED"}, + ], + } + include_types = ["Movie", "Actor"] + output = construct_schema(structured_schema, include_types, []) + expected_schema = ( + "Node properties are the following:\n" + "Movie {title: STRING},Actor {name: STRING}\n" + "Relationship properties are the following:\n\n" + "The relationships are the following:\n" + ) + assert output == expected_schema + + +def test_include_types3() -> None: + structured_schema = { + "node_props": { + "Movie": [{"property": "title", "type": "STRING"}], + "Actor": [{"property": "name", "type": "STRING"}], + "Person": [{"property": "name", "type": "STRING"}], + }, + "rel_props": {}, + "relationships": [ + {"start": "Actor", "end": "Movie", "type": "ACTED_IN"}, + {"start": "Person", "end": "Movie", "type": "DIRECTED"}, + ], + } + include_types = ["Movie", "Actor", "ACTED_IN"] + output = construct_schema(structured_schema, include_types, []) + expected_schema = ( + "Node properties are the following:\n" + "Movie {title: STRING},Actor {name: STRING}\n" + "Relationship properties are the following:\n\n" + "The relationships are the following:\n" + "(:Actor)-[:ACTED_IN]->(:Movie)" + ) + assert output == expected_schema + + +HERE = pathlib.Path(__file__).parent + +UNIT_TESTS_ROOT = HERE.parent + + +def test_validating_cypher_statements() -> None: + cypher_file = str(UNIT_TESTS_ROOT / "data/cypher_corrector.csv") + with open(cypher_file, newline="") as csvfile: + csv_reader = DictReader(csvfile) + for row in csv_reader: + schema = load_schemas(row["schema"]) + corrector = CypherQueryCorrector(schema) + assert corrector(row["statement"]) == row["correct_query"] + + +def load_schemas(str_schemas: str) -> List[Schema]: + """ + Args: + str_schemas: string of schemas + """ + values = str_schemas.replace("(", "").replace(")", "").split(",") + schemas = [] + for i in range(len(values) // 3): + schemas.append( + Schema( + values[i * 3].strip(), + values[i * 3 + 1].strip(), + values[i * 3 + 2].strip(), + ) + ) + return schemas diff --git a/libs/neo4j/tests/unit_tests/data/cypher_corrector.csv b/libs/neo4j/tests/unit_tests/data/cypher_corrector.csv new file mode 100644 index 0000000..539e0e5 --- /dev/null +++ b/libs/neo4j/tests/unit_tests/data/cypher_corrector.csv @@ -0,0 +1,512 @@ +"statement","schema","correct_query" +"MATCH (p:Person)-[:KNOWS]->(:Person) RETURN p, count(*) AS count","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person)-[:KNOWS]->(:Person) RETURN p, count(*) AS count" +"MATCH (p:Person)<-[:KNOWS]-(:Person) RETURN p, count(*) AS count","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person)<-[:KNOWS]-(:Person) RETURN p, count(*) AS count" +"MATCH (p:Person {id:""Foo""})<-[:WORKS_AT]-(o:Organization) RETURN o.name AS name","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person {id:""Foo""})-[:WORKS_AT]->(o:Organization) RETURN o.name AS name" +"MATCH (o:Organization)-[:WORKS_AT]->(p:Person {id:""Foo""}) RETURN o.name AS name","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (o:Organization)<-[:WORKS_AT]-(p:Person {id:""Foo""}) RETURN o.name AS name" +"MATCH (o:Organization {name:""Bar""})-[:WORKS_AT]->(p:Person {id:""Foo""}) RETURN o.name AS name","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (o:Organization {name:""Bar""})<-[:WORKS_AT]-(p:Person {id:""Foo""}) RETURN o.name AS name" +"MATCH (o:Organization)-[:WORKS_AT]->(p:Person {id:""Foo""})-[:WORKS_AT]->(o1:Organization) RETURN o.name AS name","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (o:Organization)<-[:WORKS_AT]-(p:Person {id:""Foo""})-[:WORKS_AT]->(o1:Organization) RETURN o.name AS name" +"MATCH (o:`Organization` {name:""Foo""})-[:WORKS_AT]->(p:Person {id:""Foo""})-[:WORKS_AT]-(o1:Organization {name:""b""}) +WHERE id(o) > id(o1) +RETURN o.name AS name","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (o:`Organization` {name:""Foo""})<-[:WORKS_AT]-(p:Person {id:""Foo""})-[:WORKS_AT]-(o1:Organization {name:""b""}) +WHERE id(o) > id(o1) +RETURN o.name AS name" +"MATCH (p:Person) +RETURN p, + [(p)-[:WORKS_AT]->(o:Organization) | o.name] AS op","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person) +RETURN p, + [(p)-[:WORKS_AT]->(o:Organization) | o.name] AS op" +"MATCH (p:Person) +RETURN p, + [(p)<-[:WORKS_AT]-(o:Organization) | o.name] AS op","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person) +RETURN p, + [(p)-[:WORKS_AT]->(o:Organization) | o.name] AS op" +"MATCH (p:Person {name:""John""}) MATCH (p)-[:WORKS_AT]->(:Organization) RETURN p, count(*)","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person {name:""John""}) MATCH (p)-[:WORKS_AT]->(:Organization) RETURN p, count(*)" +"MATCH (p:Person) MATCH (p)<-[:WORKS_AT]-(:Organization) RETURN p, count(*)","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person) MATCH (p)-[:WORKS_AT]->(:Organization) RETURN p, count(*)" +"MATCH (p:Person), (p)<-[:WORKS_AT]-(:Organization) RETURN p, count(*)","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person), (p)-[:WORKS_AT]->(:Organization) RETURN p, count(*)" +"MATCH (o:Organization)-[:WORKS_AT]->(p:Person {id:""Foo""})-[:WORKS_AT]->(o1:Organization) +WHERE id(o) < id(o1) RETURN o.name AS name","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (o:Organization)<-[:WORKS_AT]-(p:Person {id:""Foo""})-[:WORKS_AT]->(o1:Organization) +WHERE id(o) < id(o1) RETURN o.name AS name" +"MATCH (o:Organization)-[:WORKS_AT]-(p:Person {id:""Foo""})-[:WORKS_AT]-(o1:Organization) +WHERE id(o) < id(o1) RETURN o.name AS name","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (o:Organization)-[:WORKS_AT]-(p:Person {id:""Foo""})-[:WORKS_AT]-(o1:Organization) +WHERE id(o) < id(o1) RETURN o.name AS name" +"MATCH (p:Person)--(:Organization)--(p1:Person) +RETURN p1","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person)--(:Organization)--(p1:Person) +RETURN p1" +"MATCH (p:Person)<--(:Organization)--(p1:Person) +RETURN p1","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person)-->(:Organization)--(p1:Person) +RETURN p1" +"MATCH (p:Person)<-[r]-(:Organization)--(p1:Person) +RETURN p1, r","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person)-[r]->(:Organization)--(p1:Person) +RETURN p1, r" +"MATCH (person:Person) +CALL { + WITH person + MATCH (person)-->(o:Organization) + RETURN o LIMIT 3 +} +RETURN person, o","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (person:Person) +CALL { + WITH person + MATCH (person)-->(o:Organization) + RETURN o LIMIT 3 +} +RETURN person, o" +"MATCH (person:Person) +CALL { + WITH person + MATCH (person)<--(o:Organization) + RETURN o LIMIT 3 +} +RETURN person, o","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (person:Person) +CALL { + WITH person + MATCH (person)-->(o:Organization) + RETURN o LIMIT 3 +} +RETURN person, o" +"MATCH (person:Person) +CALL { + WITH person + MATCH (person)-[:KNOWS]->(o:Organization) + RETURN o LIMIT 3 +} +RETURN person, o","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)", +"MATCH (person:Person) +CALL { + WITH person + MATCH (person)<-[:WORKS_AT|INVESTOR]-(o:Organization) + RETURN o LIMIT 3 +} +RETURN person, o","(Person, KNOWS, Person), (Person, WORKS_AT, Organization), (Person, INVESTOR, Organization)","MATCH (person:Person) +CALL { + WITH person + MATCH (person)-[:WORKS_AT|INVESTOR]->(o:Organization) + RETURN o LIMIT 3 +} +RETURN person, o" +"MATCH (p:Person) +WHERE EXISTS { (p)<-[:KNOWS]-()} +RETURN p","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person) +WHERE EXISTS { (p)<-[:KNOWS]-()} +RETURN p" +"MATCH (p:Person) +WHERE EXISTS { (p)-[:KNOWS]->()} +RETURN p","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person) +WHERE EXISTS { (p)-[:KNOWS]->()} +RETURN p" +"MATCH (p:Person) +WHERE EXISTS { (p)<-[:WORKS_AT]-()} +RETURN p","(Person, KNOWS, Person), (Person, WORKS_AT, Organization)","MATCH (p:Person) +WHERE EXISTS { (p)-[:WORKS_AT]->()} +RETURN p" +"MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WHERE p.name = 'Tom Hanks' +AND m.year = 2013 +RETURN m.title","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WHERE p.name = 'Tom Hanks' +AND m.year = 2013 +RETURN m.title" +"MATCH (p:Person)-[:ACTED_IN]-(m:Movie) +WHERE p.name = 'Tom Hanks' +AND m.year = 2013 +RETURN m.title","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN]-(m:Movie) +WHERE p.name = 'Tom Hanks' +AND m.year = 2013 +RETURN m.title" +"MATCH (p:Person)<-[:ACTED_IN]-(m:Movie) +WHERE p.name = 'Tom Hanks' +AND m.year = 2013 +RETURN m.title","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WHERE p.name = 'Tom Hanks' +AND m.year = 2013 +RETURN m.title" +"MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WHERE p.name <> 'Tom Hanks' +AND m.title = 'Captain Phillips' +RETURN p.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WHERE p.name <> 'Tom Hanks' +AND m.title = 'Captain Phillips' +RETURN p.name" +"MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WHERE p.name <> 'Tom Hanks' +AND m.title = 'Captain Phillips' +AND m.year > 2019 +AND m.year < 2030 +RETURN p.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WHERE p.name <> 'Tom Hanks' +AND m.title = 'Captain Phillips' +AND m.year > 2019 +AND m.year < 2030 +RETURN p.name" +"MATCH (p:Person)<-[:ACTED_IN]-(m:Movie) +WHERE p.name <> 'Tom Hanks' +AND m.title = 'Captain Phillips' +AND m.year > 2019 +AND m.year < 2030 +RETURN p.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WHERE p.name <> 'Tom Hanks' +AND m.title = 'Captain Phillips' +AND m.year > 2019 +AND m.year < 2030 +RETURN p.name" +"MATCH (p:Person)<-[:FOLLOWS]-(m:Movie) +WHERE p.name <> 'Tom Hanks' +AND m.title = 'Captain Phillips' +AND m.year > 2019 +AND m.year < 2030 +RETURN p.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)", +"MATCH (p:Person)-[:`ACTED_IN`]->(m:Movie)<-[:DIRECTED]-(p) +WHERE p.born.year > 1960 +RETURN p.name, p.born, labels(p), m.title","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:`ACTED_IN`]->(m:Movie)<-[:DIRECTED]-(p) +WHERE p.born.year > 1960 +RETURN p.name, p.born, labels(p), m.title" +"MATCH (p:Person)-[:ACTED_IN]-(m:Movie)<-[:DIRECTED]-(p) +WHERE p.born.year > 1960 +RETURN p.name, p.born, labels(p), m.title","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN]-(m:Movie)<-[:DIRECTED]-(p) +WHERE p.born.year > 1960 +RETURN p.name, p.born, labels(p), m.title" +"MATCH (p:Person)-[:ACTED_IN]-(m:Movie)-[:DIRECTED]->(p) +WHERE p.born.year > 1960 +RETURN p.name, p.born, labels(p), m.title","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN]-(m:Movie)<-[:DIRECTED]-(p) +WHERE p.born.year > 1960 +RETURN p.name, p.born, labels(p), m.title" +"MATCH (p:`Person`)<-[r]-(m:Movie) +WHERE p.name = 'Tom Hanks' +RETURN m.title AS movie, type(r) AS relationshipType","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:`Person`)-[r]->(m:Movie) +WHERE p.name = 'Tom Hanks' +RETURN m.title AS movie, type(r) AS relationshipType" +"MATCH (d:Person)-[:DIRECTED]->(m:Movie)-[:IN_GENRE]->(g:Genre) +WHERE m.year = 2000 AND g.name = ""Horror"" +RETURN d.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (d:Person)-[:DIRECTED]->(m:Movie)-[:IN_GENRE]->(g:Genre) +WHERE m.year = 2000 AND g.name = ""Horror"" +RETURN d.name" +"MATCH (d:Person)-[:DIRECTED]->(m:Movie)<--(g:Genre) +WHERE m.year = 2000 AND g.name = ""Horror"" +RETURN d.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (d:Person)-[:DIRECTED]->(m:Movie)-->(g:Genre) +WHERE m.year = 2000 AND g.name = ""Horror"" +RETURN d.name" +"MATCH (d:Person)<--(m:Movie)<--(g:Genre) +WHERE m.year = 2000 AND g.name = ""Horror"" +RETURN d.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (d:Person)-->(m:Movie)-->(g:Genre) +WHERE m.year = 2000 AND g.name = ""Horror"" +RETURN d.name" +"MATCH (d:Person)-[:DIRECTED]-(m:Movie)<-[:IN_GENRE]-(g:Genre) +WHERE m.year = 2000 AND g.name = ""Horror"" +RETURN d.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (d:Person)-[:DIRECTED]-(m:Movie)-[:IN_GENRE]->(g:Genre) +WHERE m.year = 2000 AND g.name = ""Horror"" +RETURN d.name" +"MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WHERE p.name = 'Tom Hanks' +AND exists {(p)-[:DIRECTED]->(m)} +RETURN p.name, labels(p), m.title","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WHERE p.name = 'Tom Hanks' +AND exists {(p)-[:DIRECTED]->(m)} +RETURN p.name, labels(p), m.title" +"MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WHERE p.name = 'Tom Hanks' +AND exists {(p)<-[:DIRECTED]-(m)} +RETURN p.name, labels(p), m.title","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WHERE p.name = 'Tom Hanks' +AND exists {(p)-[:DIRECTED]->(m)} +RETURN p.name, labels(p), m.title" +"MATCH (a:Person)-[:ACTED_IN]->(m:Movie) +WHERE m.year > 2000 +MATCH (m)<-[:DIRECTED]-(d:Person) +RETURN a.name, m.title, d.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (a:Person)-[:ACTED_IN]->(m:Movie) +WHERE m.year > 2000 +MATCH (m)<-[:DIRECTED]-(d:Person) +RETURN a.name, m.title, d.name" +"MATCH (a:Person)-[:ACTED_IN]-(m:Movie) +WHERE m.year > 2000 +MATCH (m)-[:DIRECTED]->(d:Person) +RETURN a.name, m.title, d.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (a:Person)-[:ACTED_IN]-(m:Movie) +WHERE m.year > 2000 +MATCH (m)<-[:DIRECTED]-(d:Person) +RETURN a.name, m.title, d.name" +"MATCH (m:Movie) WHERE m.title = ""Kiss Me Deadly"" +MATCH (m)-[:IN_GENRE]-(g:Genre)-[:IN_GENRE]->(rec:Movie) +MATCH (m)-[:ACTED_IN]->(a:Person)-[:ACTED_IN]-(rec) +RETURN rec.title, a.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (m:Movie) WHERE m.title = ""Kiss Me Deadly"" +MATCH (m)-[:IN_GENRE]-(g:Genre)<-[:IN_GENRE]-(rec:Movie) +MATCH (m)<-[:ACTED_IN]-(a:Person)-[:ACTED_IN]-(rec) +RETURN rec.title, a.name" +"MATCH (p:Person)-[:ACTED_IN]->(m:Movie), +(coActors:Person)-[:ACTED_IN]->(m) +WHERE p.name = 'Eminem' +RETURN m.title AS movie ,collect(coActors.name) AS coActors","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN]->(m:Movie), +(coActors:Person)-[:ACTED_IN]->(m) +WHERE p.name = 'Eminem' +RETURN m.title AS movie ,collect(coActors.name) AS coActors" +"MATCH (p:Person)<-[:ACTED_IN]-(m:Movie), +(coActors:Person)-[:ACTED_IN]->(m) +WHERE p.name = 'Eminem' +RETURN m.title AS movie ,collect(coActors.name) AS coActors","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN]->(m:Movie), +(coActors:Person)-[:ACTED_IN]->(m) +WHERE p.name = 'Eminem' +RETURN m.title AS movie ,collect(coActors.name) AS coActors" +"MATCH p = ((person:Person)<-[]-(movie:Movie)) +WHERE person.name = 'Walt Disney' +RETURN p","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH p = ((person:Person)-[]->(movie:Movie)) +WHERE person.name = 'Walt Disney' +RETURN p" +"MATCH p = ((person:Person)<-[:DIRECTED]-(movie:Movie)) +WHERE person.name = 'Walt Disney' +RETURN p","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH p = ((person:Person)-[:DIRECTED]->(movie:Movie)) +WHERE person.name = 'Walt Disney' +RETURN p" +"MATCH p = shortestPath((p1:Person)-[*]-(p2:Person)) +WHERE p1.name = ""Eminem"" +AND p2.name = ""Charlton Heston"" +RETURN p","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH p = shortestPath((p1:Person)-[*]-(p2:Person)) +WHERE p1.name = ""Eminem"" +AND p2.name = ""Charlton Heston"" +RETURN p" +"MATCH p = ((person:Person)-[:DIRECTED*]->(:Person)) +WHERE person.name = 'Walt Disney' +RETURN p","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH p = ((person:Person)-[:DIRECTED*]->(:Person)) +WHERE person.name = 'Walt Disney' +RETURN p" +"MATCH p = ((person:Person)-[:DIRECTED*1..4]->(:Person)) +WHERE person.name = 'Walt Disney' +RETURN p","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH p = ((person:Person)-[:DIRECTED*1..4]->(:Person)) +WHERE person.name = 'Walt Disney' +RETURN p" +"MATCH (p:Person {name: 'Eminem'})-[:ACTED_IN*2]-(others:Person) +RETURN others.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person {name: 'Eminem'})-[:ACTED_IN*2]-(others:Person) +RETURN others.name" +"MATCH (u:User {name: ""Misty Williams""})-[r:RATED]->(:Movie) +WITH u, avg(r.rating) AS average +MATCH (u)-[r:RATED]->(m:Movie) +WHERE r.rating > average +RETURN average , m.title AS movie, +r.rating as rating +ORDER BY rating DESC","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie), (User, RATED, Movie)","MATCH (u:User {name: ""Misty Williams""})-[r:RATED]->(:Movie) +WITH u, avg(r.rating) AS average +MATCH (u)-[r:RATED]->(m:Movie) +WHERE r.rating > average +RETURN average , m.title AS movie, +r.rating as rating +ORDER BY rating DESC" +"MATCH (u:User {name: ""Misty Williams""})-[r:RATED]->(:Movie) +WITH u, avg(r.rating) AS average +MATCH (u)<-[r:RATED]-(m:Movie) +WHERE r.rating > average +RETURN average , m.title AS movie, +r.rating as rating +ORDER BY rating DESC","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie), (User, RATED, Movie)","MATCH (u:User {name: ""Misty Williams""})-[r:RATED]->(:Movie) +WITH u, avg(r.rating) AS average +MATCH (u)-[r:RATED]->(m:Movie) +WHERE r.rating > average +RETURN average , m.title AS movie, +r.rating as rating +ORDER BY rating DESC" +"MATCH (p:`Person`) +WHERE p.born.year = 1980 +WITH p LIMIT 3 +MATCH (p)<-[:ACTED_IN]-(m:Movie) +WITH p, collect(m.title) AS movies +RETURN p.name AS actor, movies","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:`Person`) +WHERE p.born.year = 1980 +WITH p LIMIT 3 +MATCH (p)-[:ACTED_IN]->(m:Movie) +WITH p, collect(m.title) AS movies +RETURN p.name AS actor, movies" +"MATCH (p:Person) +WHERE p.born.year = 1980 +WITH p LIMIT 3 +MATCH (p)-[:ACTED_IN]->(m:Movie)<-[:IN_GENRE]-(g) +WITH p, collect(DISTINCT g.name) AS genres +RETURN p.name AS actor, genres","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person) +WHERE p.born.year = 1980 +WITH p LIMIT 3 +MATCH (p)-[:ACTED_IN]->(m:Movie)-[:IN_GENRE]->(g) +WITH p, collect(DISTINCT g.name) AS genres +RETURN p.name AS actor, genres" +"CALL { + MATCH (m:Movie) WHERE m.year = 2000 + RETURN m ORDER BY m.imdbRating DESC LIMIT 10 +} +MATCH (:User)-[r:RATED]->(m) +RETURN m.title, avg(r.rating)","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (User, RATED, Movie)","CALL { + MATCH (m:Movie) WHERE m.year = 2000 + RETURN m ORDER BY m.imdbRating DESC LIMIT 10 +} +MATCH (:User)-[r:RATED]->(m) +RETURN m.title, avg(r.rating)" +"CALL { + MATCH (m:Movie) WHERE m.year = 2000 + RETURN m ORDER BY m.imdbRating DESC LIMIT 10 +} +MATCH (:User)<-[r:RATED]-(m) +RETURN m.title, avg(r.rating)","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (User, RATED, Movie)","CALL { + MATCH (m:Movie) WHERE m.year = 2000 + RETURN m ORDER BY m.imdbRating DESC LIMIT 10 +} +MATCH (:User)-[r:RATED]->(m) +RETURN m.title, avg(r.rating)" +"MATCH (m:Movie) +CALL { + WITH m + MATCH (m)-[r:RATED]->(u) + WHERE r.rating = 5 + RETURN count(u) AS numReviews +} +RETURN m.title, numReviews +ORDER BY numReviews DESC","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (m:Movie) +CALL { + WITH m + MATCH (m)<-[r:RATED]-(u) + WHERE r.rating = 5 + RETURN count(u) AS numReviews +} +RETURN m.title, numReviews +ORDER BY numReviews DESC" +"MATCH (p:Person) +WITH p LIMIT 100 +CALL { + WITH p + OPTIONAL MATCH (p)<-[:ACTED_IN]-(m) + RETURN m.title + "": "" + ""Actor"" AS work +UNION + WITH p + OPTIONAL MATCH (p)-[:DIRECTED]->(m:Movie) + RETURN m.title+ "": "" + ""Director"" AS work +} +RETURN p.name, collect(work)","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person) +WITH p LIMIT 100 +CALL { + WITH p + OPTIONAL MATCH (p)-[:ACTED_IN]->(m) + RETURN m.title + "": "" + ""Actor"" AS work +UNION + WITH p + OPTIONAL MATCH (p)-[:DIRECTED]->(m:Movie) + RETURN m.title+ "": "" + ""Director"" AS work +} +RETURN p.name, collect(work)" +"MATCH (p:Person)<-[:ACTED_IN {role:""Neo""}]-(m:Movie) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p, m","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN {role:""Neo""}]->(m:Movie) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p, m" +"MATCH (p:Person)<-[:ACTED_IN {role:""Neo""}]-(m) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN {role:""Neo""}]->(m) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p" +"MATCH (p:Person)-[:ACTED_IN {role:""Neo""}]->(m:Movie) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p, m","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:ACTED_IN {role:""Neo""}]->(m:Movie) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p, m" +"MATCH (wallstreet:Movie {title: 'Wall Street'})-[:ACTED_IN {role:""Foo""}]->(actor) +RETURN actor.name","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (wallstreet:Movie {title: 'Wall Street'})<-[:ACTED_IN {role:""Foo""}]-(actor) +RETURN actor.name" +"MATCH (p:Person)<-[:`ACTED_IN` {role:""Neo""}]-(m:Movie) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p, m","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:`ACTED_IN` {role:""Neo""}]->(m:Movie) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p, m" +"MATCH (p:`Person`)<-[:`ACTED_IN` {role:""Neo""}]-(m:Movie) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p, m","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:`Person`)-[:`ACTED_IN` {role:""Neo""}]->(m:Movie) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p, m" +"MATCH (p:`Person`)<-[:`ACTED_IN` {role:""Neo""}]-(m) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p, m","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:`Person`)-[:`ACTED_IN` {role:""Neo""}]->(m) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p, m" +"MATCH (p:Person)<-[:!DIRECTED]-(:Movie) RETURN p, count(*)","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:!DIRECTED]->(:Movie) RETURN p, count(*)" +"MATCH (p:Person)<-[:`ACTED_IN`|`DIRECTED`]-(m:Movie) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p, m","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH (p:Person)-[:`ACTED_IN`|`DIRECTED`]->(m:Movie) +WHERE p.name = $actorName +AND m.title = $movieName +RETURN p, m" +"MATCH (a:Person:Actor)-[:ACTED_IN]->(:Movie) +RETURN a, count(*)","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie), (Actor, ACTED_IN, Movie)","MATCH (a:Person:Actor)-[:ACTED_IN]->(:Movie) +RETURN a, count(*)" +"MATCH (a:Person:Actor)<-[:ACTED_IN]-(:Movie) +RETURN a, count(*)","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie), (Actor, ACTED_IN, Movie)","MATCH (a:Person:Actor)-[:ACTED_IN]->(:Movie) +RETURN a, count(*)" +"MATCH (a:Person:Actor)<-[:ACTED_IN]-() +RETURN a, count(*)","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie), (Actor, ACTED_IN, Movie)","MATCH (a:Person:Actor)-[:ACTED_IN]->() +RETURN a, count(*)" +"MATCH (a:Person:Actor) +RETURN a, [(a)<-[:`ACTED_IN`]-(m) | m.title] AS movies","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie), (Actor, ACTED_IN, Movie)","MATCH (a:Person:Actor) +RETURN a, [(a)-[:`ACTED_IN`]->(m) | m.title] AS movies" +"MATCH (a:Person:Actor) +RETURN a, [(a)-[:`ACTED_IN`]->(m) | m.title] AS movies","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie), (Actor, ACTED_IN, Movie)","MATCH (a:Person:Actor) +RETURN a, [(a)-[:`ACTED_IN`]->(m) | m.title] AS movies" +"MATCH p = ((person:Person)-[:DIRECTED*]->(:Movie)) RETURN p +","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","MATCH p = ((person:Person)-[:DIRECTED*]->(:Movie)) RETURN p +" +"""MATCH p = ((person:Person)-[:DIRECTED*1..3]->(:Movie)) RETURN p""","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","""MATCH p = ((person:Person)-[:DIRECTED*1..3]->(:Movie)) RETURN p""" +"""MATCH p = ((person:Person)-[:DIRECTED*..3]->(:Movie)) RETURN p""","(Person, FOLLOWS, Person), (Person, ACTED_IN, Movie), (Person, REVIEWED, Movie), (Person, WROTE, Movie), (Person, DIRECTED, Movie), (Movie, IN_GENRE, Genre), (Person, RATED, Movie)","""MATCH p = ((person:Person)-[:DIRECTED*..3]->(:Movie)) RETURN p""" +"MATCH (p:Person {name:""Emil Eifrem""})-[:HAS_CEO]-(o:Organization)<-[:MENTIONS]-(a:Article)-[:HAS_CHUNK]->(c) +RETURN o.name AS company, a.title AS title, c.text AS text, a.date AS date +ORDER BY date DESC LIMIT 3 +","(Person, HAS_CEO, Organization), (Article, MENTIONS, Organization), (Article, HAS_CHUNK, Chunk), (Organization, HAS_COMPETITOR, Organization), (Organization, HAS_SUBSIDIARY, Organization)","MATCH (p:Person {name:""Emil Eifrem""})-[:HAS_CEO]-(o:Organization)<-[:MENTIONS]-(a:Article)-[:HAS_CHUNK]->(c) +RETURN o.name AS company, a.title AS title, c.text AS text, a.date AS date +ORDER BY date DESC LIMIT 3 +" +"MATCH (p:Person {name:""Emil Eifrem""})-[:HAS_CEO]->(o:Organization)<-[:MENTIONS]-(a:Article)-[:HAS_CHUNK]->(c) +RETURN o.name AS company, a.title AS title, c.text AS text, a.date AS date +ORDER BY date DESC LIMIT 3 +","(Organization, HAS_CEO, Person), (Article, MENTIONS, Organization), (Article, HAS_CHUNK, Chunk), (Organization, HAS_COMPETITOR, Organization), (Organization, HAS_SUBSIDIARY, Organization)","MATCH (p:Person {name:""Emil Eifrem""})<-[:HAS_CEO]-(o:Organization)<-[:MENTIONS]-(a:Article)-[:HAS_CHUNK]->(c) +RETURN o.name AS company, a.title AS title, c.text AS text, a.date AS date +ORDER BY date DESC LIMIT 3 +" +"MATCH (o:Organization {name: ""Databricks""})-[:HAS_COMPETITOR]->(c:Organization) +RETURN c.name as Competitor","(Organization, HAS_CEO, Person), (Article, MENTIONS, Organization), (Article, HAS_CHUNK, Chunk), (Organization, HAS_COMPETITOR, Organization), (Organization, HAS_SUBSIDIARY, Organization)","MATCH (o:Organization {name: ""Databricks""})-[:HAS_COMPETITOR]->(c:Organization) +RETURN c.name as Competitor" +"MATCH (o:Organization {name: ""Databricks""})<-[:HAS_COMPETITOR]-(c:Organization) +RETURN c.name as Competitor","(Organization, HAS_CEO, Person), (Article, MENTIONS, Organization), (Article, HAS_CHUNK, Chunk), (Organization, HAS_COMPETITOR, Organization), (Organization, HAS_SUBSIDIARY, Organization)","MATCH (o:Organization {name: ""Databricks""})<-[:HAS_COMPETITOR]-(c:Organization) +RETURN c.name as Competitor" +"MATCH p=(o:Organization {name:""Blackstone""})-[:HAS_SUBSIDIARY*]->(t) +WHERE NOT EXISTS {(t)-[:HAS_SUBSIDIARY]->()} +RETURN max(length(p)) AS max","(Organization, HAS_CEO, Person), (Article, MENTIONS, Organization), (Article, HAS_CHUNK, Chunk), (Organization, HAS_COMPETITOR, Organization), (Organization, HAS_SUBSIDIARY, Organization)","MATCH p=(o:Organization {name:""Blackstone""})-[:HAS_SUBSIDIARY*]->(t) +WHERE NOT EXISTS {(t)-[:HAS_SUBSIDIARY]->()} +RETURN max(length(p)) AS max" +"MATCH p=(o:Organization {name:""Blackstone""})-[:HAS_SUBSIDIARY*]-(t) +WHERE NOT EXISTS {(t)-[:HAS_SUBSIDIARY]->()} +RETURN max(length(p)) AS max","(Organization, HAS_CEO, Person), (Article, MENTIONS, Organization), (Article, HAS_CHUNK, Chunk), (Organization, HAS_COMPETITOR, Organization), (Organization, HAS_SUBSIDIARY, Organization)","MATCH p=(o:Organization {name:""Blackstone""})-[:HAS_SUBSIDIARY*]-(t) +WHERE NOT EXISTS {(t)-[:HAS_SUBSIDIARY]->()} +RETURN max(length(p)) AS max" +"MATCH p=(o:Organization {name:""Blackstone""})-[:HAS_SUBSIDIARY*]-(t:Person) +WHERE NOT EXISTS {(o)-[:HAS_SUBSIDIARY]->()} +RETURN max(length(p)) AS max","(Organization, HAS_CEO, Person), (Article, MENTIONS, Organization), (Article, HAS_CHUNK, Chunk), (Organization, HAS_COMPETITOR, Organization), (Organization, HAS_SUBSIDIARY, Organization)","MATCH p=(o:Organization {name:""Blackstone""})-[:HAS_SUBSIDIARY*]-(t:Person) +WHERE NOT EXISTS {(o)-[:HAS_SUBSIDIARY]->()} +RETURN max(length(p)) AS max" +"CALL apoc.ml.openai.embedding([""Are there any news regarding employee satisfaction?""], $openai_api_key) YIELD embedding +CALL db.index.vector.queryNodes(""news"", 3, embedding) YIELD node,score +RETURN node.text AS text, score","(Organization, HAS_CEO, Person), (Article, MENTIONS, Organization), (Article, HAS_CHUNK, Chunk), (Organization, HAS_COMPETITOR, Organization), (Organization, HAS_SUBSIDIARY, Organization)","CALL apoc.ml.openai.embedding([""Are there any news regarding employee satisfaction?""], $openai_api_key) YIELD embedding +CALL db.index.vector.queryNodes(""news"", 3, embedding) YIELD node,score +RETURN node.text AS text, score" +"MATCH (o:Organization {name:""Neo4j""})<-[:MENTIONS]-(a:Article)-[:HAS_CHUNK]->(c) +WHERE toLower(c.text) CONTAINS 'partnership' +RETURN a.title AS title, c.text AS text, a.date AS date +ORDER BY date DESC LIMIT 3","(Organization, HAS_CEO, Person), (Article, MENTIONS, Organization), (Article, HAS_CHUNK, Chunk), (Organization, HAS_COMPETITOR, Organization), (Organization, HAS_SUBSIDIARY, Organization)","MATCH (o:Organization {name:""Neo4j""})<-[:MENTIONS]-(a:Article)-[:HAS_CHUNK]->(c) +WHERE toLower(c.text) CONTAINS 'partnership' +RETURN a.title AS title, c.text AS text, a.date AS date +ORDER BY date DESC LIMIT 3" +"MATCH (n:`Some Label`)-[:`SOME REL TYPE üäß`]->(m:`Sömé Øther Læbel`) RETURN n,m","(Some Label, SOME REL TYPE üäß, Sömé Øther Læbel)","MATCH (n:`Some Label`)-[:`SOME REL TYPE üäß`]->(m:`Sömé Øther Læbel`) RETURN n,m" +"MATCH (n:`Some Label`)<-[:`SOME REL TYPE üäß`]-(m:`Sömé Øther Læbel`) RETURN n,m","(Some Label, SOME REL TYPE üäß, Sömé Øther Læbel)","MATCH (n:`Some Label`)-[:`SOME REL TYPE üäß`]->(m:`Sömé Øther Læbel`) RETURN n,m" +"MATCH (a:Actor {name: 'Tom Hanks'})-[:ACTED_IN]->(m:Movie) RETURN count(m)","(Movie, IN_GENRE, Genre), (User, RATED, Movie), (Actor, ACTED_IN, Movie), (Actor, DIRECTED, Movie), (Director, DIRECTED, Movie), (Director, ACTED_IN, Movie), (Person, ACTED_IN, Movie), (Person, DIRECTED, Movie)","MATCH (a:Actor {name: 'Tom Hanks'})-[:ACTED_IN]->(m:Movie) RETURN count(m)" +"MATCH (a:Actor)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g1:Genre), (a)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g2:Genre) WHERE g1.name = 'Comedy' AND g2.name = 'Action' RETURN DISTINCT a.name","(Movie, IN_GENRE, Genre), (User, RATED, Movie), (Actor, ACTED_IN, Movie), (Actor, DIRECTED, Movie), (Director, DIRECTED, Movie), (Director, ACTED_IN, Movie), (Person, ACTED_IN, Movie), (Person, DIRECTED, Movie)","MATCH (a:Actor)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g1:Genre), (a)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g2:Genre) WHERE g1.name = 'Comedy' AND g2.name = 'Action' RETURN DISTINCT a.name" +"MATCH (a:Actor)-[:ACTED_IN]->(m:Movie) RETURN a.name, COUNT(m) AS movieCount ORDER BY movieCount DESC LIMIT 1","(Movie, IN_GENRE, Genre), (User, RATED, Movie), (Actor, ACTED_IN, Movie), (Actor, DIRECTED, Movie), (Director, DIRECTED, Movie), (Director, ACTED_IN, Movie), (Person, ACTED_IN, Movie), (Person, DIRECTED, Movie)","MATCH (a:Actor)-[:ACTED_IN]->(m:Movie) RETURN a.name, COUNT(m) AS movieCount ORDER BY movieCount DESC LIMIT 1" +"MATCH (g:Genre)<-[:IN_GENRE]-(m:Movie) RETURN g.name, COUNT(m) AS movieCount","(Movie, IN_GENRE, Genre), (User, RATED, Movie), (Actor, ACTED_IN, Movie), (Actor, DIRECTED, Movie), (Director, DIRECTED, Movie), (Director, ACTED_IN, Movie), (Person, ACTED_IN, Movie), (Person, DIRECTED, Movie)","MATCH (g:Genre)<-[:IN_GENRE]-(m:Movie) RETURN g.name, COUNT(m) AS movieCount" diff --git a/libs/neo4j/tests/unit_tests/graphs/__init__.py b/libs/neo4j/tests/unit_tests/graphs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/tests/unit_tests/graphs/test_neo4j_graph.py b/libs/neo4j/tests/unit_tests/graphs/test_neo4j_graph.py new file mode 100644 index 0000000..12daf57 --- /dev/null +++ b/libs/neo4j/tests/unit_tests/graphs/test_neo4j_graph.py @@ -0,0 +1,41 @@ +from langchain_neo4j.graphs.neo4j_graph import value_sanitize + + +def test_value_sanitize_with_small_list(): # type: ignore[no-untyped-def] + small_list = list(range(15)) # list size > LIST_LIMIT + input_dict = {"key1": "value1", "small_list": small_list} + expected_output = {"key1": "value1", "small_list": small_list} + assert value_sanitize(input_dict) == expected_output + + +def test_value_sanitize_with_oversized_list(): # type: ignore[no-untyped-def] + oversized_list = list(range(150)) # list size > LIST_LIMIT + input_dict = {"key1": "value1", "oversized_list": oversized_list} + expected_output = { + "key1": "value1" + # oversized_list should not be included + } + assert value_sanitize(input_dict) == expected_output + + +def test_value_sanitize_with_nested_oversized_list(): # type: ignore[no-untyped-def] + oversized_list = list(range(150)) # list size > LIST_LIMIT + input_dict = {"key1": "value1", "oversized_list": {"key": oversized_list}} + expected_output = {"key1": "value1", "oversized_list": {}} + assert value_sanitize(input_dict) == expected_output + + +def test_value_sanitize_with_dict_in_list(): # type: ignore[no-untyped-def] + oversized_list = list(range(150)) # list size > LIST_LIMIT + input_dict = {"key1": "value1", "oversized_list": [1, 2, {"key": oversized_list}]} + expected_output = {"key1": "value1", "oversized_list": [1, 2, {}]} + assert value_sanitize(input_dict) == expected_output + + +def test_value_sanitize_with_dict_in_nested_list(): # type: ignore[no-untyped-def] + input_dict = { + "key1": "value1", + "deeply_nested_lists": [[[[{"final_nested_key": list(range(200))}]]]], + } + expected_output = {"key1": "value1", "deeply_nested_lists": [[[[{}]]]]} + assert value_sanitize(input_dict) == expected_output diff --git a/libs/neo4j/tests/unit_tests/llms/__init__.py b/libs/neo4j/tests/unit_tests/llms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/tests/unit_tests/llms/fake_llm.py b/libs/neo4j/tests/unit_tests/llms/fake_llm.py new file mode 100644 index 0000000..a8e9eb6 --- /dev/null +++ b/libs/neo4j/tests/unit_tests/llms/fake_llm.py @@ -0,0 +1,61 @@ +"""Fake LLM wrapper for testing purposes.""" + +from typing import Any, Dict, List, Mapping, Optional, cast + +from langchain_core.callbacks import CallbackManagerForLLMRun +from langchain_core.language_models.llms import LLM +from pydantic import validator + + +class FakeLLM(LLM): + """Fake LLM wrapper for testing purposes.""" + + queries: Optional[Mapping] = None + sequential_responses: Optional[bool] = False + response_index: int = 0 + + @validator("queries", always=True) + def check_queries_required( + cls, queries: Optional[Mapping], values: Mapping[str, Any] + ) -> Optional[Mapping]: + if values.get("sequential_response") and not queries: + raise ValueError( + "queries is required when sequential_response is set to True" + ) + return queries + + def get_num_tokens(self, text: str) -> int: + """Return number of tokens.""" + return len(text.split()) + + @property + def _llm_type(self) -> str: + """Return type of llm.""" + return "fake" + + def _call( + self, + prompt: str, + stop: Optional[List[str]] = None, + run_manager: Optional[CallbackManagerForLLMRun] = None, + **kwargs: Any, + ) -> str: + if self.sequential_responses: + return self._get_next_response_in_sequence + if self.queries is not None: + return self.queries[prompt] + if stop is None: + return "foo" + else: + return "bar" + + @property + def _identifying_params(self) -> Dict[str, Any]: + return {} + + @property + def _get_next_response_in_sequence(self) -> str: + queries = cast(Mapping, self.queries) + response = queries[list(queries.keys())[self.response_index]] + self.response_index = self.response_index + 1 + return response diff --git a/libs/neo4j/tests/unit_tests/query_constructors/__init__.py b/libs/neo4j/tests/unit_tests/query_constructors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/tests/unit_tests/query_constructors/test_neo4j.py b/libs/neo4j/tests/unit_tests/query_constructors/test_neo4j.py new file mode 100644 index 0000000..eb0c95f --- /dev/null +++ b/libs/neo4j/tests/unit_tests/query_constructors/test_neo4j.py @@ -0,0 +1,90 @@ +from typing import Dict, Tuple + +from langchain_core.structured_query import ( + Comparator, + Comparison, + Operation, + Operator, + StructuredQuery, +) + +from langchain_neo4j.query_constructors.neo4j import Neo4jTranslator + +DEFAULT_TRANSLATOR = Neo4jTranslator() + + +def test_visit_comparison() -> None: + comp = Comparison(comparator=Comparator.LT, attribute="foo", value=["1", "2"]) + expected = {"foo": {"$lt": ["1", "2"]}} + actual = DEFAULT_TRANSLATOR.visit_comparison(comp) + assert expected == actual + + +def test_visit_operation() -> None: + op = Operation( + operator=Operator.AND, + arguments=[ + Comparison(comparator=Comparator.LT, attribute="foo", value=2), + Comparison(comparator=Comparator.EQ, attribute="bar", value="baz"), + Comparison(comparator=Comparator.LT, attribute="abc", value=["1", "2"]), + ], + ) + expected = { + "$and": [ + {"foo": {"$lt": 2}}, + {"bar": {"$eq": "baz"}}, + {"abc": {"$lt": ["1", "2"]}}, + ] + } + actual = DEFAULT_TRANSLATOR.visit_operation(op) + assert expected == actual + + +def test_visit_structured_query() -> None: + query = "What is the capital of France?" + structured_query = StructuredQuery( + query=query, + filter=None, + ) + expected: Tuple[str, Dict] = (query, {}) + actual = DEFAULT_TRANSLATOR.visit_structured_query(structured_query) + assert expected == actual + + comp = Comparison(comparator=Comparator.LT, attribute="foo", value=["1", "2"]) + expected = ( + query, + {"filter": {"foo": {"$lt": ["1", "2"]}}}, + ) + structured_query = StructuredQuery( + query=query, + filter=comp, + ) + actual = DEFAULT_TRANSLATOR.visit_structured_query(structured_query) + assert expected == actual + + op = Operation( + operator=Operator.AND, + arguments=[ + Comparison(comparator=Comparator.LT, attribute="foo", value=2), + Comparison(comparator=Comparator.EQ, attribute="bar", value="baz"), + Comparison(comparator=Comparator.LT, attribute="abc", value=["1", "2"]), + ], + ) + structured_query = StructuredQuery( + query=query, + filter=op, + ) + expected = ( + query, + { + "filter": { + "$and": [ + {"foo": {"$lt": 2}}, + {"bar": {"$eq": "baz"}}, + {"abc": {"$lt": ["1", "2"]}}, + ] + } + }, + ) + actual = DEFAULT_TRANSLATOR.visit_structured_query(structured_query) + assert expected == actual diff --git a/libs/neo4j/tests/unit_tests/test_imports.py b/libs/neo4j/tests/unit_tests/test_imports.py new file mode 100644 index 0000000..571e2d5 --- /dev/null +++ b/libs/neo4j/tests/unit_tests/test_imports.py @@ -0,0 +1,13 @@ +from langchain_neo4j import __all__ + +EXPECTED_ALL = [ + "GraphCypherQAChain", + "Neo4jChatMessageHistory", + "Neo4jGraph", + "Neo4jVector", + "__version__", +] + + +def test_all_imports() -> None: + assert sorted(EXPECTED_ALL) == sorted(__all__) diff --git a/libs/neo4j/tests/unit_tests/vectorstores/__init__.py b/libs/neo4j/tests/unit_tests/vectorstores/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/neo4j/tests/unit_tests/vectorstores/test_neo4j.py b/libs/neo4j/tests/unit_tests/vectorstores/test_neo4j.py new file mode 100644 index 0000000..72b371f --- /dev/null +++ b/libs/neo4j/tests/unit_tests/vectorstores/test_neo4j.py @@ -0,0 +1,67 @@ +"""Test Neo4j functionality.""" + +from langchain_neo4j.vectorstores.neo4j_vector import ( + dict_to_yaml_str, + remove_lucene_chars, +) + + +def test_escaping_lucene() -> None: + """Test escaping lucene characters""" + assert remove_lucene_chars("Hello+World") == "Hello World" + assert remove_lucene_chars("Hello World\\") == "Hello World" + assert ( + remove_lucene_chars("It is the end of the world. Take shelter!") + == "It is the end of the world. Take shelter" + ) + assert ( + remove_lucene_chars("It is the end of the world. Take shelter&&") + == "It is the end of the world. Take shelter" + ) + assert ( + remove_lucene_chars("Bill&&Melinda Gates Foundation") + == "Bill Melinda Gates Foundation" + ) + assert ( + remove_lucene_chars("It is the end of the world. Take shelter(&&)") + == "It is the end of the world. Take shelter" + ) + assert ( + remove_lucene_chars("It is the end of the world. Take shelter??") + == "It is the end of the world. Take shelter" + ) + assert ( + remove_lucene_chars("It is the end of the world. Take shelter^") + == "It is the end of the world. Take shelter" + ) + assert ( + remove_lucene_chars("It is the end of the world. Take shelter+") + == "It is the end of the world. Take shelter" + ) + assert ( + remove_lucene_chars("It is the end of the world. Take shelter-") + == "It is the end of the world. Take shelter" + ) + assert ( + remove_lucene_chars("It is the end of the world. Take shelter~") + == "It is the end of the world. Take shelter" + ) + + +def test_converting_to_yaml() -> None: + example_dict = { + "name": "John Doe", + "age": 30, + "skills": ["Python", "Data Analysis", "Machine Learning"], + "location": {"city": "Ljubljana", "country": "Slovenia"}, + } + + yaml_str = dict_to_yaml_str(example_dict) + + expected_output = ( + "name: John Doe\nage: 30\nskills:\n- Python\n- " + "Data Analysis\n- Machine Learning\nlocation:\n city: Ljubljana\n" + " country: Slovenia\n" + ) + + assert yaml_str == expected_output