diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e47a17 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copy to .env and adjust. .env is ignored by git. + +# --- Neo4j container --- +NEO4J_USER=neo4j +NEO4J_PASSWORD=change-me-please +NEO4J_HTTP_PORT=7474 +NEO4J_BOLT_PORT=7687 + +# --- Backend connection to Neo4j --- +NEO4J_URI=bolt://localhost:7687 diff --git a/README.md b/README.md index 88b87ac..9dd48bf 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,29 @@ OVERLAPS Future versions may explore image embeddings, vector search, semantic relationships, and hybrid visual search. +## Local development + +### Neo4j + +Requirements: Docker with Compose, and [uv](https://docs.astral.sh/uv/) for the backend. + +```bash +cp .env.example .env # then set NEO4J_PASSWORD (.env is not versioned) +docker compose up -d --wait # starts Neo4j and waits until it is healthy +``` + +* Neo4j Browser: (Connect URL `bolt://localhost:7687`, user/password from `.env`) +* Data and logs are stored in `./neo4j/` (git-ignored). Stop with `docker compose down`; + reset the database with `docker compose down` followed by deleting `./neo4j/data`. + +Verify the backend can connect (needs the container running): + +```bash +cd backend +uv sync +uv run pytest -m integration +``` + ## License This project is licensed under the MIT License. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 925695b..8d9d142 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.30", "pydantic-settings>=2.4", + "neo4j>=5.26", ] [project.scripts] @@ -47,4 +48,4 @@ select = ["E", "F", "I", "UP"] [tool.mypy] files = ["src"] -strict = true \ No newline at end of file +strict = true diff --git a/backend/src/visual_graph/config.py b/backend/src/visual_graph/config.py index b82b035..cde8a34 100644 --- a/backend/src/visual_graph/config.py +++ b/backend/src/visual_graph/config.py @@ -1,17 +1,22 @@ from functools import lru_cache +from pydantic import SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", extra="ignore") + model_config = SettingsConfigDict(env_file=("../.env", ".env"), extra="ignore") app_name: str = "VisualGraph" api_host: str = "127.0.0.1" api_port: int = 8000 api_reload: bool = False + neo4j_uri: str = "bolt://localhost:7687" + neo4j_user: str = "neo4j" + neo4j_password: SecretStr | None = None + @lru_cache def get_settings() -> Settings: - return Settings() \ No newline at end of file + return Settings() diff --git a/backend/src/visual_graph/graph/connection.py b/backend/src/visual_graph/graph/connection.py new file mode 100644 index 0000000..3c7500a --- /dev/null +++ b/backend/src/visual_graph/graph/connection.py @@ -0,0 +1,30 @@ +"""Neo4j driver factory.""" + +from neo4j import Driver, GraphDatabase + +from visual_graph.config import Settings, get_settings + + +class MissingCredentialsError(RuntimeError): + """Raised when NEO4J_PASSWORD is not configured.""" + + +def create_driver(settings: Settings | None = None) -> Driver: + settings = settings or get_settings() + if settings.neo4j_password is None: + raise MissingCredentialsError( + "NEO4J_PASSWORD is not set. Copy .env.example to .env and set it." + ) + return GraphDatabase.driver( + settings.neo4j_uri, + auth=(settings.neo4j_user, settings.neo4j_password.get_secret_value()), + ) + + +def verify_connection(driver: Driver) -> None: + """Raise if the database is unreachable or credentials are wrong.""" + driver.verify_connectivity() + with driver.session() as session: + record = session.run("RETURN 1 AS ok").single() + if record is None or record["ok"] != 1: + raise RuntimeError("Unexpected response from Neo4j") diff --git a/backend/tests/integration/test_neo4j_connection.py b/backend/tests/integration/test_neo4j_connection.py new file mode 100644 index 0000000..d1a8c9b --- /dev/null +++ b/backend/tests/integration/test_neo4j_connection.py @@ -0,0 +1,13 @@ +import pytest + +from visual_graph.graph.connection import create_driver, verify_connection + +pytestmark = pytest.mark.integration + + +def test_backend_can_connect_to_neo4j() -> None: + driver = create_driver() + try: + verify_connection(driver) + finally: + driver.close() diff --git a/backend/tests/unit/test_connection.py b/backend/tests/unit/test_connection.py new file mode 100644 index 0000000..2315f48 --- /dev/null +++ b/backend/tests/unit/test_connection.py @@ -0,0 +1,10 @@ +import pytest + +from visual_graph.config import Settings +from visual_graph.graph.connection import MissingCredentialsError, create_driver + + +def test_missing_password_raises() -> None: + settings = Settings(_env_file=None, neo4j_password=None) # type: ignore[call-arg] + with pytest.raises(MissingCredentialsError): + create_driver(settings) diff --git a/backend/uv.lock b/backend/uv.lock index 34ef81e..fa3ab9c 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -575,6 +575,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "neo4j" +version = "6.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/d1/bc40a291c60b06738ce92d5f0259d348eee1f07ba10d569a881c848c8afe/neo4j-6.4.1.tar.gz", hash = "sha256:fac99b7cb9679b93ef97dc23f22a3f0e7c3feb2db223eddfd2521f27005cfeaf", size = 287897, upload-time = "2026-10-15T10:47:26.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/69/3036bdcacfba561c6ef4b00fe72529e0a1b6241a6b2de82572a0940a276b/neo4j-6.4.1-py3-none-any.whl", hash = "sha256:f56a281c74355522fc2642df8919bec450115431531b8f3f6fe1cc17acb57a27", size = 390684, upload-time = "2026-10-15T10:47:25.236Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.45.0" @@ -766,6 +778,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "pytz" +version = "2026.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/b0/168030b0bfcc334cf09fa3313afdb888d892c8a0027324683d1de0ec277e/pytz-2026.6.tar.gz", hash = "sha256:3b0fc2727707663d6e517936f444fd8a8b240f1fc32f4bf7ee03274245945284", size = 318520, upload-time = "2026-10-29T09:05:47.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/25/564f3fc5b70c1398d706c51f2b1470d0ea0b1b2f96fee67877b6f5bdf3b6/pytz-2026.6-py2.py3-none-any.whl", hash = "sha256:7fed3fee8fd6bd4a2b7b75a8d56021d0624127bf69bd5691f115db929ac570b3", size = 506321, upload-time = "2026-10-29T09:05:45.14Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -941,6 +962,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, + { name = "neo4j" }, { name = "pydantic-settings" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -958,6 +980,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, + { name = "neo4j", specifier = ">=5.26" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, ] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..18054bd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + neo4j: + image: neo4j:5.26-community + container_name: visual-graph-neo4j + restart: unless-stopped + ports: + - "${NEO4J_HTTP_PORT:-7474}:7474" # Neo4j Browser + - "${NEO4J_BOLT_PORT:-7687}:7687" # Bolt + environment: + NEO4J_AUTH: "${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:?Set NEO4J_PASSWORD in .env (see .env.example)}" + volumes: + - ./neo4j/data:/data + - ./neo4j/logs:/logs + healthcheck: + test: ["CMD-SHELL", "cypher-shell -u \"$${NEO4J_USER:-neo4j}\" -p \"$${NEO4J_AUTH#*/}\" 'RETURN 1' || exit 1"] + interval: 10s + timeout: 10s + retries: 12 + start_period: 20s