This is an automated email from the ASF dual-hosted git repository.
imbajin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hugegraph-ai.git
The following commit(s) were added to refs/heads/main by this push:
new 3872443b refactor: enhance the tests frame & quality in core modules
(#357)
3872443b is described below
commit 3872443b073b1332bd8089c6acda8eda857028d8
Author: imbajin <[email protected]>
AuthorDate: Tue Jun 2 20:37:34 2026 +0800
refactor: enhance the tests frame & quality in core modules (#357)
- define the long-task quality program scope and guardrails
- split execution into client-first and llm-focused goals
- specify test layers, CI gate evolution, and coverage ratchet
- document checkpoints, deferred refactors, and PR collision rules
---
.github/workflows/auto-pr-comment.yml | 1 +
.github/workflows/hugegraph-llm.yml | 171 +++++++++-
.github/workflows/hugegraph-python-client.yml | 95 +++++-
docs/quality/coverage-ratchet.md | 66 ++++
docs/quality/hugegraph-integration.md | 20 ++
docs/quality/test-taxonomy.md | 32 ++
hugegraph-llm/src/hugegraph_llm/api/rag_api.py | 4 +-
.../src/hugegraph_llm/config/models/base_config.py | 28 +-
.../config/models/base_prompt_config.py | 47 ++-
.../hugegraph_llm/demo/rag_demo/configs_block.py | 3 +-
.../operators/hugegraph_op/commit_to_hugegraph.py | 16 +-
hugegraph-llm/src/tests/api/test_rag_api.py | 115 +++++++
hugegraph-llm/src/tests/config/test_config.py | 63 ++++
.../src/tests/config/test_prompt_config.py | 4 +
hugegraph-llm/src/tests/conftest.py | 62 +++-
.../data/quality_program/graphrag_documents.json | 4 +
.../data/quality_program/kg_graph_output.json | 14 +
.../src/tests/data/quality_program/kg_text.txt | 1 +
.../data/quality_program/text2gremlin_schema.json | 9 +
hugegraph-llm/src/tests/document/test_document.py | 4 +
.../src/tests/document/test_document_splitter.py | 4 +
.../src/tests/document/test_text_loader.py | 4 +
.../test_config.py => fixtures/__init__.py} | 13 -
.../test_config.py => fixtures/fake_llm.py} | 20 +-
.../src/tests/fixtures/hugegraph_service.py | 74 +++++
.../tests/integration/test_core_graphrag_smoke.py | 89 +++++
.../src/tests/integration/test_core_kg_smoke.py | 108 ++++++
.../integration/test_core_text2gremlin_smoke.py | 68 ++++
.../tests/integration/test_graph_rag_pipeline.py | 4 +
.../tests/integration/test_hugegraph_boundary.py | 187 +++++++++++
.../src/tests/integration/test_kg_construction.py | 4 +
.../src/tests/integration/test_rag_pipeline.py | 4 +
.../src/tests/middleware/test_middleware.py | 3 +
.../models/embeddings/test_ollama_embedding.py | 30 ++
.../src/tests/models/llms/test_litellm_client.py | 24 +-
.../src/tests/models/llms/test_openai_client.py | 16 +
.../hugegraph_op/test_commit_to_hugegraph.py | 254 +-------------
.../test_commit_to_hugegraph_load_into_graph.py | 366 +++++++++++++++++++++
.../hugegraph_op/test_fetch_graph_data.py | 4 +
.../operators/hugegraph_op/test_schema_manager.py | 4 +
.../operators/llm_op/test_disambiguate_data.py | 4 +
.../operators/llm_op/test_gremlin_generate.py | 29 ++
.../tests/operators/llm_op/test_info_extract.py | 4 +
.../tests/operators/llm_op/test_keyword_extract.py | 31 ++
.../llm_op/test_property_graph_extract.py | 126 +++++++
.../src/pyhugegraph/utils/util.py | 11 +-
hugegraph-python-client/src/tests/api/test_auth.py | 3 +
.../src/tests/api/test_auth_routing.py | 2 +
.../src/tests/api/test_graph.py | 15 +
.../src/tests/api/test_graphs.py | 4 +
.../src/tests/api/test_gremlin.py | 10 +
.../src/tests/api/test_metric.py | 4 +
.../src/tests/api/test_response_validation.py | 41 +++
.../src/tests/api/test_schema.py | 42 +++
hugegraph-python-client/src/tests/api/test_task.py | 3 +
.../src/tests/api/test_traverser.py | 4 +
.../src/tests/api/test_variable.py | 2 +
.../src/tests/api/test_version.py | 4 +
hugegraph-python-client/src/tests/client_utils.py | 16 +-
.../src/tests/conftest.py | 25 +-
.../src/tests/fixtures/__init__.py | 13 -
.../src/tests/fixtures/hugegraph_service.py | 75 +++++
pyproject.toml | 12 +
63 files changed, 2160 insertions(+), 359 deletions(-)
diff --git a/.github/workflows/auto-pr-comment.yml
b/.github/workflows/auto-pr-comment.yml
index 6a585355..f6561fed 100644
--- a/.github/workflows/auto-pr-comment.yml
+++ b/.github/workflows/auto-pr-comment.yml
@@ -28,6 +28,7 @@ jobs:
pull-requests: write
steps:
- name: Add review comment
+ # This workflow is only enabled in fork repositories to request AI
review.
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
diff --git a/.github/workflows/hugegraph-llm.yml
b/.github/workflows/hugegraph-llm.yml
index b813483c..f14bd567 100644
--- a/.github/workflows/hugegraph-llm.yml
+++ b/.github/workflows/hugegraph-llm.yml
@@ -26,8 +26,14 @@ on:
- 'release-*'
pull_request:
+permissions:
+ contents: read
+
+env:
+ UV_VERSION: "0.9.22"
+
jobs:
- build:
+ llm-unit-contract:
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -35,25 +41,23 @@ jobs:
python-version: ["3.10", "3.11"]
steps:
- - name: Prepare HugeGraph Server Environment
- run: |
- docker run -d --name=graph -p 8080:8080 -e PASSWORD=admin
hugegraph/hugegraph:1.5.0
- sleep 10
-
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
run: |
- curl -LsSf https://astral.sh/uv/install.sh | sh
- echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+ curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+ "$HOME/.local/bin/uv" --version
- name: Cache dependencies
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
~/.cache/uv
@@ -68,15 +72,148 @@ jobs:
uv run python -c "import nltk; nltk.download('stopwords');
nltk.download('punkt')"
- name: Run unit tests
- working-directory: hugegraph-llm
env:
SKIP_EXTERNAL_SERVICES: true
run: |
- uv run pytest src/tests/config/ src/tests/document/
src/tests/middleware/ src/tests/operators/ src/tests/models/ src/tests/indices/
src/tests/test_utils.py -v --tb=short
+ uv run pytest hugegraph-llm/src/tests -m "not integration and not
hugegraph and not smoke and not external" --cov=hugegraph_llm
--cov-fail-under=34 --cov-report=term --cov-report=xml:llm-unit-contract.xml -v
--tb=short --durations=20
+
+ - name: Upload coverage artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: llm-unit-contract-coverage-${{ matrix.python-version }}
+ path: llm-unit-contract.xml
+ if-no-files-found: error
+
+ llm-hugegraph-boundary:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.11"]
+
+ services:
+ hugegraph:
+ image: hugegraph/hugegraph:1.7.0
+ env:
+ PASSWORD: admin
+ options: --health-cmd="curl -f http://localhost:8080/versions || exit
1" --health-interval=10s --health-timeout=5s --health-retries=8
+ ports:
+ - 8080:8080
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- - name: Run integration tests
- working-directory: hugegraph-llm
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install uv
+ run: |
+ curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+ "$HOME/.local/bin/uv" --version
+
+ - name: Cache dependencies
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cache/uv
+ ~/nltk_data
+ key: ${{ runner.os }}-uv-${{ matrix.python-version }}-${{
hashFiles('**/pyproject.toml', 'uv.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-uv-${{ matrix.python-version }}-
+
+ - name: Install dependencies
+ run: |
+ uv sync --extra llm --extra dev
+ uv run python -c "import nltk; nltk.download('stopwords');
nltk.download('punkt')"
+
+ - name: Run HugeGraph boundary tests
env:
- SKIP_EXTERNAL_SERVICES: true
+ HUGEGRAPH_REQUIRED: true
+ HUGEGRAPH_URL: http://127.0.0.1:8080
+ HUGEGRAPH_GRAPH: hugegraph
+ HUGEGRAPH_USER: admin
+ HUGEGRAPH_PASSWORD: admin
+ SKIP_EXTERNAL_SERVICES: false
+ run: |
+ uv run pytest hugegraph-llm/src/tests -m "integration and hugegraph"
-v --tb=short
+
+ - name: Dump HugeGraph diagnostics
+ if: failure()
+ run: |
+ docker ps -a
+ container_id=$(docker ps -aq --filter
ancestor=hugegraph/hugegraph:1.7.0 | head -n 1)
+ if [ -n "$container_id" ]; then
+ docker logs "$container_id"
+ fi
+
+ llm-core-smoke:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.11"]
+
+ services:
+ hugegraph:
+ image: hugegraph/hugegraph:1.7.0
+ env:
+ PASSWORD: admin
+ options: --health-cmd="curl -f http://localhost:8080/versions || exit
1" --health-interval=10s --health-timeout=5s --health-retries=8
+ ports:
+ - 8080:8080
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install uv
+ run: |
+ curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+ "$HOME/.local/bin/uv" --version
+
+ - name: Cache dependencies
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cache/uv
+ ~/nltk_data
+ key: ${{ runner.os }}-uv-${{ matrix.python-version }}-${{
hashFiles('**/pyproject.toml', 'uv.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-uv-${{ matrix.python-version }}-
+
+ - name: Install dependencies
+ run: |
+ uv sync --extra llm --extra dev
+ uv run python -c "import nltk; nltk.download('stopwords');
nltk.download('punkt')"
+
+ - name: Run deterministic core smoke tests
+ env:
+ HUGEGRAPH_REQUIRED: true
+ HUGEGRAPH_URL: http://127.0.0.1:8080
+ HUGEGRAPH_GRAPH: hugegraph
+ HUGEGRAPH_USER: admin
+ HUGEGRAPH_PASSWORD: admin
+ SKIP_EXTERNAL_SERVICES: false
+ run: |
+ uv run pytest hugegraph-llm/src/tests/integration -m "smoke" -v
--tb=short
+
+ - name: Dump HugeGraph diagnostics
+ if: failure()
run: |
- uv run pytest src/tests/integration/test_graph_rag_pipeline.py
src/tests/integration/test_kg_construction.py
src/tests/integration/test_rag_pipeline.py -v --tb=short
+ docker ps -a
+ container_id=$(docker ps -aq --filter
ancestor=hugegraph/hugegraph:1.7.0 | head -n 1)
+ if [ -n "$container_id" ]; then
+ docker logs "$container_id"
+ fi
diff --git a/.github/workflows/hugegraph-python-client.yml
b/.github/workflows/hugegraph-python-client.yml
index 5dcf27c1..ab8915f3 100644
--- a/.github/workflows/hugegraph-python-client.yml
+++ b/.github/workflows/hugegraph-python-client.yml
@@ -7,39 +7,96 @@ on:
- 'release-*'
pull_request:
+permissions:
+ contents: read
+
+env:
+ UV_VERSION: "0.9.22"
+
jobs:
- build:
+ client-unit-contract:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.11"]
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install uv
+ run: |
+ curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+ "$HOME/.local/bin/uv" --version
+
+ - name: Cache dependencies
+ id: cache-deps
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cache/uv
+ key: ${{ runner.os }}-uv-${{ matrix.python-version }}-${{
hashFiles('**/pyproject.toml') }}
+ restore-keys: |
+ ${{ runner.os }}-uv-${{ matrix.python-version }}-
+
+ - name: Install dependencies with uv sync
+ run: |
+ uv sync --extra python-client --extra dev
+
+ - name: Run unit and contract tests
+ run: |
+ uv run pytest hugegraph-python-client/src/tests -m "unit or
contract" --cov=pyhugegraph --cov-fail-under=45 --cov-report=term
--cov-report=xml:client-unit-contract.xml -v --tb=short --durations=20
+
+ - name: Upload coverage artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: client-unit-contract-coverage-${{ matrix.python-version }}
+ path: client-unit-contract.xml
+ if-no-files-found: error
+
+ client-hugegraph-integration:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
- python-version: ["3.10", "3.11", "3.12"]
+ python-version: ["3.10", "3.11"]
services:
hugegraph:
image: hugegraph/hugegraph:1.7.0
env:
PASSWORD: admin
- options: --health-cmd="curl -f http://localhost:8080/versions || exit
1" --health-interval=10s --health-timeout=5s --health-retries=5
+ options: --health-cmd="curl -f http://localhost:8080/versions || exit
1" --health-interval=10s --health-timeout=5s --health-retries=8
ports:
- 8080:8080
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
run: |
- curl -LsSf https://astral.sh/uv/install.sh | sh
- echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+ curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+ "$HOME/.local/bin/uv" --version
- name: Cache dependencies
id: cache-deps
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
~/.cache/uv
@@ -51,13 +108,21 @@ jobs:
run: |
uv sync --extra python-client --extra dev
- - name: Test example
+ - name: Run HugeGraph integration tests
+ env:
+ HUGEGRAPH_REQUIRED: true
+ HUGEGRAPH_URL: http://127.0.0.1:8080
+ HUGEGRAPH_GRAPH: hugegraph
+ HUGEGRAPH_USER: admin
+ HUGEGRAPH_PASSWORD: admin
run: |
- ls -al
- export PYTHONPATH=$(pwd)/hugegraph-python-client/src && echo
${PYTHONPATH}
- uv run python
hugegraph-python-client/src/pyhugegraph/example/hugegraph_example.py
+ uv run pytest hugegraph-python-client/src/tests -m "integration and
hugegraph" -v --tb=short
- - name: Test with pytest
+ - name: Dump HugeGraph diagnostics
+ if: failure()
run: |
- uv run pytest
- working-directory: hugegraph-python-client
+ docker ps -a
+ container_id=$(docker ps -aq --filter
ancestor=hugegraph/hugegraph:1.7.0 | head -n 1)
+ if [ -n "$container_id" ]; then
+ docker logs "$container_id"
+ fi
diff --git a/docs/quality/coverage-ratchet.md b/docs/quality/coverage-ratchet.md
new file mode 100644
index 00000000..082c4b3e
--- /dev/null
+++ b/docs/quality/coverage-ratchet.md
@@ -0,0 +1,66 @@
+# Coverage Ratchet
+
+## Principles
+
+- Start with local areas, not a full-repo threshold.
+- New production logic requires tests.
+- Bug fixes require regression tests.
+- HugeGraph boundaries need Layer B or Layer C evidence.
+- Thresholds may start low but must not decrease.
+- External provider, vector DB, and UI credential paths stay outside default
PR ratchets.
+
+## Initial Ratchet Areas
+
+- `pyhugegraph`
+- `hugegraph_llm.operators.hugegraph_op`
+- `hugegraph_llm.operators.llm_op`
+- `hugegraph_llm.api`
+- `hugegraph_llm.api.models`
+
+## Baseline
+
+Current combined baseline:
+
+| Scope | Covered lines | Statements | Missing lines | Coverage |
+|---|---:|---:|---:|---:|
+| `pyhugegraph` + `hugegraph_llm` unit/contract | 3115 | 7975 | 4860 | 39% |
+
+The original combined command hits a pytest import-path collision because both
workspace packages define a `tests.conftest` module. Use
`--import-mode=importlib` for combined workspace baselines until the test
package layout is normalized.
+
+## CI Gates
+
+The default unit/contract jobs enforce module-level floors from the initial
local baseline runs:
+
+| Workflow | Scope | Gate |
+|---|---|---:|
+| `.github/workflows/hugegraph-python-client.yml` | `pyhugegraph`
unit/contract | `--cov-fail-under=45` |
+| `.github/workflows/hugegraph-llm.yml` | `hugegraph_llm` unit/contract |
`--cov-fail-under=34` |
+
+These gates are intentionally baseline-level floors. Raise them only after the
corresponding local ratchet areas gain meaningful tests.
+
+## Local Commands
+
+```bash
+uv run pytest hugegraph-python-client/src/tests -m "unit or contract"
--cov=pyhugegraph --cov-report=term
+uv run pytest hugegraph-llm/src/tests/operators/hugegraph_op -m "unit or
contract" --cov=hugegraph_llm.operators.hugegraph_op --cov-report=term
+uv run pytest hugegraph-llm/src/tests/operators/llm_op -m "unit or contract"
--cov=hugegraph_llm.operators.llm_op --cov-report=term
+uv run pytest hugegraph-llm/src/tests/api -m "unit or contract"
--cov=hugegraph_llm.api --cov-report=term
+```
+
+Combined workspace baseline command:
+
+```bash
+uv run pytest --import-mode=importlib hugegraph-python-client/src/tests
hugegraph-llm/src/tests \
+ -m "unit or contract" \
+ --cov=pyhugegraph \
+ --cov=hugegraph_llm \
+ --cov-report=term \
+ --cov-report=json:combined-baseline.json
+```
+
+## Ratchet Rules
+
+- Do not lower an existing local threshold for a touched ratchet area.
+- For production bug fixes, add or update a regression test in the nearest
unit, contract, integration, or smoke layer.
+- For HugeGraph service-boundary changes, include a selected Layer B or Layer
C command with `HUGEGRAPH_REQUIRED=true`.
+- For new external-provider behavior, keep deterministic contract tests in
default gates and reserve live-provider coverage for Layer D.
diff --git a/docs/quality/hugegraph-integration.md
b/docs/quality/hugegraph-integration.md
new file mode 100644
index 00000000..0edbc486
--- /dev/null
+++ b/docs/quality/hugegraph-integration.md
@@ -0,0 +1,20 @@
+# HugeGraph Integration Test Service
+
+Default image: `hugegraph/hugegraph:1.7.0`
+
+## Environment
+
+```text
+HUGEGRAPH_URL=http://127.0.0.1:8080
+HUGEGRAPH_GRAPH=hugegraph
+HUGEGRAPH_USER=admin
+HUGEGRAPH_PASSWORD=admin
+HUGEGRAPH_GRAPHSPACE=
+HUGEGRAPH_REQUIRED=true|false
+```
+
+## Semantics
+
+- If `HUGEGRAPH_REQUIRED=true`, selected integration tests fail when the
service is unavailable.
+- If `HUGEGRAPH_REQUIRED=false`, local integration tests may skip when no
service is present.
+- Default unit/contract tests must not require Docker.
diff --git a/docs/quality/test-taxonomy.md b/docs/quality/test-taxonomy.md
new file mode 100644
index 00000000..a93ecb42
--- /dev/null
+++ b/docs/quality/test-taxonomy.md
@@ -0,0 +1,32 @@
+# HugeGraph AI Test Taxonomy
+
+## Layer A: Unit / Pure Contract
+
+- Markers: `unit` or `contract`.
+- No Docker, network, real HugeGraph, real LLM provider, embedding provider,
reranker provider, vector DB, or UI service.
+- Use fakes, fixtures, monkeypatches, and public APIs.
+
+## Layer B: HugeGraph Server Contract
+
+- Markers: `integration` and `hugegraph`.
+- Requires HugeGraph Server, default `hugegraph/hugegraph:1.7.0`.
+- If selected with `HUGEGRAPH_REQUIRED=true`, service connection failures fail.
+- Must import production code and validate real server behavior.
+
+## Layer C: Core Pipeline Smoke
+
+- Marker: `smoke`; also use `integration` and `hugegraph` when real HugeGraph
is required.
+- Uses production flow/node/operator code.
+- Uses fake LLM and deterministic embeddings/vector fixtures.
+- Does not define local replacement pipeline implementations inside tests.
+
+## Layer D: External Provider / Optional E2E
+
+- Markers: `external` and usually `slow`.
+- May require real provider credentials or non-HugeGraph services.
+- Excluded from default PR gates.
+
+## Required Skip Semantics
+
+Do not silently skip selected Layer B tests. Prefer not selecting integration
tests locally.
+If `HUGEGRAPH_REQUIRED=true`, unavailable HugeGraph is a failure.
diff --git a/hugegraph-llm/src/hugegraph_llm/api/rag_api.py
b/hugegraph-llm/src/hugegraph_llm/api/rag_api.py
index 76217251..4f5a80c3 100644
--- a/hugegraph-llm/src/hugegraph_llm/api/rag_api.py
+++ b/hugegraph-llm/src/hugegraph_llm/api/rag_api.py
@@ -161,7 +161,9 @@ def rag_http_api(
# TODO: restructure the implement of llm to three types, like
"/config/chat_llm"
@router.post("/config/llm", status_code=status.HTTP_201_CREATED)
def llm_config_api(req: LLMConfigRequest):
- llm_settings.llm_type = req.llm_type
+ llm_settings.chat_llm_type = req.llm_type
+ llm_settings.extract_llm_type = req.llm_type
+ llm_settings.text2gql_llm_type = req.llm_type
if req.llm_type == "openai":
res = apply_llm_conf(
diff --git a/hugegraph-llm/src/hugegraph_llm/config/models/base_config.py
b/hugegraph-llm/src/hugegraph_llm/config/models/base_config.py
index 5fec3a77..0e5ca609 100644
--- a/hugegraph-llm/src/hugegraph_llm/config/models/base_config.py
+++ b/hugegraph-llm/src/hugegraph_llm/config/models/base_config.py
@@ -17,6 +17,7 @@
import os
+from pathlib import Path
from dotenv import dotenv_values, set_key
from pydantic_settings import BaseSettings
@@ -24,7 +25,32 @@ from pydantic_settings import BaseSettings
from hugegraph_llm.utils.log import log
dir_name = os.path.dirname
-env_path = os.path.join(os.getcwd(), ".env") # Load .env from the current
working directory
+ENV_PATH_ENV_VAR = "HUGEGRAPH_LLM_ENV_PATH"
+
+
+def _source_project_root() -> Path | None:
+ package_root = Path(__file__).resolve().parents[2]
+ if package_root.parent.name != "src":
+ return None
+ project_root = package_root.parent.parent
+ if (project_root / "pyproject.toml").exists():
+ return project_root
+ return None
+
+
+def resolve_env_path() -> str:
+ explicit_env_path = os.getenv(ENV_PATH_ENV_VAR)
+ if explicit_env_path:
+ return str(Path(explicit_env_path).expanduser())
+
+ project_root = _source_project_root()
+ if project_root is not None:
+ return str(project_root / ".env")
+
+ return str(Path.cwd() / ".env")
+
+
+env_path = resolve_env_path()
class BaseConfig(BaseSettings):
diff --git
a/hugegraph-llm/src/hugegraph_llm/config/models/base_prompt_config.py
b/hugegraph-llm/src/hugegraph_llm/config/models/base_prompt_config.py
index 57d8ba63..f1e0c6c1 100644
--- a/hugegraph-llm/src/hugegraph_llm/config/models/base_prompt_config.py
+++ b/hugegraph-llm/src/hugegraph_llm/config/models/base_prompt_config.py
@@ -16,17 +16,45 @@
# under the License.
import os
-import sys
from pathlib import Path
import yaml
-from hugegraph_llm.utils.anchor import get_project_root
from hugegraph_llm.utils.log import log
dir_name = os.path.dirname
F_NAME = "config_prompt.yaml"
-yaml_file_path = os.path.join(os.getcwd(), "src/hugegraph_llm/resources/demo",
F_NAME)
+PROMPT_CONFIG_PATH_ENV_VAR = "HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"
+
+
+def _source_prompt_yaml_path() -> Path | None:
+ package_root = Path(__file__).resolve().parents[2]
+ if package_root.parent.name != "src":
+ return None
+ project_root = package_root.parent.parent
+ if (project_root / "pyproject.toml").exists():
+ return package_root / "resources" / "demo" / F_NAME
+ return None
+
+
+def _user_prompt_yaml_path() -> Path:
+ config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() /
".config")).expanduser()
+ return config_home / "hugegraph-llm" / F_NAME
+
+
+def resolve_prompt_yaml_path() -> str:
+ explicit_prompt_path = os.getenv(PROMPT_CONFIG_PATH_ENV_VAR)
+ if explicit_prompt_path:
+ return str(Path(explicit_prompt_path).expanduser())
+
+ source_prompt_path = _source_prompt_yaml_path()
+ if source_prompt_path is not None:
+ return str(source_prompt_path)
+
+ return str(_user_prompt_yaml_path())
+
+
+yaml_file_path = resolve_prompt_yaml_path()
class LiteralStr(str):
@@ -54,18 +82,6 @@ class BasePromptConfig:
generate_extract_prompt_template: str = ""
def ensure_yaml_file_exists(self):
- current_dir = Path.cwd().resolve()
- project_root = get_project_root()
- if current_dir == project_root:
- log.info("Current working directory is the project root,
proceeding to run the app.")
- else:
- error_msg = (
- f"Current working directory is not the project root. "
- f"Please run this script from the project root directory:
{project_root}\n"
- f"Current directory: {current_dir}"
- )
- log.error(error_msg)
- sys.exit(1)
if os.path.exists(yaml_file_path):
log.info("Loading prompt file '%s' successfully.", F_NAME)
with open(yaml_file_path, "r", encoding="utf-8") as file:
@@ -123,6 +139,7 @@ class BasePromptConfig:
"_language_generated":
str(self.llm_settings.language).lower().strip(),
"generate_extract_prompt_template":
to_literal(self.generate_extract_prompt_template),
}
+ Path(yaml_file_path).parent.mkdir(parents=True, exist_ok=True)
with open(yaml_file_path, "w", encoding="utf-8") as file:
yaml.dump(data, file, allow_unicode=True, sort_keys=False,
default_flow_style=False)
diff --git a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/configs_block.py
b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/configs_block.py
index 72c1eec0..0b51aa6c 100644
--- a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/configs_block.py
+++ b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/configs_block.py
@@ -16,7 +16,6 @@
# under the License.
import json
-import os
from functools import partial
from typing import Optional
@@ -26,6 +25,7 @@ from dotenv import dotenv_values
from requests.auth import HTTPBasicAuth
from hugegraph_llm.config import huge_settings, index_settings, llm_settings
+from hugegraph_llm.config.models.base_config import env_path
from hugegraph_llm.models.embeddings.litellm import LiteLLMEmbedding
from hugegraph_llm.models.llms.litellm import LiteLLMClient
from hugegraph_llm.utils.log import log
@@ -429,7 +429,6 @@ def create_configs_block() -> list:
llm_config_button = gr.Button("Apply configuration")
llm_config_button.click(apply_llm_config_with_chat_op,
inputs=llm_config_input)
# Determine whether there are Settings in the.env file
- env_path = os.path.join(os.getcwd(), ".env") # Load .env from
the current working directory
env_vars = dotenv_values(env_path)
api_extract_key = env_vars.get("OPENAI_EXTRACT_API_KEY")
api_text2sql_key = env_vars.get("OPENAI_TEXT2GQL_API_KEY")
diff --git
a/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/commit_to_hugegraph.py
b/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/commit_to_hugegraph.py
index daade304..14bb3865 100644
---
a/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/commit_to_hugegraph.py
+++
b/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/commit_to_hugegraph.py
@@ -147,20 +147,26 @@ class Commit2Graph:
continue
# TODO: we could try batch add vertices first, setback to
single-mode if failed
- original_id = vertex.get("id")
- if vertex_label.get("id_strategy") == "CUSTOMIZE_STRING" and
original_id:
+ explicit_id = vertex.get("id")
+ mapping_id = explicit_id
+ if not mapping_id and primary_keys:
+ mapping_id =
f"{input_label}:{'!'.join(str(input_properties[pk]) for pk in primary_keys)}"
+
+ if vertex_label.get("id_strategy") == "CUSTOMIZE_STRING" and
explicit_id:
result = self._handle_graph_creation(
self.client.graph().addVertex,
input_label,
input_properties,
- id=original_id,
+ id=explicit_id,
)
else:
result =
self._handle_graph_creation(self.client.graph().addVertex, input_label,
input_properties)
+ if result is None:
+ raise ValueError(f"Failed to create vertex '{input_label}'
with properties {input_properties}")
vid = result.id
vertex["id"] = vid
- if original_id:
- vid_mapping[original_id] = vid
+ if mapping_id:
+ vid_mapping[mapping_id] = vid
for edge in edges:
start = vid_mapping.get(edge.get("outV"), edge.get("outV"))
diff --git a/hugegraph-llm/src/tests/api/test_rag_api.py
b/hugegraph-llm/src/tests/api/test_rag_api.py
index 55fbd679..0bed3174 100644
--- a/hugegraph-llm/src/tests/api/test_rag_api.py
+++ b/hugegraph-llm/src/tests/api/test_rag_api.py
@@ -17,10 +17,32 @@
from unittest.mock import Mock
+import pytest
from fastapi import APIRouter, FastAPI, status
from fastapi.testclient import TestClient
from hugegraph_llm.api.rag_api import rag_http_api
+from hugegraph_llm.config import llm_settings
+
+pytestmark = pytest.mark.contract
+
+
+def _make_test_client(**overrides):
+ callbacks = {
+ "rag_answer_func": Mock(return_value=("raw", "vector", "graph",
"graph_vector")),
+ "graph_rag_recall_func": Mock(return_value={"query": "q", "keywords":
[]}),
+ "apply_graph_conf": Mock(return_value=status.HTTP_200_OK),
+ "apply_llm_conf": Mock(return_value=status.HTTP_200_OK),
+ "apply_embedding_conf": Mock(return_value=status.HTTP_200_OK),
+ "apply_reranker_conf": Mock(return_value=status.HTTP_200_OK),
+ "gremlin_generate_selective_func": Mock(return_value={"result":
"g.V()"}),
+ }
+ callbacks.update(overrides)
+ router = APIRouter()
+ rag_http_api(router, **callbacks)
+ app = FastAPI()
+ app.include_router(router)
+ return TestClient(app), callbacks
def test_graph_config_api_passes_graph_field_to_apply_graph_conf():
@@ -60,3 +82,96 @@ def
test_graph_config_api_passes_graph_field_to_apply_graph_conf():
"space_a",
origin_call="http",
)
+
+
+def test_llm_config_api_passes_openai_fields_to_apply_llm_conf(monkeypatch):
+ monkeypatch.setattr(llm_settings, "chat_llm_type", "ollama/local")
+ monkeypatch.setattr(llm_settings, "extract_llm_type", "ollama/local")
+ monkeypatch.setattr(llm_settings, "text2gql_llm_type", "ollama/local")
+ client, callbacks = _make_test_client()
+
+ response = client.post(
+ "/config/llm",
+ json={
+ "llm_type": "openai",
+ "api_key": "sk-test",
+ "api_base": "https://api.example.test",
+ "language_model": "gpt-test",
+ "max_tokens": "1024",
+ },
+ )
+
+ assert llm_settings.chat_llm_type == "openai"
+ assert llm_settings.extract_llm_type == "openai"
+ assert llm_settings.text2gql_llm_type == "openai"
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.json() == {"message": "Connection successful. Configured
finished."}
+ callbacks["apply_llm_conf"].assert_called_once_with(
+ "sk-test",
+ "https://api.example.test",
+ "gpt-test",
+ "1024",
+ origin_call="http",
+ )
+
+
+def test_embedding_config_api_passes_openai_fields_to_apply_embedding_conf():
+ client, callbacks = _make_test_client()
+
+ response = client.post(
+ "/config/embedding",
+ json={
+ "llm_type": "openai",
+ "api_key": "sk-embedding",
+ "api_base": "https://embedding.example.test",
+ "language_model": "embedding-test",
+ },
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ callbacks["apply_embedding_conf"].assert_called_once_with(
+ "sk-embedding",
+ "https://embedding.example.test",
+ "embedding-test",
+ origin_call="http",
+ )
+
+
+def test_rerank_config_api_passes_cohere_fields_to_apply_reranker_conf():
+ client, callbacks = _make_test_client()
+
+ response = client.post(
+ "/config/rerank",
+ json={
+ "reranker_type": "cohere",
+ "api_key": "cohere-key",
+ "reranker_model": "rerank-test",
+ "cohere_base_url": "https://cohere.example.test",
+ },
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ callbacks["apply_reranker_conf"].assert_called_once_with(
+ "cohere-key",
+ "rerank-test",
+ "https://cohere.example.test",
+ origin_call="http",
+ )
+
+
+def test_rag_api_invalid_request_body_returns_validation_shape():
+ client, _ = _make_test_client()
+
+ response = client.post("/rag", json={})
+
+ assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+ assert response.json()["detail"][0]["loc"][-1] == "query"
+
+
+def test_text2gremlin_callback_exception_returns_stable_response():
+ client, _ =
_make_test_client(gremlin_generate_selective_func=Mock(side_effect=RuntimeError("callback
failed")))
+
+ response = client.post("/text2gremlin", json={"query": "find people"})
+
+ assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
+ assert response.json() == {"detail": "An unexpected error occurred during
Gremlin generation."}
diff --git a/hugegraph-llm/src/tests/config/test_config.py
b/hugegraph-llm/src/tests/config/test_config.py
index 63dc6cb9..9e9b9fc6 100644
--- a/hugegraph-llm/src/tests/config/test_config.py
+++ b/hugegraph-llm/src/tests/config/test_config.py
@@ -17,6 +17,15 @@
import unittest
+from os import environ
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+
+pytestmark = pytest.mark.unit
class TestConfig(unittest.TestCase):
@@ -27,3 +36,57 @@ class TestConfig(unittest.TestCase):
nltk.data.path.append(resource_path)
nltk.data.find("corpora/stopwords")
+
+ def test_prompt_yaml_path_is_project_root_independent(self):
+ from hugegraph_llm.config.models import base_prompt_config
+
+ expected = Path(__file__).resolve().parents[2] / "hugegraph_llm" /
"resources" / "demo" / "config_prompt.yaml"
+ self.assertEqual(Path(base_prompt_config.yaml_file_path), expected)
+
+ def test_prompt_yaml_path_prefers_explicit_override(self):
+ from hugegraph_llm.config.models import base_prompt_config
+
+ custom_prompt = Path("/tmp/hugegraph-llm-prompt-test.yaml")
+ with patch.dict(environ,
{base_prompt_config.PROMPT_CONFIG_PATH_ENV_VAR: str(custom_prompt)}):
+
self.assertEqual(Path(base_prompt_config.resolve_prompt_yaml_path()),
custom_prompt)
+
+ def test_prompt_yaml_path_uses_user_config_outside_source_tree(self):
+ from hugegraph_llm.config.models import base_prompt_config
+
+ with TemporaryDirectory() as config_home:
+ with patch.dict(environ, {"XDG_CONFIG_HOME": config_home},
clear=False):
+ with patch.object(base_prompt_config,
"_source_prompt_yaml_path", return_value=None):
+ expected = Path(config_home) / "hugegraph-llm" /
"config_prompt.yaml"
+
self.assertEqual(Path(base_prompt_config.resolve_prompt_yaml_path()), expected)
+
+ def test_prompt_save_creates_mutable_config_parent_directory(self):
+ from hugegraph_llm.config.models import base_prompt_config
+ from hugegraph_llm.config.prompt_config import PromptConfig
+
+ with TemporaryDirectory() as temp_dir:
+ prompt_path = Path(temp_dir) / "nested" / "config_prompt.yaml"
+ prompt = PromptConfig(SimpleNamespace(language="en"))
+
+ with patch.object(base_prompt_config, "yaml_file_path",
str(prompt_path)):
+ prompt.save_to_yaml()
+
+ self.assertTrue(prompt_path.exists())
+
+ def test_env_path_is_project_root_independent(self):
+ from hugegraph_llm.config.models import base_config
+
+ expected = Path(__file__).resolve().parents[3] / ".env"
+ self.assertEqual(Path(base_config.env_path), expected)
+
+ def test_env_path_prefers_explicit_override(self):
+ from hugegraph_llm.config.models import base_config
+
+ custom_env = Path("/tmp/hugegraph-llm-test.env")
+ with patch.dict(environ, {base_config.ENV_PATH_ENV_VAR:
str(custom_env)}):
+ self.assertEqual(Path(base_config.resolve_env_path()), custom_env)
+
+ def test_demo_config_block_uses_shared_env_path(self):
+ from hugegraph_llm.config.models import base_config
+ from hugegraph_llm.demo.rag_demo import configs_block
+
+ self.assertEqual(configs_block.env_path, base_config.env_path)
diff --git a/hugegraph-llm/src/tests/config/test_prompt_config.py
b/hugegraph-llm/src/tests/config/test_prompt_config.py
index 0a46b4c6..c300a551 100644
--- a/hugegraph-llm/src/tests/config/test_prompt_config.py
+++ b/hugegraph-llm/src/tests/config/test_prompt_config.py
@@ -19,10 +19,14 @@ import json
from pathlib import Path
from unittest.mock import MagicMock
+import pytest
+
from hugegraph_llm.config.prompt_config import PromptConfig
from hugegraph_llm.models.llms.base import BaseLLM
from hugegraph_llm.operators.llm_op.property_graph_extract import
PropertyGraphExtract
+pytestmark = pytest.mark.contract
+
def _json_objects_after_marker(prompt, marker):
start = prompt.index(marker) + len(marker)
diff --git a/hugegraph-llm/src/tests/conftest.py
b/hugegraph-llm/src/tests/conftest.py
index ff38f3d5..d2d1ed66 100644
--- a/hugegraph-llm/src/tests/conftest.py
+++ b/hugegraph-llm/src/tests/conftest.py
@@ -18,17 +18,72 @@
import logging
import os
import sys
+from contextlib import suppress
import nltk
+import pytest
# Get project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__),
"../.."))
-# Add to Python path
sys.path.insert(0, project_root)
# Add src directory to Python path
src_path = os.path.join(project_root, "src")
sys.path.insert(0, src_path)
+from tests.fixtures.hugegraph_service import hugegraph_service # noqa: E402
+
+__all__ = ["hugegraph_client", "hugegraph_service"]
+
+
+def _clear_quality_schema(client):
+ schema = client.schema()
+ for remove in (
+ lambda: schema.edgeLabel("quality_created").remove(),
+ lambda: schema.vertexLabel("quality_software").remove(),
+ lambda: schema.vertexLabel("quality_person").remove(),
+ ):
+ with suppress(Exception):
+ remove()
+
+
[email protected]()
+def hugegraph_client(hugegraph_service):
+ from pyhugegraph.client import PyHugeClient
+
+ from hugegraph_llm.config import huge_settings
+
+ original = {
+ "graph_url": huge_settings.graph_url,
+ "graph_name": huge_settings.graph_name,
+ "graph_user": huge_settings.graph_user,
+ "graph_pwd": huge_settings.graph_pwd,
+ "graph_space": huge_settings.graph_space,
+ }
+ huge_settings.graph_url = hugegraph_service.url
+ huge_settings.graph_name = hugegraph_service.graph
+ huge_settings.graph_user = hugegraph_service.user
+ huge_settings.graph_pwd = hugegraph_service.password
+ huge_settings.graph_space = hugegraph_service.graphspace
+
+ client = PyHugeClient(
+ url=hugegraph_service.url,
+ graph=hugegraph_service.graph,
+ user=hugegraph_service.user,
+ pwd=hugegraph_service.password,
+ graphspace=hugegraph_service.graphspace,
+ )
+ client.graphs().clear_graph_all_data()
+ _clear_quality_schema(client)
+ try:
+ yield client
+ finally:
+ try:
+ client.graphs().clear_graph_all_data()
+ _clear_quality_schema(client)
+ finally:
+ for key, value in original.items():
+ setattr(huge_settings, key, value)
+
# Download NLTK resources
def download_nltk_resources():
@@ -41,7 +96,8 @@ def download_nltk_resources():
# Download NLTK resources before tests start
download_nltk_resources()
-# Set environment variable to skip external service tests
-os.environ["SKIP_EXTERNAL_SERVICES"] = "true"
+# Default local tests away from external services while allowing selected
+# integration runs to opt in explicitly.
+os.environ.setdefault("SKIP_EXTERNAL_SERVICES", "true")
# Log current Python path for debugging
logging.debug("Python path: %s", sys.path)
diff --git
a/hugegraph-llm/src/tests/data/quality_program/graphrag_documents.json
b/hugegraph-llm/src/tests/data/quality_program/graphrag_documents.json
new file mode 100644
index 00000000..f66cb9cb
--- /dev/null
+++ b/hugegraph-llm/src/tests/data/quality_program/graphrag_documents.json
@@ -0,0 +1,4 @@
+[
+ {"id": "doc-1", "text": "marko is a person who created lop."},
+ {"id": "doc-2", "text": "lop is software written in java."}
+]
diff --git a/hugegraph-llm/src/tests/data/quality_program/kg_graph_output.json
b/hugegraph-llm/src/tests/data/quality_program/kg_graph_output.json
new file mode 100644
index 00000000..d005c67b
--- /dev/null
+++ b/hugegraph-llm/src/tests/data/quality_program/kg_graph_output.json
@@ -0,0 +1,14 @@
+{
+ "vertices": [
+ {"label": "quality_person", "properties": {"name": "marko", "age": 29}},
+ {"label": "quality_software", "properties": {"name": "lop", "lang":
"java"}}
+ ],
+ "edges": [
+ {
+ "label": "quality_created",
+ "outV": "quality_person:marko",
+ "inV": "quality_software:lop",
+ "properties": {"date": "2026-05-31"}
+ }
+ ]
+}
diff --git a/hugegraph-llm/src/tests/data/quality_program/kg_text.txt
b/hugegraph-llm/src/tests/data/quality_program/kg_text.txt
new file mode 100644
index 00000000..be0663d5
--- /dev/null
+++ b/hugegraph-llm/src/tests/data/quality_program/kg_text.txt
@@ -0,0 +1 @@
+marko is a person aged 29. marko created lop. lop is software written in java.
diff --git
a/hugegraph-llm/src/tests/data/quality_program/text2gremlin_schema.json
b/hugegraph-llm/src/tests/data/quality_program/text2gremlin_schema.json
new file mode 100644
index 00000000..eba7dff8
--- /dev/null
+++ b/hugegraph-llm/src/tests/data/quality_program/text2gremlin_schema.json
@@ -0,0 +1,9 @@
+{
+ "vertexlabels": [
+ {"name": "quality_person", "properties": ["name", "age"]},
+ {"name": "quality_software", "properties": ["name", "lang"]}
+ ],
+ "edgelabels": [
+ {"name": "quality_created", "source_label": "quality_person",
"target_label": "quality_software"}
+ ]
+}
diff --git a/hugegraph-llm/src/tests/document/test_document.py
b/hugegraph-llm/src/tests/document/test_document.py
index cf106ead..cd5fe5da 100644
--- a/hugegraph-llm/src/tests/document/test_document.py
+++ b/hugegraph-llm/src/tests/document/test_document.py
@@ -17,8 +17,12 @@
import unittest
+import pytest
+
from hugegraph_llm.document import Document, Metadata
+pytestmark = pytest.mark.unit
+
class TestDocument(unittest.TestCase):
def test_document_initialization(self):
diff --git a/hugegraph-llm/src/tests/document/test_document_splitter.py
b/hugegraph-llm/src/tests/document/test_document_splitter.py
index d1f67580..e919bddb 100644
--- a/hugegraph-llm/src/tests/document/test_document_splitter.py
+++ b/hugegraph-llm/src/tests/document/test_document_splitter.py
@@ -17,8 +17,12 @@
import unittest
+import pytest
+
from hugegraph_llm.document.chunk_split import ChunkSplitter
+pytestmark = pytest.mark.unit
+
class TestChunkSplitter(unittest.TestCase):
def test_paragraph_split_zh(self):
diff --git a/hugegraph-llm/src/tests/document/test_text_loader.py
b/hugegraph-llm/src/tests/document/test_text_loader.py
index f9ac6730..529cfede 100644
--- a/hugegraph-llm/src/tests/document/test_text_loader.py
+++ b/hugegraph-llm/src/tests/document/test_text_loader.py
@@ -19,6 +19,10 @@ import os
import tempfile
import unittest
+import pytest
+
+pytestmark = pytest.mark.unit
+
class TextLoader:
"""Simple text file loader for testing."""
diff --git a/hugegraph-llm/src/tests/config/test_config.py
b/hugegraph-llm/src/tests/fixtures/__init__.py
similarity index 75%
copy from hugegraph-llm/src/tests/config/test_config.py
copy to hugegraph-llm/src/tests/fixtures/__init__.py
index 63dc6cb9..13a83393 100644
--- a/hugegraph-llm/src/tests/config/test_config.py
+++ b/hugegraph-llm/src/tests/fixtures/__init__.py
@@ -14,16 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-
-
-import unittest
-
-
-class TestConfig(unittest.TestCase):
- def test_config(self):
- import nltk
-
- from hugegraph_llm.config import resource_path
-
- nltk.data.path.append(resource_path)
- nltk.data.find("corpora/stopwords")
diff --git a/hugegraph-llm/src/tests/config/test_config.py
b/hugegraph-llm/src/tests/fixtures/fake_llm.py
similarity index 59%
copy from hugegraph-llm/src/tests/config/test_config.py
copy to hugegraph-llm/src/tests/fixtures/fake_llm.py
index 63dc6cb9..4eacd807 100644
--- a/hugegraph-llm/src/tests/config/test_config.py
+++ b/hugegraph-llm/src/tests/fixtures/fake_llm.py
@@ -16,14 +16,16 @@
# under the License.
-import unittest
+class FakeLLM:
+ def __init__(self, responses):
+ self.responses = list(responses)
+ self.calls = []
+ def generate(self, prompt=None, messages=None, **kwargs):
+ self.calls.append({"prompt": prompt, "messages": messages, "kwargs":
kwargs})
+ if not self.responses:
+ raise AssertionError("FakeLLM has no remaining responses")
+ return self.responses.pop(0)
-class TestConfig(unittest.TestCase):
- def test_config(self):
- import nltk
-
- from hugegraph_llm.config import resource_path
-
- nltk.data.path.append(resource_path)
- nltk.data.find("corpora/stopwords")
+ async def agenerate(self, prompt=None, messages=None, **kwargs):
+ return self.generate(prompt=prompt, messages=messages, **kwargs)
diff --git a/hugegraph-llm/src/tests/fixtures/hugegraph_service.py
b/hugegraph-llm/src/tests/fixtures/hugegraph_service.py
new file mode 100644
index 00000000..a6318eb9
--- /dev/null
+++ b/hugegraph-llm/src/tests/fixtures/hugegraph_service.py
@@ -0,0 +1,74 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import os
+import time
+from dataclasses import dataclass
+
+import pytest
+import requests
+
+
+@dataclass(frozen=True)
+class HugeGraphService:
+ url: str
+ graph: str
+ user: str
+ password: str
+ graphspace: str | None
+
+
+def hugegraph_required() -> bool:
+ return os.getenv("HUGEGRAPH_REQUIRED", "false").lower() == "true"
+
+
+def hugegraph_service_from_env() -> HugeGraphService:
+ return HugeGraphService(
+ url=os.getenv("HUGEGRAPH_URL", "http://127.0.0.1:8080"),
+ graph=os.getenv("HUGEGRAPH_GRAPH", "hugegraph"),
+ user=os.getenv("HUGEGRAPH_USER", "admin"),
+ password=os.getenv("HUGEGRAPH_PASSWORD", "admin"),
+ graphspace=os.getenv("HUGEGRAPH_GRAPHSPACE") or None,
+ )
+
+
+def wait_for_hugegraph(service: HugeGraphService, timeout_seconds: int = 60)
-> None:
+ deadline = time.monotonic() + timeout_seconds
+ last_error: Exception | None = None
+ while time.monotonic() < deadline:
+ try:
+ response = requests.get(f"{service.url}/versions", timeout=5)
+ response.raise_for_status()
+ return
+ except requests.RequestException as exc:
+ last_error = exc
+ time.sleep(2)
+ raise RuntimeError(f"HugeGraph is not ready at {service.url}/versions")
from last_error
+
+
[email protected](scope="session")
+def hugegraph_service() -> HugeGraphService:
+ service = hugegraph_service_from_env()
+ if hugegraph_required():
+ wait_for_hugegraph(service)
+ return service
+
+ try:
+ wait_for_hugegraph(service, timeout_seconds=5)
+ except RuntimeError as exc:
+ pytest.skip(f"HugeGraph integration tests not selected with required
service: {exc}")
+ return service
diff --git a/hugegraph-llm/src/tests/integration/test_core_graphrag_smoke.py
b/hugegraph-llm/src/tests/integration/test_core_graphrag_smoke.py
new file mode 100644
index 00000000..c1fdac77
--- /dev/null
+++ b/hugegraph-llm/src/tests/integration/test_core_graphrag_smoke.py
@@ -0,0 +1,89 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import json
+from pathlib import Path
+
+import pytest
+
+pytestmark = [pytest.mark.smoke, pytest.mark.integration]
+
+
+class DeterministicEmbedding:
+ def get_embedding_dim(self):
+ return 2
+
+ def get_texts_embeddings(self, texts):
+ return [[float("lop" in text.lower()), float("marko" in text.lower())]
for text in texts]
+
+ async def async_get_texts_embeddings(self, texts):
+ return self.get_texts_embeddings(texts)
+
+
+class InMemoryVectorIndex:
+ stores = {}
+
+ def __init__(self, name):
+ self.name = name
+ self.entries = []
+
+ @classmethod
+ def from_name(cls, embedding_dim, graph_name, index_name):
+ key = (embedding_dim, graph_name, index_name)
+ cls.stores.setdefault(key, cls(index_name))
+ return cls.stores[key]
+
+ def add(self, embeddings, chunks):
+ self.entries.extend(zip(embeddings, chunks))
+
+ def save_index_by_name(self, graph_name, index_name):
+ return None
+
+ def search(self, query_embedding, topk, dis_threshold=2):
+ scored = [(sum(a * b for a, b in zip(query_embedding, embedding)),
chunk) for embedding, chunk in self.entries]
+ return [chunk for _, chunk in sorted(scored, reverse=True)[:topk]]
+
+
+def test_graphrag_smoke_uses_production_vector_and_rerank_operators():
+ from hugegraph_llm.operators.common_op.merge_dedup_rerank import
MergeDedupRerank
+ from hugegraph_llm.operators.index_op.build_vector_index import
BuildVectorIndex
+ from hugegraph_llm.operators.index_op.vector_index_query import
VectorIndexQuery
+
+ InMemoryVectorIndex.stores.clear()
+ data_file = Path(__file__).resolve().parents[1] / "data" /
"quality_program" / "graphrag_documents.json"
+ docs = json.loads(data_file.read_text(encoding="utf-8"))
+ chunks = [doc["text"] for doc in docs]
+ embedding = DeterministicEmbedding()
+
+ BuildVectorIndex(embedding=embedding,
vector_index=InMemoryVectorIndex).run({"chunks": chunks})
+ vector_context = VectorIndexQuery(vector_index=InMemoryVectorIndex,
embedding=embedding, topk=2).run(
+ {"query": "Who created lop?"}
+ )
+ merged_context = MergeDedupRerank(embedding=embedding,
topk_return_results=2, method="bleu").run(
+ {
+ "query": "Who created lop?",
+ "vector_search": True,
+ "graph_search": True,
+ "vector_result": vector_context["vector_result"],
+ "graph_result": ["marko created lop"],
+ }
+ )
+
+ assert vector_context["vector_result"]
+ assert merged_context["graph_result"]
+ assert merged_context["vector_result"]
+ assert any("lop" in item for item in merged_context["vector_result"])
diff --git a/hugegraph-llm/src/tests/integration/test_core_kg_smoke.py
b/hugegraph-llm/src/tests/integration/test_core_kg_smoke.py
new file mode 100644
index 00000000..2824a9f5
--- /dev/null
+++ b/hugegraph-llm/src/tests/integration/test_core_kg_smoke.py
@@ -0,0 +1,108 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import json
+from pathlib import Path
+
+import pytest
+
+pytestmark = [pytest.mark.smoke, pytest.mark.integration,
pytest.mark.hugegraph]
+
+QUALITY_COMMIT_SCHEMA = {
+ "propertykeys": [
+ {"name": "name", "data_type": "TEXT", "cardinality": "SINGLE"},
+ {"name": "age", "data_type": "INT", "cardinality": "SINGLE"},
+ {"name": "lang", "data_type": "TEXT", "cardinality": "SINGLE"},
+ {"name": "date", "data_type": "TEXT", "cardinality": "SINGLE"},
+ ],
+ "vertexlabels": [
+ {
+ "name": "quality_person",
+ "properties": ["name", "age"],
+ "primary_keys": ["name"],
+ "nullable_keys": [],
+ },
+ {
+ "name": "quality_software",
+ "properties": ["name", "lang"],
+ "primary_keys": ["name"],
+ "nullable_keys": [],
+ },
+ ],
+ "edgelabels": [
+ {
+ "name": "quality_created",
+ "source_label": "quality_person",
+ "target_label": "quality_software",
+ "properties": ["date"],
+ }
+ ],
+}
+
+
+def test_kg_construction_smoke_uses_production_code(hugegraph_client):
+ from hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph import
Commit2Graph
+ from hugegraph_llm.operators.hugegraph_op.fetch_graph_data import
FetchGraphData
+
+ fixture_file = Path(__file__).resolve().parents[1] / "data" /
"quality_program" / "kg_graph_output.json"
+ fixture = json.loads(fixture_file.read_text(encoding="utf-8"))
+ assert fixture["vertices"]
+ assert fixture["edges"]
+
+ data = {
+ "schema": QUALITY_COMMIT_SCHEMA,
+ "vertices": fixture["vertices"],
+ "edges": fixture["edges"],
+ }
+ Commit2Graph().run(data)
+
+ summary = FetchGraphData(hugegraph_client).run({})
+ vertices = hugegraph_client.gremlin().exec(
+ """
+ g.V().hasLabel('quality_person', 'quality_software').
+ project('label', 'name', 'age', 'lang').
+ by(label()).
+ by(values('name')).
+ by(coalesce(values('age'), constant(null))).
+ by(coalesce(values('lang'), constant(null)))
+ """
+ )["data"]
+ edges = hugegraph_client.gremlin().exec(
+ """
+ g.E().hasLabel('quality_created').
+ project('label', 'out', 'in', 'date').
+ by(label()).
+ by(outV().values('name')).
+ by(inV().values('name')).
+ by(values('date'))
+ """
+ )["data"]
+
+ assert summary["vertex_num"] == len(fixture["vertices"])
+ assert summary["edge_num"] == len(fixture["edges"])
+ assert sorted(vertices, key=lambda item: item["label"]) == [
+ {"label": "quality_person", "name": "marko", "age": 29, "lang": None},
+ {"label": "quality_software", "name": "lop", "age": None, "lang":
"java"},
+ ]
+ assert edges == [
+ {
+ "label": "quality_created",
+ "out": "marko",
+ "in": "lop",
+ "date": "2026-05-31",
+ }
+ ]
diff --git
a/hugegraph-llm/src/tests/integration/test_core_text2gremlin_smoke.py
b/hugegraph-llm/src/tests/integration/test_core_text2gremlin_smoke.py
new file mode 100644
index 00000000..3ebecd28
--- /dev/null
+++ b/hugegraph-llm/src/tests/integration/test_core_text2gremlin_smoke.py
@@ -0,0 +1,68 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import json
+from pathlib import Path
+
+import pytest
+
+from tests.fixtures.fake_llm import FakeLLM
+
+pytestmark = [pytest.mark.smoke, pytest.mark.integration]
+
+
+def test_text2gremlin_smoke_normalizes_fake_llm_output():
+ from hugegraph_llm.flows.text2gremlin import Text2GremlinFlow
+ from hugegraph_llm.operators.llm_op.gremlin_generate import
GremlinGenerateSynthesize
+ from hugegraph_llm.state.ai_state import WkFlowInput
+
+ schema_file = Path(__file__).resolve().parents[1] / "data" /
"quality_program" / "text2gremlin_schema.json"
+ schema = json.loads(schema_file.read_text(encoding="utf-8"))
+ generator = GremlinGenerateSynthesize(
+ llm=FakeLLM(
+ [
+ "```gremlin\ng.V().hasLabel('quality_person')\n```",
+ "Here is the query:\n```gremlin\ng.V().has('quality_person',
'name', 'marko')\n```",
+ ]
+ ),
+ schema=schema,
+ )
+
+ result = generator.run({"query": "Find marko"})
+ prepared = WkFlowInput()
+ Text2GremlinFlow().prepare(
+ prepared,
+ query="Find marko",
+ example_num=99,
+ schema_input="hugegraph",
+ gremlin_prompt_input=None,
+ requested_outputs=["template_gremlin", "invalid_output"],
+ )
+
+ assert result["result"] == "g.V().has('quality_person', 'name', 'marko')"
+ assert result["raw_result"] == "g.V().hasLabel('quality_person')"
+ assert prepared.example_num == 10
+ assert prepared.requested_outputs == ["template_gremlin"]
+
+
+def test_text2gremlin_smoke_invalid_query_fails_explicitly():
+ from hugegraph_llm.operators.llm_op.gremlin_generate import
GremlinGenerateSynthesize
+
+ generator = GremlinGenerateSynthesize(llm=FakeLLM(["g.V()", "g.V()"]))
+
+ with pytest.raises(ValueError, match="query is required"):
+ generator.run({"query": ""})
diff --git a/hugegraph-llm/src/tests/integration/test_graph_rag_pipeline.py
b/hugegraph-llm/src/tests/integration/test_graph_rag_pipeline.py
index 243963c3..8a352e29 100644
--- a/hugegraph-llm/src/tests/integration/test_graph_rag_pipeline.py
+++ b/hugegraph-llm/src/tests/integration/test_graph_rag_pipeline.py
@@ -21,8 +21,12 @@ import tempfile
import unittest
from unittest.mock import MagicMock
+import pytest
+
from tests.utils.mock import MockEmbedding
+pytestmark = [pytest.mark.external, pytest.mark.slow]
+
class BaseLLM:
def generate(self, prompt, **kwargs):
diff --git a/hugegraph-llm/src/tests/integration/test_hugegraph_boundary.py
b/hugegraph-llm/src/tests/integration/test_hugegraph_boundary.py
new file mode 100644
index 00000000..581963a9
--- /dev/null
+++ b/hugegraph-llm/src/tests/integration/test_hugegraph_boundary.py
@@ -0,0 +1,187 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from copy import deepcopy
+
+import pytest
+
+pytestmark = [pytest.mark.integration, pytest.mark.hugegraph]
+
+QUALITY_SCHEMA = {
+ "vertices": [
+ {"vertex_label": "quality_person", "properties": ["name", "age"],
"primary_keys": ["name"]},
+ {"vertex_label": "quality_software", "properties": ["name", "lang"],
"primary_keys": ["name"]},
+ ],
+ "edges": [
+ {
+ "edge_label": "quality_created",
+ "source_vertex_label": "quality_person",
+ "target_vertex_label": "quality_software",
+ "properties": ["date"],
+ }
+ ],
+}
+
+QUALITY_GRAPH = {
+ "vertices": [
+ {"label": "quality_person", "properties": {"name": "marko", "age":
29}},
+ {"label": "quality_software", "properties": {"name": "lop", "lang":
"java"}},
+ ],
+ "edges": [
+ {
+ "label": "quality_created",
+ "source": "marko",
+ "target": "lop",
+ "properties": {"date": "2026-05-31"},
+ }
+ ],
+}
+
+QUALITY_COMMIT_SCHEMA = {
+ "propertykeys": [
+ {"name": "name", "data_type": "TEXT", "cardinality": "SINGLE"},
+ {"name": "age", "data_type": "INT", "cardinality": "SINGLE"},
+ {"name": "lang", "data_type": "TEXT", "cardinality": "SINGLE"},
+ {"name": "date", "data_type": "TEXT", "cardinality": "SINGLE"},
+ ],
+ "vertexlabels": [
+ {
+ "name": "quality_person",
+ "properties": ["name", "age"],
+ "primary_keys": ["name"],
+ "nullable_keys": [],
+ },
+ {
+ "name": "quality_software",
+ "properties": ["name", "lang"],
+ "primary_keys": ["name"],
+ "nullable_keys": [],
+ },
+ ],
+ "edgelabels": [
+ {
+ "name": "quality_created",
+ "source_label": "quality_person",
+ "target_label": "quality_software",
+ "properties": ["date"],
+ }
+ ],
+}
+
+QUALITY_COMMIT_DATA = {
+ "schema": QUALITY_COMMIT_SCHEMA,
+ "vertices": [
+ {"label": "quality_person", "properties": {"name": "marko", "age":
29}},
+ {"label": "quality_software", "properties": {"name": "lop", "lang":
"java"}},
+ ],
+ "edges": [
+ {
+ "label": "quality_created",
+ "outV": "quality_person:marko",
+ "inV": "quality_software:lop",
+ "properties": {"date": "2026-05-31"},
+ }
+ ],
+}
+
+
+def _create_quality_schema(client):
+ schema = client.schema()
+ schema.propertyKey("name").asText().ifNotExist().create()
+ schema.propertyKey("age").asInt().ifNotExist().create()
+ schema.propertyKey("lang").asText().ifNotExist().create()
+ schema.propertyKey("date").asText().ifNotExist().create()
+ schema.vertexLabel("quality_person").properties("name",
"age").primaryKeys("name").ifNotExist().create()
+ schema.vertexLabel("quality_software").properties("name",
"lang").primaryKeys("name").ifNotExist().create()
+
schema.edgeLabel("quality_created").sourceLabel("quality_person").targetLabel("quality_software").properties(
+ "date"
+ ).ifNotExist().create()
+
+
+def _commit_quality_graph():
+ from hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph import
Commit2Graph
+
+ commit = Commit2Graph()
+ data = deepcopy(QUALITY_COMMIT_DATA)
+ return commit.run(data)
+
+
+def test_schema_manager_reads_real_schema(hugegraph_client, hugegraph_service):
+ from hugegraph_llm.operators.hugegraph_op.schema_manager import
SchemaManager
+
+ _create_quality_schema(hugegraph_client)
+
+ manager = SchemaManager(graph_name=hugegraph_service.graph)
+ context = manager.run({})
+
+ assert "schema" in context
+ assert "simple_schema" in context
+ assert isinstance(context["schema"]["vertexlabels"], list)
+ assert any(label["name"] == "quality_person" for label in
context["schema"]["vertexlabels"])
+
+
+def test_commit_to_graph_writes_vertices_and_edges(hugegraph_client):
+ _commit_quality_graph()
+
+ counts = hugegraph_client.gremlin().exec(
+ """
+ g.V().hasLabel('quality_person', 'quality_software').count()
+ """
+ )["data"][0]
+ edges = hugegraph_client.gremlin().exec(
+ """
+ g.E().hasLabel('quality_created').
+ project('out', 'in', 'date').
+ by(outV().values('name')).
+ by(inV().values('name')).
+ by(values('date'))
+ """
+ )["data"]
+
+ assert counts == 2
+ assert len(edges) == 1
+ assert edges[0]["out"] == "marko"
+ assert edges[0]["in"] == "lop"
+ assert edges[0]["date"] == "2026-05-31"
+
+
+def test_fetch_graph_data_returns_counts_and_samples(hugegraph_client):
+ from hugegraph_llm.operators.hugegraph_op.fetch_graph_data import
FetchGraphData
+
+ _commit_quality_graph()
+ result = FetchGraphData(hugegraph_client).run({})
+
+ assert {"vertex_num", "edge_num", "vertices", "edges",
"note"}.issubset(result)
+ assert result["vertex_num"] == 2
+ assert result["edge_num"] == 1
+ assert isinstance(result["vertices"], list)
+ assert isinstance(result["edges"], list)
+
+
+def test_gremlin_execute_surfaces_invalid_query(hugegraph_client):
+ from hugegraph_llm.nodes.hugegraph_node.gremlin_execute import
GremlinExecuteNode
+
+ node = GremlinExecuteNode()
+ node.wk_input = type("Input", (), {"requested_outputs":
["raw_execution_result"]})()
+ result = node.operator_schedule({"raw_result": "g.V2()"})
+
+ assert result["template_exec_res"] == ""
+ assert (
+ "g.V2" in result["raw_exec_res"]
+ or "No signature" in result["raw_exec_res"]
+ or "NotFound" in result["raw_exec_res"]
+ )
diff --git a/hugegraph-llm/src/tests/integration/test_kg_construction.py
b/hugegraph-llm/src/tests/integration/test_kg_construction.py
index daa7b191..379cb350 100644
--- a/hugegraph-llm/src/tests/integration/test_kg_construction.py
+++ b/hugegraph-llm/src/tests/integration/test_kg_construction.py
@@ -22,6 +22,8 @@ import os
import unittest
from unittest.mock import patch
+import pytest
+
# 导入测试工具
from src.tests.test_utils import (
create_test_document,
@@ -29,6 +31,8 @@ from src.tests.test_utils import (
with_mock_openai_client,
)
+pytestmark = [pytest.mark.external, pytest.mark.slow]
+
# Create mock classes to replace missing modules
class OpenAILLM:
diff --git a/hugegraph-llm/src/tests/integration/test_rag_pipeline.py
b/hugegraph-llm/src/tests/integration/test_rag_pipeline.py
index bb5e4364..3abf253f 100644
--- a/hugegraph-llm/src/tests/integration/test_rag_pipeline.py
+++ b/hugegraph-llm/src/tests/integration/test_rag_pipeline.py
@@ -19,6 +19,8 @@ import os
import tempfile
import unittest
+import pytest
+
# 导入测试工具
from src.tests.test_utils import (
create_test_document,
@@ -28,6 +30,8 @@ from src.tests.test_utils import (
)
from tests.utils.mock import VectorIndex
+pytestmark = [pytest.mark.external, pytest.mark.slow]
+
# 创建模拟类,替代缺失的模块
class Document:
diff --git a/hugegraph-llm/src/tests/middleware/test_middleware.py
b/hugegraph-llm/src/tests/middleware/test_middleware.py
index 90e71272..0a1cd36f 100644
--- a/hugegraph-llm/src/tests/middleware/test_middleware.py
+++ b/hugegraph-llm/src/tests/middleware/test_middleware.py
@@ -18,10 +18,13 @@
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
+import pytest
from fastapi import FastAPI
from hugegraph_llm.middleware.middleware import UseTimeMiddleware
+pytestmark = pytest.mark.unit
+
class TestUseTimeMiddlewareInit(unittest.TestCase):
def setUp(self):
diff --git a/hugegraph-llm/src/tests/models/embeddings/test_ollama_embedding.py
b/hugegraph-llm/src/tests/models/embeddings/test_ollama_embedding.py
index e7a2702a..07b5df62 100644
--- a/hugegraph-llm/src/tests/models/embeddings/test_ollama_embedding.py
+++ b/hugegraph-llm/src/tests/models/embeddings/test_ollama_embedding.py
@@ -20,6 +20,8 @@ import os
import unittest
from unittest.mock import AsyncMock, MagicMock
+import pytest
+
from hugegraph_llm.models.embeddings.base import SimilarityMode
from hugegraph_llm.models.embeddings.ollama import OllamaEmbedding
@@ -28,12 +30,14 @@ class TestOllamaEmbedding(unittest.TestCase):
def setUp(self):
self.skip_external = os.getenv("SKIP_EXTERNAL_SERVICES",
"false").lower() == "true"
+ @pytest.mark.external
@unittest.skipIf(os.getenv("SKIP_EXTERNAL_SERVICES", "false").lower() ==
"true", "Skipping external service tests")
def test_get_text_embedding(self):
ollama_embedding =
OllamaEmbedding(model_name="quentinz/bge-large-zh-v1.5")
embedding = ollama_embedding.get_text_embedding("hello world")
print(embedding)
+ @pytest.mark.external
@unittest.skipIf(os.getenv("SKIP_EXTERNAL_SERVICES", "false").lower() ==
"true", "Skipping external service tests")
def test_get_cosine_similarity(self):
ollama_embedding =
OllamaEmbedding(model_name="quentinz/bge-large-zh-v1.5")
@@ -42,6 +46,7 @@ class TestOllamaEmbedding(unittest.TestCase):
similarity = OllamaEmbedding.similarity(embedding1, embedding2,
SimilarityMode.DEFAULT)
print(similarity)
+ @pytest.mark.contract
def test_async_get_texts_embeddings_preserves_batch_order(self):
ollama_embedding = OllamaEmbedding(model="test-model")
ollama_embedding.async_client = AsyncMock()
@@ -61,6 +66,7 @@ class TestOllamaEmbedding(unittest.TestCase):
asyncio.run(run_async_test())
+ @pytest.mark.contract
def test_async_get_text_embedding_requires_embeddings_key(self):
ollama_embedding = OllamaEmbedding(model="test-model")
ollama_embedding.async_client = AsyncMock()
@@ -74,6 +80,7 @@ class TestOllamaEmbedding(unittest.TestCase):
asyncio.run(run_async_test())
+ @pytest.mark.contract
def test_get_texts_embeddings_requires_embeddings_key(self):
ollama_embedding = OllamaEmbedding(model="test-model")
ollama_embedding.client = MagicMock()
@@ -82,6 +89,15 @@ class TestOllamaEmbedding(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "missing 'embeddings'"):
ollama_embedding.get_texts_embeddings(["a"])
+ @pytest.mark.contract
+ def test_get_texts_embeddings_requires_embed_method(self):
+ ollama_embedding = OllamaEmbedding(model="test-model")
+ ollama_embedding.client = object()
+
+ with self.assertRaisesRegex(AttributeError, "required 'embed' method"):
+ ollama_embedding.get_texts_embeddings(["a"])
+
+ @pytest.mark.contract
def test_get_texts_embeddings_requires_non_empty_embeddings(self):
ollama_embedding = OllamaEmbedding(model="test-model")
ollama_embedding.client = MagicMock()
@@ -90,6 +106,7 @@ class TestOllamaEmbedding(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "returned no embeddings"):
ollama_embedding.get_texts_embeddings(["a"])
+ @pytest.mark.contract
def test_async_get_text_embedding_requires_non_empty_embeddings(self):
ollama_embedding = OllamaEmbedding(model="test-model")
ollama_embedding.async_client = AsyncMock()
@@ -102,3 +119,16 @@ class TestOllamaEmbedding(unittest.TestCase):
import asyncio
asyncio.run(run_async_test())
+
+ @pytest.mark.contract
+ def test_async_get_text_embedding_requires_embed_method(self):
+ ollama_embedding = OllamaEmbedding(model="test-model")
+ ollama_embedding.async_client = object()
+
+ async def run_async_test():
+ with self.assertRaisesRegex(AttributeError, "required 'embed'
method"):
+ await ollama_embedding.async_get_text_embedding("a")
+
+ import asyncio
+
+ asyncio.run(run_async_test())
diff --git a/hugegraph-llm/src/tests/models/llms/test_litellm_client.py
b/hugegraph-llm/src/tests/models/llms/test_litellm_client.py
index 3b27d780..c32cb128 100644
--- a/hugegraph-llm/src/tests/models/llms/test_litellm_client.py
+++ b/hugegraph-llm/src/tests/models/llms/test_litellm_client.py
@@ -17,14 +17,36 @@
import asyncio
import unittest
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
+import pytest
from litellm.exceptions import APIError, BudgetExceededError
from hugegraph_llm.models.llms.litellm import LiteLLMClient
+pytestmark = pytest.mark.contract
+
class TestLiteLLMClient(unittest.TestCase):
+ def test_generate_returns_message_content(self):
+ client = LiteLLMClient(model_name="openai/gpt-4.1-mini")
+ response = MagicMock()
+ response.usage = {"prompt_tokens": 1}
+ response.choices = [MagicMock(message=MagicMock(content="hello"))]
+
+ with patch("hugegraph_llm.models.llms.litellm.completion",
return_value=response) as mock_completion:
+ result = client.generate(prompt="hello")
+
+ self.assertEqual(result, "hello")
+ mock_completion.assert_called_once()
+
+ def test_generate_malformed_sdk_response_raises_attribute_error(self):
+ client = LiteLLMClient(model_name="openai/gpt-4.1-mini")
+
+ with patch("hugegraph_llm.models.llms.litellm.completion",
return_value=object()):
+ with self.assertRaises(AttributeError):
+ client.generate(prompt="hello")
+
def test_budget_exceeded_error_is_not_retried(self):
client = LiteLLMClient(model_name="openai/gpt-4.1-mini")
error = BudgetExceededError(current_cost=2.0, max_budget=1.0)
diff --git a/hugegraph-llm/src/tests/models/llms/test_openai_client.py
b/hugegraph-llm/src/tests/models/llms/test_openai_client.py
index 20e5aaac..cee6e9e0 100644
--- a/hugegraph-llm/src/tests/models/llms/test_openai_client.py
+++ b/hugegraph-llm/src/tests/models/llms/test_openai_client.py
@@ -19,8 +19,12 @@ import asyncio
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
+import pytest
+
from hugegraph_llm.models.llms.openai import OpenAIClient
+pytestmark = pytest.mark.contract
+
class TestOpenAIClient(unittest.TestCase):
def setUp(self):
@@ -109,6 +113,18 @@ class TestOpenAIClient(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "Empty choices in LLM
response"):
openai_client.generate(prompt="What is the capital of France?")
+ @patch("hugegraph_llm.models.llms.openai.OpenAI")
+ def test_generate_malformed_sdk_response_raises_attribute_error(self,
mock_openai_class):
+ """Test malformed SDK responses surface the missing contract."""
+ mock_client = MagicMock()
+ mock_client.chat.completions.create.return_value = object()
+ mock_openai_class.return_value = mock_client
+
+ openai_client = OpenAIClient(model_name="gpt-3.5-turbo")
+
+ with self.assertRaises(AttributeError):
+ openai_client.generate(prompt="What is the capital of France?")
+
@patch("hugegraph_llm.models.llms.openai.AsyncOpenAI")
def test_agenerate(self, mock_async_openai_class):
"""Test agenerate method with mocked async OpenAI client."""
diff --git
a/hugegraph-llm/src/tests/operators/hugegraph_op/test_commit_to_hugegraph.py
b/hugegraph-llm/src/tests/operators/hugegraph_op/test_commit_to_hugegraph.py
index b13f9042..fbd9e3a5 100644
--- a/hugegraph-llm/src/tests/operators/hugegraph_op/test_commit_to_hugegraph.py
+++ b/hugegraph-llm/src/tests/operators/hugegraph_op/test_commit_to_hugegraph.py
@@ -19,10 +19,12 @@
import unittest
from unittest.mock import MagicMock, patch
+import pytest
from pyhugegraph.utils.exceptions import CreateError, NotFoundError
from hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph import
Commit2Graph
-from hugegraph_llm.operators.llm_op.property_graph_extract import
PropertyGraphExtract
+
+pytestmark = [pytest.mark.unit]
class TestCommit2Graph(unittest.TestCase):
@@ -298,256 +300,6 @@ class TestCommit2Graph(unittest.TestCase):
# Verify that edgeLabel was called for each edge label
self.assertEqual(schema_mocks["edge_label"].call_count, 1) # 1 edge
label
-
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._check_property_data_type")
-
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
- def test_load_into_graph(self, mock_handle_graph_creation,
mock_check_property_data_type):
- """Test load_into_graph method."""
- # Setup mocks
- mock_handle_graph_creation.return_value = MagicMock(id="vertex_id")
- mock_check_property_data_type.return_value = True
-
- # Create vertices with proper data types according to schema
- vertices = [
- {"label": "person", "properties": {"name": "Tom Hanks", "age":
67}},
- {"label": "movie", "properties": {"title": "Forrest Gump", "year":
1994}},
- ]
-
- edges = [
- {
- "label": "acted_in",
- "properties": {"role": "Forrest Gump"},
- "outV": "person:Tom Hanks", # Use the format expected by the
implementation
- "inV": "movie:Forrest Gump", # Use the format expected by the
implementation
- }
- ]
-
- # Call the method
- self.commit2graph.load_into_graph(vertices, edges, self.schema)
-
- # Verify that _handle_graph_creation was called for each vertex and
edge
- self.assertEqual(mock_handle_graph_creation.call_count, 3) # 2
vertices + 1 edge
-
-
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
- def test_load_into_graph_with_data_type_validation_success(self,
mock_handle_graph_creation):
- """Test load_into_graph method with successful data type validation."""
- # Setup mocks
- mock_handle_graph_creation.return_value = MagicMock(id="vertex_id")
-
- # Create vertices with correct data types matching schema expectations
- vertices = [
- {"label": "person", "properties": {"name": "Tom Hanks", "age":
67}}, # age: INT -> int
- {"label": "movie", "properties": {"title": "Forrest Gump", "year":
1994}}, # year: INT -> int
- ]
-
- edges = [
- {
- "label": "acted_in",
- "properties": {"role": "Forrest Gump"}, # role: TEXT -> str
- "outV": "person:Tom Hanks",
- "inV": "movie:Forrest Gump",
- }
- ]
-
- # Call the method - should succeed with correct data types
- self.commit2graph.load_into_graph(vertices, edges, self.schema)
-
- # Verify that _handle_graph_creation was called for each vertex and
edge
- self.assertEqual(mock_handle_graph_creation.call_count, 3) # 2
vertices + 1 edge
-
-
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
- def test_load_into_graph_maps_llm_vertex_ids_to_created_vertex_ids(self,
mock_handle_graph_creation):
- """Test edges use server-created vertex ids when LLM ids differ."""
- mock_handle_graph_creation.side_effect = [
- MagicMock(id="1:Tom Hanks"),
- MagicMock(id="2:Forrest Gump"),
- MagicMock(id="edge_id"),
- ]
-
- vertices = [
- {
- "id": "person:Tom Hanks",
- "label": "person",
- "properties": {"name": "Tom Hanks", "age": 67},
- },
- {
- "id": "movie:Forrest Gump",
- "label": "movie",
- "properties": {"title": "Forrest Gump", "year": 1994},
- },
- ]
- edges = [
- {
- "label": "acted_in",
- "properties": {"role": "Forrest Gump"},
- "outV": "person:Tom Hanks",
- "inV": "movie:Forrest Gump",
- }
- ]
-
- self.commit2graph.load_into_graph(vertices, edges, self.schema)
-
- self.assertEqual(vertices[0]["id"], "1:Tom Hanks")
- self.assertEqual(vertices[1]["id"], "2:Forrest Gump")
- mock_handle_graph_creation.assert_any_call(
- self.commit2graph.client.graph().addEdge,
- "acted_in",
- "1:Tom Hanks",
- "2:Forrest Gump",
- {"role": "Forrest Gump"},
- )
-
-
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
- def test_load_into_graph_uses_explicit_customize_string_ids(self,
mock_handle_graph_creation):
- """Test custom string ids are passed to HugeGraph when schema requires
them."""
- mock_handle_graph_creation.side_effect = [
- MagicMock(id="Tom Hanks"),
- MagicMock(id="Forrest Gump"),
- MagicMock(id="edge_id"),
- ]
- schema = {
- "propertykeys": [
- {"name": "name", "data_type": "TEXT", "cardinality": "SINGLE"},
- {"name": "title", "data_type": "TEXT", "cardinality":
"SINGLE"},
- ],
- "vertexlabels": [
- {
- "id": 7,
- "name": "person",
- "id_strategy": "CUSTOMIZE_STRING",
- "primary_keys": ["name"],
- "properties": ["name"],
- "nullable_keys": [],
- },
- {
- "id": 8,
- "name": "movie",
- "id_strategy": "CUSTOMIZE_STRING",
- "primary_keys": ["title"],
- "properties": ["title"],
- "nullable_keys": [],
- },
- ],
- "edgelabels": [{"name": "acted_in", "properties": [],
"source_label": "person", "target_label": "movie"}],
- }
- vertices = [
- {"id": "Tom Hanks", "label": "person", "properties": {"name": "Tom
Hanks"}},
- {"id": "Forrest Gump", "label": "movie", "properties": {"title":
"Forrest Gump"}},
- ]
- edges = [
- {
- "label": "acted_in",
- "properties": {},
- "outV": "Tom Hanks",
- "inV": "Forrest Gump",
- }
- ]
-
- self.commit2graph.load_into_graph(vertices, edges, schema)
-
- mock_handle_graph_creation.assert_any_call(
- self.commit2graph.client.graph().addVertex,
- "person",
- {"name": "Tom Hanks"},
- id="Tom Hanks",
- )
- mock_handle_graph_creation.assert_any_call(
- self.commit2graph.client.graph().addVertex,
- "movie",
- {"title": "Forrest Gump"},
- id="Forrest Gump",
- )
- mock_handle_graph_creation.assert_any_call(
- self.commit2graph.client.graph().addEdge,
- "acted_in",
- "Tom Hanks",
- "Forrest Gump",
- {},
- )
-
-
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
- def
test_load_into_graph_accepts_normalized_extraction_without_item_type(self,
mock_handle_graph_creation):
- """Test normalized LLM output without type fields can be committed."""
- mock_handle_graph_creation.side_effect = [
- MagicMock(id="1:Tom Hanks"),
- MagicMock(id="2:Forrest Gump"),
- MagicMock(id="edge_id"),
- ]
- llm_output = """{
- "vertices": [
- {
- "id": "person:Tom Hanks",
- "label": "person",
- "properties": {
- "name": "Tom Hanks",
- "age": 67
- }
- },
- {
- "id": "movie:Forrest Gump",
- "label": "movie",
- "properties": {
- "title": "Forrest Gump",
- "year": 1994
- }
- }
- ],
- "edges": [
- {
- "label": "acted_in",
- "outV": "person:Tom Hanks",
- "outVLabel": "person",
- "inV": "movie:Forrest Gump",
- "inVLabel": "movie",
- "properties": {
- "role": "Forrest Gump"
- }
- }
- ]
- }"""
-
- items =
PropertyGraphExtract(llm=MagicMock())._extract_and_filter_label(self.schema,
llm_output)
- vertices = [item for item in items if item["type"] == "vertex"]
- edges = [item for item in items if item["type"] == "edge"]
- self.assertEqual(edges[0]["outV"], "1:Tom Hanks")
- self.assertEqual(edges[0]["inV"], "2:Forrest Gump")
-
- self.commit2graph.load_into_graph(vertices, edges, self.schema)
-
- mock_handle_graph_creation.assert_any_call(
- self.commit2graph.client.graph().addEdge,
- "acted_in",
- "1:Tom Hanks",
- "2:Forrest Gump",
- {"role": "Forrest Gump"},
- )
-
-
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
- def test_load_into_graph_with_data_type_validation_failure(self,
mock_handle_graph_creation):
- """Test load_into_graph method with data type validation failure."""
- # Setup mocks
- mock_handle_graph_creation.return_value = MagicMock(id="vertex_id")
-
- # Create vertices with incorrect data types (strings for INT fields)
- vertices = [
- {"label": "person", "properties": {"name": "Tom Hanks", "age":
"67"}}, # age should be int, not str
- {"label": "movie", "properties": {"title": "Forrest Gump", "year":
"1994"}}, # year should be int, not str
- ]
-
- edges = [
- {
- "label": "acted_in",
- "properties": {"role": "Forrest Gump"},
- "outV": "person:Tom Hanks",
- "inV": "movie:Forrest Gump",
- }
- ]
-
- # Call the method - should skip vertices due to data type validation
failure
- self.commit2graph.load_into_graph(vertices, edges, self.schema)
-
- # Verify that _handle_graph_creation was called only for the edge
(vertices were skipped)
- self.assertEqual(mock_handle_graph_creation.call_count, 1) # Only 1
edge, vertices skipped
-
def test_check_property_data_type_success(self):
"""Test _check_property_data_type method with valid data types."""
# Test TEXT type
diff --git
a/hugegraph-llm/src/tests/operators/hugegraph_op/test_commit_to_hugegraph_load_into_graph.py
b/hugegraph-llm/src/tests/operators/hugegraph_op/test_commit_to_hugegraph_load_into_graph.py
new file mode 100644
index 00000000..6605bec2
--- /dev/null
+++
b/hugegraph-llm/src/tests/operators/hugegraph_op/test_commit_to_hugegraph_load_into_graph.py
@@ -0,0 +1,366 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# pylint: disable=protected-access,no-member
+import unittest
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph import
Commit2Graph
+from hugegraph_llm.operators.llm_op.property_graph_extract import
PropertyGraphExtract
+
+pytestmark = [pytest.mark.unit]
+
+
+class TestCommit2GraphLoadIntoGraph(unittest.TestCase):
+ def setUp(self):
+ self.mock_client = MagicMock()
+ self.mock_schema = MagicMock()
+ self.mock_client.schema.return_value = self.mock_schema
+
+ with patch(
+
"hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.PyHugeClient",
return_value=self.mock_client
+ ):
+ self.commit2graph = Commit2Graph()
+
+ self.schema = {
+ "propertykeys": [
+ {"name": "name", "data_type": "TEXT", "cardinality": "SINGLE"},
+ {"name": "age", "data_type": "INT", "cardinality": "SINGLE"},
+ {"name": "title", "data_type": "TEXT", "cardinality":
"SINGLE"},
+ {"name": "year", "data_type": "INT", "cardinality": "SINGLE"},
+ {"name": "role", "data_type": "TEXT", "cardinality": "SINGLE"},
+ ],
+ "vertexlabels": [
+ {
+ "id": 1,
+ "name": "person",
+ "properties": ["name", "age"],
+ "primary_keys": ["name"],
+ "nullable_keys": ["age"],
+ "id_strategy": "PRIMARY_KEY",
+ },
+ {
+ "id": 2,
+ "name": "movie",
+ "properties": ["title", "year"],
+ "primary_keys": ["title"],
+ "nullable_keys": ["year"],
+ "id_strategy": "PRIMARY_KEY",
+ },
+ ],
+ "edgelabels": [
+ {"name": "acted_in", "properties": ["role"], "source_label":
"person", "target_label": "movie"}
+ ],
+ }
+
+
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._check_property_data_type")
+
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
+ def test_load_into_graph(self, mock_handle_graph_creation,
mock_check_property_data_type):
+ """Test load_into_graph method."""
+ mock_handle_graph_creation.return_value = MagicMock(id="vertex_id")
+ mock_check_property_data_type.return_value = True
+
+ vertices = [
+ {"label": "person", "properties": {"name": "Tom Hanks", "age":
67}},
+ {"label": "movie", "properties": {"title": "Forrest Gump", "year":
1994}},
+ ]
+ edges = [
+ {
+ "label": "acted_in",
+ "properties": {"role": "Forrest Gump"},
+ "outV": "person:Tom Hanks",
+ "inV": "movie:Forrest Gump",
+ }
+ ]
+
+ self.commit2graph.load_into_graph(vertices, edges, self.schema)
+
+ self.assertEqual(mock_handle_graph_creation.call_count, 3)
+
+
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
+ def test_load_into_graph_with_data_type_validation_success(self,
mock_handle_graph_creation):
+ """Test load_into_graph method with successful data type validation."""
+ mock_handle_graph_creation.return_value = MagicMock(id="vertex_id")
+
+ vertices = [
+ {"label": "person", "properties": {"name": "Tom Hanks", "age":
67}},
+ {"label": "movie", "properties": {"title": "Forrest Gump", "year":
1994}},
+ ]
+ edges = [
+ {
+ "label": "acted_in",
+ "properties": {"role": "Forrest Gump"},
+ "outV": "person:Tom Hanks",
+ "inV": "movie:Forrest Gump",
+ }
+ ]
+
+ self.commit2graph.load_into_graph(vertices, edges, self.schema)
+
+ self.assertEqual(mock_handle_graph_creation.call_count, 3)
+
+
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
+ def test_load_into_graph_maps_llm_vertex_ids_to_created_vertex_ids(self,
mock_handle_graph_creation):
+ """Test edges use server-created vertex ids when LLM ids differ."""
+ mock_handle_graph_creation.side_effect = [
+ MagicMock(id="1:Tom Hanks"),
+ MagicMock(id="2:Forrest Gump"),
+ MagicMock(id="edge_id"),
+ ]
+
+ vertices = [
+ {"id": "person:Tom Hanks", "label": "person", "properties":
{"name": "Tom Hanks", "age": 67}},
+ {"id": "movie:Forrest Gump", "label": "movie", "properties":
{"title": "Forrest Gump", "year": 1994}},
+ ]
+ edges = [
+ {
+ "label": "acted_in",
+ "properties": {"role": "Forrest Gump"},
+ "outV": "person:Tom Hanks",
+ "inV": "movie:Forrest Gump",
+ }
+ ]
+
+ self.commit2graph.load_into_graph(vertices, edges, self.schema)
+
+ self.assertEqual(vertices[0]["id"], "1:Tom Hanks")
+ self.assertEqual(vertices[1]["id"], "2:Forrest Gump")
+ mock_handle_graph_creation.assert_any_call(
+ self.commit2graph.client.graph().addEdge,
+ "acted_in",
+ "1:Tom Hanks",
+ "2:Forrest Gump",
+ {"role": "Forrest Gump"},
+ )
+
+
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
+ def
test_load_into_graph_maps_multiple_primary_keys_to_created_vertex_ids(self,
mock_handle_graph_creation):
+ mock_handle_graph_creation.side_effect = [
+ MagicMock(id="1:Tom!Hanks"),
+ MagicMock(id="2:Forrest Gump"),
+ MagicMock(id="edge_id"),
+ ]
+ schema = {
+ "propertykeys": [
+ {"name": "first", "data_type": "TEXT", "cardinality":
"SINGLE"},
+ {"name": "last", "data_type": "TEXT", "cardinality": "SINGLE"},
+ {"name": "title", "data_type": "TEXT", "cardinality":
"SINGLE"},
+ ],
+ "vertexlabels": [
+ {
+ "id": 1,
+ "name": "person",
+ "properties": ["first", "last"],
+ "primary_keys": ["first", "last"],
+ "nullable_keys": [],
+ "id_strategy": "PRIMARY_KEY",
+ },
+ {
+ "id": 2,
+ "name": "movie",
+ "properties": ["title"],
+ "primary_keys": ["title"],
+ "nullable_keys": [],
+ "id_strategy": "PRIMARY_KEY",
+ },
+ ],
+ "edgelabels": [{"name": "acted_in", "properties": [],
"source_label": "person", "target_label": "movie"}],
+ }
+ vertices = [
+ {"label": "person", "properties": {"first": "Tom", "last":
"Hanks"}},
+ {"label": "movie", "properties": {"title": "Forrest Gump"}},
+ ]
+ edges = [
+ {
+ "label": "acted_in",
+ "properties": {},
+ "outV": "person:Tom!Hanks",
+ "inV": "movie:Forrest Gump",
+ }
+ ]
+
+ self.commit2graph.load_into_graph(vertices, edges, schema)
+
+ mock_handle_graph_creation.assert_any_call(
+ self.commit2graph.client.graph().addEdge,
+ "acted_in",
+ "1:Tom!Hanks",
+ "2:Forrest Gump",
+ {},
+ )
+
+
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
+ def test_load_into_graph_uses_explicit_customize_string_ids(self,
mock_handle_graph_creation):
+ """Test custom string ids are passed to HugeGraph when schema requires
them."""
+ mock_handle_graph_creation.side_effect = [
+ MagicMock(id="Tom Hanks"),
+ MagicMock(id="Forrest Gump"),
+ MagicMock(id="edge_id"),
+ ]
+ schema = {
+ "propertykeys": [
+ {"name": "name", "data_type": "TEXT", "cardinality": "SINGLE"},
+ {"name": "title", "data_type": "TEXT", "cardinality":
"SINGLE"},
+ ],
+ "vertexlabels": [
+ {
+ "id": 7,
+ "name": "person",
+ "id_strategy": "CUSTOMIZE_STRING",
+ "primary_keys": ["name"],
+ "properties": ["name"],
+ "nullable_keys": [],
+ },
+ {
+ "id": 8,
+ "name": "movie",
+ "id_strategy": "CUSTOMIZE_STRING",
+ "primary_keys": ["title"],
+ "properties": ["title"],
+ "nullable_keys": [],
+ },
+ ],
+ "edgelabels": [{"name": "acted_in", "properties": [],
"source_label": "person", "target_label": "movie"}],
+ }
+ vertices = [
+ {"id": "Tom Hanks", "label": "person", "properties": {"name": "Tom
Hanks"}},
+ {"id": "Forrest Gump", "label": "movie", "properties": {"title":
"Forrest Gump"}},
+ ]
+ edges = [{"label": "acted_in", "properties": {}, "outV": "Tom Hanks",
"inV": "Forrest Gump"}]
+
+ self.commit2graph.load_into_graph(vertices, edges, schema)
+
+ mock_handle_graph_creation.assert_any_call(
+ self.commit2graph.client.graph().addVertex,
+ "person",
+ {"name": "Tom Hanks"},
+ id="Tom Hanks",
+ )
+ mock_handle_graph_creation.assert_any_call(
+ self.commit2graph.client.graph().addVertex,
+ "movie",
+ {"title": "Forrest Gump"},
+ id="Forrest Gump",
+ )
+ mock_handle_graph_creation.assert_any_call(
+ self.commit2graph.client.graph().addEdge,
+ "acted_in",
+ "Tom Hanks",
+ "Forrest Gump",
+ {},
+ )
+
+
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
+ def
test_load_into_graph_accepts_normalized_extraction_without_item_type(self,
mock_handle_graph_creation):
+ """Test normalized LLM output without type fields can be committed."""
+ mock_handle_graph_creation.side_effect = [
+ MagicMock(id="1:Tom Hanks"),
+ MagicMock(id="2:Forrest Gump"),
+ MagicMock(id="edge_id"),
+ ]
+ llm_output = """{
+ "vertices": [
+ {
+ "id": "person:Tom Hanks",
+ "label": "person",
+ "properties": {
+ "name": "Tom Hanks",
+ "age": 67
+ }
+ },
+ {
+ "id": "movie:Forrest Gump",
+ "label": "movie",
+ "properties": {
+ "title": "Forrest Gump",
+ "year": 1994
+ }
+ }
+ ],
+ "edges": [
+ {
+ "label": "acted_in",
+ "outV": "person:Tom Hanks",
+ "outVLabel": "person",
+ "inV": "movie:Forrest Gump",
+ "inVLabel": "movie",
+ "properties": {
+ "role": "Forrest Gump"
+ }
+ }
+ ]
+ }"""
+
+ items =
PropertyGraphExtract(llm=MagicMock())._extract_and_filter_label(self.schema,
llm_output)
+ vertices = [item for item in items if item["type"] == "vertex"]
+ edges = [item for item in items if item["type"] == "edge"]
+ self.assertEqual(edges[0]["outV"], "1:Tom Hanks")
+ self.assertEqual(edges[0]["inV"], "2:Forrest Gump")
+
+ self.commit2graph.load_into_graph(vertices, edges, self.schema)
+
+ mock_handle_graph_creation.assert_any_call(
+ self.commit2graph.client.graph().addEdge,
+ "acted_in",
+ "1:Tom Hanks",
+ "2:Forrest Gump",
+ {"role": "Forrest Gump"},
+ )
+
+
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
+ def
test_load_into_graph_raises_explicit_error_when_vertex_creation_fails(self,
mock_handle_graph_creation):
+ """Test failed vertex creation is reported before edge creation."""
+ mock_handle_graph_creation.return_value = None
+
+ vertices = [{"label": "person", "properties": {"name": "Tom Hanks",
"age": 67}}]
+ edges = [
+ {
+ "label": "acted_in",
+ "properties": {"role": "Forrest Gump"},
+ "outV": "person:Tom Hanks",
+ "inV": "movie:Forrest Gump",
+ }
+ ]
+
+ with self.assertRaisesRegex(ValueError, "Failed to create vertex"):
+ self.commit2graph.load_into_graph(vertices, edges, self.schema)
+
+ mock_handle_graph_creation.assert_called_once()
+
+
@patch("hugegraph_llm.operators.hugegraph_op.commit_to_hugegraph.Commit2Graph._handle_graph_creation")
+ def test_load_into_graph_with_data_type_validation_failure(self,
mock_handle_graph_creation):
+ """Test load_into_graph method with data type validation failure."""
+ mock_handle_graph_creation.return_value = MagicMock(id="vertex_id")
+
+ vertices = [
+ {"label": "person", "properties": {"name": "Tom Hanks", "age":
"67"}},
+ {"label": "movie", "properties": {"title": "Forrest Gump", "year":
"1994"}},
+ ]
+ edges = [
+ {
+ "label": "acted_in",
+ "properties": {"role": "Forrest Gump"},
+ "outV": "person:Tom Hanks",
+ "inV": "movie:Forrest Gump",
+ }
+ ]
+
+ self.commit2graph.load_into_graph(vertices, edges, self.schema)
+
+ self.assertEqual(mock_handle_graph_creation.call_count, 1)
diff --git
a/hugegraph-llm/src/tests/operators/hugegraph_op/test_fetch_graph_data.py
b/hugegraph-llm/src/tests/operators/hugegraph_op/test_fetch_graph_data.py
index 8527502d..44b51f44 100644
--- a/hugegraph-llm/src/tests/operators/hugegraph_op/test_fetch_graph_data.py
+++ b/hugegraph-llm/src/tests/operators/hugegraph_op/test_fetch_graph_data.py
@@ -18,8 +18,12 @@
import unittest
from unittest.mock import MagicMock
+import pytest
+
from hugegraph_llm.operators.hugegraph_op.fetch_graph_data import
FetchGraphData
+pytestmark = [pytest.mark.unit]
+
class TestFetchGraphData(unittest.TestCase):
def setUp(self):
diff --git
a/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py
b/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py
index b77ae01f..05d9659f 100644
--- a/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py
+++ b/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py
@@ -18,8 +18,12 @@
import unittest
from unittest.mock import MagicMock, patch
+import pytest
+
from hugegraph_llm.operators.hugegraph_op.schema_manager import SchemaManager
+pytestmark = [pytest.mark.unit]
+
class TestSchemaManager(unittest.TestCase):
def setUp(self):
diff --git a/hugegraph-llm/src/tests/operators/llm_op/test_disambiguate_data.py
b/hugegraph-llm/src/tests/operators/llm_op/test_disambiguate_data.py
index 04ee4214..4bb3c321 100644
--- a/hugegraph-llm/src/tests/operators/llm_op/test_disambiguate_data.py
+++ b/hugegraph-llm/src/tests/operators/llm_op/test_disambiguate_data.py
@@ -17,8 +17,12 @@
import unittest
+import pytest
+
from hugegraph_llm.operators.llm_op.disambiguate_data import DisambiguateData
+pytestmark = pytest.mark.contract
+
class TestDisambiguateData(unittest.TestCase):
def setUp(self):
diff --git a/hugegraph-llm/src/tests/operators/llm_op/test_gremlin_generate.py
b/hugegraph-llm/src/tests/operators/llm_op/test_gremlin_generate.py
index 557eade8..9a6e7659 100644
--- a/hugegraph-llm/src/tests/operators/llm_op/test_gremlin_generate.py
+++ b/hugegraph-llm/src/tests/operators/llm_op/test_gremlin_generate.py
@@ -21,8 +21,13 @@ import json
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
+import pytest
+
from hugegraph_llm.models.llms.base import BaseLLM
from hugegraph_llm.operators.llm_op.gremlin_generate import
GremlinGenerateSynthesize
+from tests.fixtures.fake_llm import FakeLLM
+
+pytestmark = pytest.mark.contract
class TestGremlinGenerateSynthesize(unittest.TestCase):
@@ -122,6 +127,30 @@ class TestGremlinGenerateSynthesize(unittest.TestCase):
result = generator._extract_response("No gremlin code block here")
self.assertEqual(result, "No gremlin code block here")
+ def test_extract_gremlin_from_explanation_plus_block(self):
+ """Test explanation text is stripped when a Gremlin block exists."""
+ generator = GremlinGenerateSynthesize(llm=self.mock_llm)
+ response = "Use this
query:\n```gremlin\ng.V().hasLabel('person')\n```\nIt returns people."
+
+ self.assertEqual(generator._extract_response(response),
"g.V().hasLabel('person')")
+
+ def test_extract_gremlin_uses_first_candidate_block(self):
+ """Test multiple candidate blocks resolve to the first public
result."""
+ generator = GremlinGenerateSynthesize(llm=self.mock_llm)
+ response =
"```gremlin\ng.V().limit(1)\n```\n```gremlin\ng.E().limit(1)\n```"
+
+ self.assertEqual(generator._extract_response(response),
"g.V().limit(1)")
+
+ def test_run_with_fake_llm_empty_output_returns_empty_gremlin(self):
+ """Test empty LLM output is exposed as an empty Gremlin string."""
+ generator = GremlinGenerateSynthesize(llm=FakeLLM(["", ""]))
+
+ result = generator.run({"query": self.query})
+
+ self.assertEqual(result["result"], "")
+ self.assertEqual(result["raw_result"], "")
+ self.assertEqual(result["call_count"], 2)
+
def test_format_examples(self):
"""Test the _format_examples method."""
generator = GremlinGenerateSynthesize(llm=self.mock_llm)
diff --git a/hugegraph-llm/src/tests/operators/llm_op/test_info_extract.py
b/hugegraph-llm/src/tests/operators/llm_op/test_info_extract.py
index 80359bd5..e352a097 100644
--- a/hugegraph-llm/src/tests/operators/llm_op/test_info_extract.py
+++ b/hugegraph-llm/src/tests/operators/llm_op/test_info_extract.py
@@ -17,12 +17,16 @@
import unittest
+import pytest
+
from hugegraph_llm.operators.llm_op.info_extract import (
InfoExtract,
extract_triples_by_regex,
extract_triples_by_regex_with_schema,
)
+pytestmark = pytest.mark.contract
+
class TestInfoExtract(unittest.TestCase):
def setUp(self):
diff --git a/hugegraph-llm/src/tests/operators/llm_op/test_keyword_extract.py
b/hugegraph-llm/src/tests/operators/llm_op/test_keyword_extract.py
index a2cc1bb5..6dbeb01e 100644
--- a/hugegraph-llm/src/tests/operators/llm_op/test_keyword_extract.py
+++ b/hugegraph-llm/src/tests/operators/llm_op/test_keyword_extract.py
@@ -20,8 +20,13 @@
import unittest
from unittest.mock import MagicMock, patch
+import pytest
+
from hugegraph_llm.models.llms.base import BaseLLM
from hugegraph_llm.operators.llm_op.keyword_extract import KeywordExtract
+from tests.fixtures.fake_llm import FakeLLM
+
+pytestmark = pytest.mark.contract
class TestKeywordExtract(unittest.TestCase):
@@ -252,6 +257,32 @@ KEYWORDS: artificial intelligence:0.9, missing-score, bad
score:not-a-number, gr
self.assertNotIn("missing-score", keywords)
self.assertNotIn("bad score", keywords)
+ def test_run_with_fake_llm_empty_output_returns_empty_keywords(self):
+ """Test empty provider output follows the public empty-list
fallback."""
+ extractor = KeywordExtract(text=self.query, llm=FakeLLM([""]),
max_keywords=5)
+
+ result = extractor.run({})
+
+ self.assertEqual(result["keywords"], [])
+ self.assertEqual(result["call_count"], 1)
+
+ def test_extract_keywords_from_response_deduplicates_by_last_score(self):
+ """Test duplicate keyword entries are normalized by dict semantics."""
+ response = "KEYWORDS: graph:0.3, vector:0.4, graph:0.9"
+
+ keywords = self.extractor._extract_keywords_from_response(response,
lowercase=False, start_token="KEYWORDS:")
+
+ self.assertEqual(keywords["graph"], 0.9)
+ self.assertEqual(keywords["vector"], 0.4)
+
+ def
test_extract_keywords_from_non_list_provider_text_returns_empty_mapping(self):
+ """Test non keyword-list provider text does not leak as a keyword."""
+ response = "The answer is probably graph databases."
+
+ keywords = self.extractor._extract_keywords_from_response(response,
lowercase=False, start_token="KEYWORDS:")
+
+ self.assertEqual(keywords, {})
+
def test_extract_keywords_from_response_without_start_token(self):
"""Test _extract_keywords_from_response method without start token."""
response = "artificial intelligence:0.9, machine learning:0.8, neural
networks:0.7"
diff --git
a/hugegraph-llm/src/tests/operators/llm_op/test_property_graph_extract.py
b/hugegraph-llm/src/tests/operators/llm_op/test_property_graph_extract.py
index 3eb49026..3b4bd25e 100644
--- a/hugegraph-llm/src/tests/operators/llm_op/test_property_graph_extract.py
+++ b/hugegraph-llm/src/tests/operators/llm_op/test_property_graph_extract.py
@@ -21,6 +21,8 @@ import json
import unittest
from unittest.mock import MagicMock, patch
+import pytest
+
from hugegraph_llm.models.llms.base import BaseLLM
from hugegraph_llm.operators.llm_op.property_graph_extract import (
PropertyGraphExtract,
@@ -28,6 +30,9 @@ from hugegraph_llm.operators.llm_op.property_graph_extract
import (
generate_extract_property_graph_prompt,
split_text,
)
+from tests.fixtures.fake_llm import FakeLLM
+
+pytestmark = pytest.mark.contract
class TestPropertyGraphExtract(unittest.TestCase):
@@ -389,6 +394,127 @@ Hope this helps."""
self.assertEqual(result, [])
+ def test_property_graph_extract_strips_fenced_json_with_fake_llm(self):
+ """Test fake LLM fixture drives fenced JSON parser behavior."""
+ llm = FakeLLM(['```json\n{"vertices": [], "edges": []}\n```'])
+ extractor = PropertyGraphExtract(llm=llm)
+
+ result = extractor._extract_and_filter_label({"vertexlabels": [],
"edgelabels": []}, llm.generate())
+
+ self.assertEqual(result, [])
+ self.assertEqual(len(llm.calls), 1)
+
+ def test_extract_and_filter_label_resolves_numeric_vertex_ids(self):
+ """Test numeric LLM vertex ids can be resolved before commit."""
+ extractor = PropertyGraphExtract(llm=self.mock_llm)
+ text = """{
+ "vertices": [
+ {
+ "id": 101,
+ "label": "person",
+ "properties": {
+ "name": "Tom Hanks"
+ }
+ },
+ {
+ "id": 202,
+ "label": "movie",
+ "properties": {
+ "title": "Forrest Gump"
+ }
+ }
+ ],
+ "edges": [
+ {
+ "label": "acted_in",
+ "outV": 101,
+ "outVLabel": "person",
+ "inV": 202,
+ "inVLabel": "movie",
+ "properties": {
+ "role": "Forrest Gump"
+ }
+ }
+ ]
+ }"""
+
+ result = extractor._extract_and_filter_label(self.schema, text)
+
+ self.assertEqual(result[0]["id"], "1:Tom Hanks")
+ self.assertEqual(result[1]["id"], "2:Forrest Gump")
+ self.assertEqual(result[2]["outV"], "1:Tom Hanks")
+ self.assertEqual(result[2]["inV"], "2:Forrest Gump")
+
+ def
test_extract_and_filter_label_preserves_duplicate_items_deterministically(self):
+ """Test duplicate vertices and edges are parsed in stable order."""
+ extractor = PropertyGraphExtract(llm=self.mock_llm)
+ text = """{
+ "vertices": [
+ {
+ "label": "person",
+ "properties": {
+ "name": "Tom Hanks"
+ }
+ },
+ {
+ "label": "person",
+ "properties": {
+ "name": "Tom Hanks"
+ }
+ },
+ {
+ "label": "movie",
+ "properties": {
+ "title": "Forrest Gump"
+ }
+ }
+ ],
+ "edges": [
+ {
+ "label": "acted_in",
+ "properties": {
+ "role": "Forrest Gump"
+ },
+ "source": {
+ "label": "person",
+ "properties": {
+ "name": "Tom Hanks"
+ }
+ },
+ "target": {
+ "label": "movie",
+ "properties": {
+ "title": "Forrest Gump"
+ }
+ }
+ },
+ {
+ "label": "acted_in",
+ "properties": {
+ "role": "Forrest Gump"
+ },
+ "source": {
+ "label": "person",
+ "properties": {
+ "name": "Tom Hanks"
+ }
+ },
+ "target": {
+ "label": "movie",
+ "properties": {
+ "title": "Forrest Gump"
+ }
+ }
+ }
+ ]
+ }"""
+
+ result = extractor._extract_and_filter_label(self.schema, text)
+
+ self.assertEqual([item["type"] for item in result], ["vertex",
"vertex", "vertex", "edge", "edge"])
+ self.assertEqual(result[0]["id"], "1:Tom Hanks")
+ self.assertEqual(result[1]["id"], "1:Tom Hanks")
+
def test_extract_and_filter_label_infers_type_from_grouped_arrays(self):
"""Infer item type from vertices/edges containers when LLM omits it."""
extractor = PropertyGraphExtract(llm=self.mock_llm)
diff --git a/hugegraph-python-client/src/pyhugegraph/utils/util.py
b/hugegraph-python-client/src/pyhugegraph/utils/util.py
index b4a6f644..ffe0ff90 100644
--- a/hugegraph-python-client/src/pyhugegraph/utils/util.py
+++ b/hugegraph-python-client/src/pyhugegraph/utils/util.py
@@ -100,14 +100,17 @@ class ResponseValidation:
if not self._strict and response.status_code == 404:
log.info("Resource %s not found (404)", path)
else:
+ if response.status_code == 401:
+ check_if_authorized(response)
+
try:
body = response.json()
if isinstance(body, dict):
status = body.get("status")
status_message = status.get("message") if
isinstance(status, dict) else None
details = (
- body.get("exception")
- or body.get("message")
+ body.get("message")
+ or body.get("exception")
or status_message
or response.text
or "unknown error"
@@ -129,9 +132,7 @@ class ResponseValidation:
if response.status_code == 404:
raise NotFoundError(response.content) from e
- if response.status_code == 400:
- raise Exception(f"Server Exception: {details}") from e
- raise e
+ raise Exception(f"Server Exception: {details}") from e
except Exception: # pylint: disable=broad-exception-caught
log.error("Unhandled exception occurred: %s",
traceback.format_exc())
diff --git a/hugegraph-python-client/src/tests/api/test_auth.py
b/hugegraph-python-client/src/tests/api/test_auth.py
index 1f6dec95..22218ded 100644
--- a/hugegraph-python-client/src/tests/api/test_auth.py
+++ b/hugegraph-python-client/src/tests/api/test_auth.py
@@ -18,10 +18,13 @@
import unittest
+import pytest
from pyhugegraph.utils.exceptions import NotFoundError
from ..client_utils import ClientUtils
+pytestmark = [pytest.mark.integration, pytest.mark.hugegraph]
+
class TestAuthManager(unittest.TestCase):
client = None
diff --git a/hugegraph-python-client/src/tests/api/test_auth_routing.py
b/hugegraph-python-client/src/tests/api/test_auth_routing.py
index e7a6e105..1fcd5a3d 100644
--- a/hugegraph-python-client/src/tests/api/test_auth_routing.py
+++ b/hugegraph-python-client/src/tests/api/test_auth_routing.py
@@ -20,6 +20,8 @@ from urllib.parse import urljoin
import pytest
from pyhugegraph.api.auth import AuthManager
+pytestmark = pytest.mark.contract
+
class DummyCfg:
def __init__(self, url, graphspace, gs_supported, graph_name):
diff --git a/hugegraph-python-client/src/tests/api/test_graph.py
b/hugegraph-python-client/src/tests/api/test_graph.py
index a66c3fb9..4c93fe58 100644
--- a/hugegraph-python-client/src/tests/api/test_graph.py
+++ b/hugegraph-python-client/src/tests/api/test_graph.py
@@ -17,10 +17,13 @@
import unittest
+import pytest
from pyhugegraph.utils.exceptions import NotFoundError
from ..client_utils import ClientUtils
+pytestmark = [pytest.mark.integration, pytest.mark.hugegraph]
+
class TestGraphManager(unittest.TestCase):
client = None
@@ -160,3 +163,15 @@ class TestGraphManager(unittest.TestCase):
edge2 = self.graph.addEdge("knows", vertex2.id, vertex1.id, {"date":
"2012-01-10"})
edges = self.graph.getEdgesById([edge1.id, edge2.id])
self.assertEqual(len(edges), 2)
+
+
+def test_graph_supports_primary_key_and_custom_string_id(client_utils):
+ graph = client_utils.graph
+ graph.addVertex("person", {"name": "quality_marko", "age": 29, "city":
"Beijing"})
+ person = graph.getVertexByCondition(label="person", properties={"name":
"quality_marko"}, limit=1)[0]
+ assert person.id is not None
+
+ graph.addVertex("book", {"name": "Quality Book", "price": 100},
id="quality-book-1")
+ book = graph.getVertexById("quality-book-1")
+ assert book.id == "quality-book-1"
+ assert book.properties["name"] == "Quality Book"
diff --git a/hugegraph-python-client/src/tests/api/test_graphs.py
b/hugegraph-python-client/src/tests/api/test_graphs.py
index 13fe53b0..54bf10b6 100644
--- a/hugegraph-python-client/src/tests/api/test_graphs.py
+++ b/hugegraph-python-client/src/tests/api/test_graphs.py
@@ -17,8 +17,12 @@
import unittest
+import pytest
+
from ..client_utils import ClientUtils
+pytestmark = [pytest.mark.integration, pytest.mark.hugegraph]
+
class TestGraphsManager(unittest.TestCase):
client = None
diff --git a/hugegraph-python-client/src/tests/api/test_gremlin.py
b/hugegraph-python-client/src/tests/api/test_gremlin.py
index 346812b5..1eb4a21d 100644
--- a/hugegraph-python-client/src/tests/api/test_gremlin.py
+++ b/hugegraph-python-client/src/tests/api/test_gremlin.py
@@ -24,6 +24,8 @@ from pyhugegraph.utils.exceptions import NotFoundError
from ..client_utils import ClientUtils
+pytestmark = [pytest.mark.integration, pytest.mark.hugegraph]
+
class TestGremlin(unittest.TestCase):
client = None
@@ -134,3 +136,11 @@ class TestGremlinSetupBehavior(unittest.TestCase):
client.gremlin = mock.Mock()
with mock.patch.dict(os.environ, {"SKIP_GREMLIN_TESTS": "true"}),
self.assertRaises(unittest.SkipTest):
TestGremlin.setUpClass()
+
+
+def test_gremlin_error_surface_is_explicit(client_utils):
+ with pytest.raises(NotFoundError) as exc_info:
+ client_utils.gremlin.exec("g.V2()")
+
+ message = str(exc_info.value)
+ assert "g.V2" in message or "No signature" in message or "NotFound" in
message
diff --git a/hugegraph-python-client/src/tests/api/test_metric.py
b/hugegraph-python-client/src/tests/api/test_metric.py
index 3e1a67ad..4970152d 100644
--- a/hugegraph-python-client/src/tests/api/test_metric.py
+++ b/hugegraph-python-client/src/tests/api/test_metric.py
@@ -17,8 +17,12 @@
import unittest
+import pytest
+
from ..client_utils import ClientUtils
+pytestmark = [pytest.mark.integration, pytest.mark.hugegraph]
+
class TestMetricsManager(unittest.TestCase):
client = None
diff --git a/hugegraph-python-client/src/tests/api/test_response_validation.py
b/hugegraph-python-client/src/tests/api/test_response_validation.py
index 759d9718..77dc3c9a 100644
--- a/hugegraph-python-client/src/tests/api/test_response_validation.py
+++ b/hugegraph-python-client/src/tests/api/test_response_validation.py
@@ -18,9 +18,13 @@
import unittest
from unittest.mock import Mock
+import pytest
import requests
+from pyhugegraph.utils.exceptions import NotAuthorizedError
from pyhugegraph.utils.util import ResponseValidation
+pytestmark = pytest.mark.contract
+
class TestResponseValidation(unittest.TestCase):
def _mock_error_response(self, body, text):
@@ -51,6 +55,43 @@ class TestResponseValidation(unittest.TestCase):
with self.assertRaisesRegex(Exception, "Server Exception: bad
gremlin"):
validator(response, "POST", "/gremlin")
+ def test_backend_error_envelope_preserves_message(self):
+ response = Mock(spec=requests.Response)
+ response.status_code = 500
+ response.text = '{"exception":"BackendException","message":"quality
failure"}'
+ response.content = response.text.encode("utf-8")
+ response.json.return_value = {"exception": "BackendException",
"message": "quality failure"}
+ response.request = Mock(body='{"gremlin":"g.V2()"}',
url="http://127.0.0.1:8080/gremlin")
+ response.raise_for_status.side_effect =
requests.exceptions.HTTPError("500 Server Error")
+ validator = ResponseValidation()
+
+ with pytest.raises(Exception) as exc_info:
+ validator(response, method="POST", path="/gremlin")
+
+ assert "quality failure" in str(exc_info.value)
+
+ def test_malformed_error_body_uses_response_text(self):
+ response = self._mock_error_response(ValueError("not json"), "not
json")
+ response.json.side_effect = ValueError("not json")
+ validator = ResponseValidation()
+
+ with self.assertRaisesRegex(Exception, "Server Exception: not json"):
+ validator(response, "POST", "/gremlin")
+
+ def test_unauthorized_error_preserves_not_authorized_type(self):
+ response = Mock(spec=requests.Response)
+ response.status_code = 401
+ response.text =
'{"exception":"NotAuthorizedException","message":"Authentication failed"}'
+ response.content = response.text.encode("utf-8")
+ response.request = Mock(body="Empty body",
url="http://127.0.0.1:8080/graphs")
+ response.raise_for_status.side_effect =
requests.exceptions.HTTPError("401 Client Error")
+ validator = ResponseValidation()
+
+ with pytest.raises(NotAuthorizedError) as exc_info:
+ validator(response, method="GET", path="/graphs")
+
+ assert "Please check your username and password" in str(exc_info.value)
+
if __name__ == "__main__":
unittest.main()
diff --git a/hugegraph-python-client/src/tests/api/test_schema.py
b/hugegraph-python-client/src/tests/api/test_schema.py
index 4f91822c..fa1343bb 100644
--- a/hugegraph-python-client/src/tests/api/test_schema.py
+++ b/hugegraph-python-client/src/tests/api/test_schema.py
@@ -16,9 +16,14 @@
# under the License.
import unittest
+from contextlib import suppress
+
+import pytest
from ..client_utils import ClientUtils
+pytestmark = [pytest.mark.integration, pytest.mark.hugegraph]
+
class TestSchemaManager(unittest.TestCase):
client = None
@@ -79,3 +84,40 @@ class TestSchemaManager(unittest.TestCase):
def test_get_index_label(self):
index_label = self.schema.getIndexLabel("personByCity")
self.assertEqual(index_label.name, "personByCity")
+
+
+def test_schema_create_and_fetch_property_vertex_edge_index(client_utils):
+ schema = client_utils.schema
+ try:
+ schema.propertyKey("quality_name").asText().ifNotExist().create()
+ schema.propertyKey("quality_score").asInt().ifNotExist().create()
+ schema.vertexLabel("quality_person").properties("quality_name",
"quality_score").primaryKeys(
+ "quality_name"
+ ).ifNotExist().create()
+
schema.edgeLabel("quality_knows").sourceLabel("quality_person").targetLabel(
+ "quality_person"
+ ).ifNotExist().create()
+ schema.indexLabel("quality_person_by_score").onV("quality_person").by(
+ "quality_score"
+ ).range().ifNotExist().create()
+
+ full_schema = schema.getSchema()
+
+ assert "propertykeys" in full_schema
+ assert "vertexlabels" in full_schema
+ assert "edgelabels" in full_schema
+ assert "indexlabels" in full_schema
+ assert schema.getPropertyKey("quality_name").name == "quality_name"
+ assert schema.getVertexLabel("quality_person").name == "quality_person"
+ assert schema.getEdgeLabel("quality_knows").name == "quality_knows"
+ assert schema.getIndexLabel("quality_person_by_score").name ==
"quality_person_by_score"
+ finally:
+ for remove in (
+ lambda: schema.indexLabel("quality_person_by_score").remove(),
+ lambda: schema.edgeLabel("quality_knows").remove(),
+ lambda: schema.vertexLabel("quality_person").remove(),
+ lambda: schema.propertyKey("quality_score").remove(),
+ lambda: schema.propertyKey("quality_name").remove(),
+ ):
+ with suppress(Exception):
+ remove()
diff --git a/hugegraph-python-client/src/tests/api/test_task.py
b/hugegraph-python-client/src/tests/api/test_task.py
index 599d8d1f..f5d3a2a4 100644
--- a/hugegraph-python-client/src/tests/api/test_task.py
+++ b/hugegraph-python-client/src/tests/api/test_task.py
@@ -17,10 +17,13 @@
import unittest
+import pytest
from pyhugegraph.utils.exceptions import NotFoundError
from ..client_utils import ClientUtils
+pytestmark = [pytest.mark.integration, pytest.mark.hugegraph]
+
class TestTaskManager(unittest.TestCase):
client = None
diff --git a/hugegraph-python-client/src/tests/api/test_traverser.py
b/hugegraph-python-client/src/tests/api/test_traverser.py
index bcd40acf..03106966 100644
--- a/hugegraph-python-client/src/tests/api/test_traverser.py
+++ b/hugegraph-python-client/src/tests/api/test_traverser.py
@@ -17,8 +17,12 @@
import unittest
+import pytest
+
from ..client_utils import ClientUtils
+pytestmark = [pytest.mark.integration, pytest.mark.hugegraph]
+
class TestTraverserManager(unittest.TestCase):
client = None
diff --git a/hugegraph-python-client/src/tests/api/test_variable.py
b/hugegraph-python-client/src/tests/api/test_variable.py
index d75ac514..cdb86022 100644
--- a/hugegraph-python-client/src/tests/api/test_variable.py
+++ b/hugegraph-python-client/src/tests/api/test_variable.py
@@ -22,6 +22,8 @@ from pyhugegraph.utils.exceptions import NotFoundError
from ..client_utils import ClientUtils
+pytestmark = [pytest.mark.integration, pytest.mark.hugegraph]
+
class TestVariable(unittest.TestCase):
client = None
diff --git a/hugegraph-python-client/src/tests/api/test_version.py
b/hugegraph-python-client/src/tests/api/test_version.py
index 1ca4a1e2..71eb0562 100644
--- a/hugegraph-python-client/src/tests/api/test_version.py
+++ b/hugegraph-python-client/src/tests/api/test_version.py
@@ -17,8 +17,12 @@
import unittest
+import pytest
+
from ..client_utils import ClientUtils
+pytestmark = [pytest.mark.integration, pytest.mark.hugegraph]
+
class TestVersion(unittest.TestCase):
client = None
diff --git a/hugegraph-python-client/src/tests/client_utils.py
b/hugegraph-python-client/src/tests/client_utils.py
index 1914cdb0..7d209cc6 100644
--- a/hugegraph-python-client/src/tests/client_utils.py
+++ b/hugegraph-python-client/src/tests/client_utils.py
@@ -15,6 +15,8 @@
# specific language governing permissions and limitations
# under the License.
+import os
+
from pyhugegraph.client import PyHugeClient
@@ -26,7 +28,19 @@ class ClientUtils:
GRAPHSPACE = None
TIMEOUT = 10
- def __init__(self):
+ def __init__(self, service=None):
+ if service is not None:
+ self.URL = service.url
+ self.GRAPH = service.graph
+ self.USERNAME = service.user
+ self.PASSWORD = service.password
+ self.GRAPHSPACE = service.graphspace
+ else:
+ self.URL = os.getenv("HUGEGRAPH_URL", self.URL)
+ self.GRAPH = os.getenv("HUGEGRAPH_GRAPH", self.GRAPH)
+ self.USERNAME = os.getenv("HUGEGRAPH_USER", self.USERNAME)
+ self.PASSWORD = os.getenv("HUGEGRAPH_PASSWORD", self.PASSWORD)
+ self.GRAPHSPACE = os.getenv("HUGEGRAPH_GRAPHSPACE") or
self.GRAPHSPACE
self.client = PyHugeClient(
url=self.URL,
user=self.USERNAME,
diff --git a/hugegraph-llm/src/tests/config/test_config.py
b/hugegraph-python-client/src/tests/conftest.py
similarity index 55%
copy from hugegraph-llm/src/tests/config/test_config.py
copy to hugegraph-python-client/src/tests/conftest.py
index 63dc6cb9..c58cd6b8 100644
--- a/hugegraph-llm/src/tests/config/test_config.py
+++ b/hugegraph-python-client/src/tests/conftest.py
@@ -15,15 +15,26 @@
# specific language governing permissions and limitations
# under the License.
+import pytest
-import unittest
+from .client_utils import ClientUtils
+from .fixtures.hugegraph_service import hugegraph_service
+__all__ = ["client_utils", "hugegraph_service"]
-class TestConfig(unittest.TestCase):
- def test_config(self):
- import nltk
- from hugegraph_llm.config import resource_path
[email protected]()
+def client_utils(hugegraph_service):
+ utils = ClientUtils(service=hugegraph_service)
+ utils.clear_graph_all_data()
+ utils.init_property_key()
+ utils.init_vertex_label()
+ utils.init_edge_label()
+ yield utils
+ utils.clear_graph_all_data()
- nltk.data.path.append(resource_path)
- nltk.data.find("corpora/stopwords")
+
[email protected](scope="class", autouse=True)
+def require_hugegraph_for_marked_tests(request):
+ if request.node.get_closest_marker("hugegraph") is not None:
+ request.getfixturevalue("hugegraph_service")
diff --git a/hugegraph-llm/src/tests/config/test_config.py
b/hugegraph-python-client/src/tests/fixtures/__init__.py
similarity index 75%
copy from hugegraph-llm/src/tests/config/test_config.py
copy to hugegraph-python-client/src/tests/fixtures/__init__.py
index 63dc6cb9..13a83393 100644
--- a/hugegraph-llm/src/tests/config/test_config.py
+++ b/hugegraph-python-client/src/tests/fixtures/__init__.py
@@ -14,16 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-
-
-import unittest
-
-
-class TestConfig(unittest.TestCase):
- def test_config(self):
- import nltk
-
- from hugegraph_llm.config import resource_path
-
- nltk.data.path.append(resource_path)
- nltk.data.find("corpora/stopwords")
diff --git a/hugegraph-python-client/src/tests/fixtures/hugegraph_service.py
b/hugegraph-python-client/src/tests/fixtures/hugegraph_service.py
new file mode 100644
index 00000000..324b17ea
--- /dev/null
+++ b/hugegraph-python-client/src/tests/fixtures/hugegraph_service.py
@@ -0,0 +1,75 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import os
+import time
+from dataclasses import dataclass
+
+import pytest
+import requests
+
+
+@dataclass(frozen=True)
+class HugeGraphService:
+ url: str
+ graph: str
+ user: str
+ password: str
+ graphspace: str | None
+
+
+def hugegraph_required() -> bool:
+ return os.getenv("HUGEGRAPH_REQUIRED", "false").lower() == "true"
+
+
+def hugegraph_service_from_env() -> HugeGraphService:
+ graphspace = os.getenv("HUGEGRAPH_GRAPHSPACE") or None
+ return HugeGraphService(
+ url=os.getenv("HUGEGRAPH_URL", "http://127.0.0.1:8080"),
+ graph=os.getenv("HUGEGRAPH_GRAPH", "hugegraph"),
+ user=os.getenv("HUGEGRAPH_USER", "admin"),
+ password=os.getenv("HUGEGRAPH_PASSWORD", "admin"),
+ graphspace=graphspace,
+ )
+
+
+def wait_for_hugegraph(service: HugeGraphService, timeout_seconds: int = 60)
-> None:
+ deadline = time.monotonic() + timeout_seconds
+ last_error: Exception | None = None
+ while time.monotonic() < deadline:
+ try:
+ response = requests.get(f"{service.url}/versions", timeout=5)
+ response.raise_for_status()
+ return
+ except requests.RequestException as exc:
+ last_error = exc
+ time.sleep(2)
+ raise RuntimeError(f"HugeGraph is not ready at {service.url}/versions")
from last_error
+
+
[email protected](scope="session")
+def hugegraph_service() -> HugeGraphService:
+ service = hugegraph_service_from_env()
+ if hugegraph_required():
+ wait_for_hugegraph(service)
+ return service
+
+ try:
+ wait_for_hugegraph(service, timeout_seconds=5)
+ except RuntimeError as exc:
+ pytest.skip(f"HugeGraph integration tests not selected with required
service: {exc}")
+ return service
diff --git a/pyproject.toml b/pyproject.toml
index faa8c73c..2687ce93 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -144,6 +144,18 @@ constraint-dependencies = [
"python-dateutil~=2.9.0",
]
+[tool.pytest.ini_options]
+markers = [
+ "unit: fast deterministic tests without network or Docker",
+ "contract: public contract tests; may use mocks but verify stable
behavior",
+ "integration: tests requiring a real local service such as HugeGraph",
+ "hugegraph: tests requiring HugeGraph Server",
+ "smoke: end-to-end-ish high-value smoke over production pipeline
boundaries",
+ "external: tests requiring external provider credentials or non-HugeGraph
services",
+ "slow: long-running tests excluded from default local loops",
+]
+addopts = "--strict-markers --strict-config"
+
# for code format
[tool.ruff]
line-length = 120