Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
.venv/
__pycache__/

# Stubs generated locally for the conformance tests (see test/conformance/README.md).
gen/
8 changes: 8 additions & 0 deletions test/conformance/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,11 @@
SOURCE_ADAPTER_ADDRESS=localhost:50051
SWITCH_ADAPTER_ADDRESS=localhost:50052
TIMETAGGER_ADAPTER_ADDRESS=localhost:50053

# Optional gRPC `reqType` header metadata. Omit if the adapter does not require it.
# GRPC_HEADER_METADATA_REQTYPE=mock

# Supplies gRPC request bodies for each adapter RPC. Unset uses testdata/<adapter>.json.
# SWITCH_CONFORMANCE_CONFIG=/path/to/your-switch.json
# SOURCE_CONFORMANCE_CONFIG=/path/to/your-source.json
# TIMETAGGER_CONFORMANCE_CONFIG=/path/to/your-timetagger.json
44 changes: 36 additions & 8 deletions test/conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,18 @@ TIMETAGGER_ADAPTER_ADDRESS=adapter.cloud.com:9000

## Setup

Setup virtual env and run the below commands in it.
```
python -m venv .venv
source .venv/bin/activate
```

### 1. Generate Python stubs

Install grpcio-tools (if not already installed):

```bash
pip install `grpcio-tools>=1.84.0`
pip install 'grpcio-tools>=1.84.0'
```

Generate stubs from repository root:
Expand All @@ -76,16 +82,19 @@ python -m grpc_tools.protoc \
```bash
cd test/conformance
cp .env.example .env
# Edit .env to set adapter addresses
```

Adapter addresses should be set in `.env` :
Edit these files:

| File | What to set |
|---|---|
| `.env` | Adapter host/port and optional `GRPC_HEADER_METADATA_REQTYPE` |
| `testdata/switch.json` | Per-switch request fields. Copy and point the env `SWITCH_CONFORMANCE_CONFIG` at your file; default is this path. |
| `testdata/source.json` | Per-source request fields. Copy and point the env `SOURCE_CONFORMANCE_CONFIG` at your file; default is this path. |
| `testdata/timetagger.json` | Per-time-tagger request fields. Copy and point the env `TIMETAGGER_CONFORMANCE_CONFIG` at your file; default is this path. |

Each JSON file supplies the gRPC request body for every adapter RPC. Top-level `product_id` and `resource_type` are copied onto every RPC except `HealthCheck`. Edit values so they match what the server the test is being run against accepts.

```bash
SOURCE_ADAPTER_ADDRESS=localhost:50051
SWITCH_ADAPTER_ADDRESS=localhost:50052
TIMETAGGER_ADAPTER_ADDRESS=localhost:50053
```

### 3. Install dependencies

Expand All @@ -112,3 +121,22 @@ python -m pytest timetagger/test_timetagger.py -v
```bash
python -m pytest source/test_source.py::test_health_check -v
```

## Testing an adapter built for a specific HAL version

If you are writing your own adapter against a released HAL version (for example `v1.2.0`), run the conformance tests **from that release's tag**. Each tag contains the `.proto` files, the stubs, the `testdata/*.json` and the tests for that version, so they all agree with the contract your adapter implements. Use the tests from the tag, not from `main`, because `main` may test newer RPCs or fields that your version does not have.

```bash
git clone https://github.com/outshift-open/quantum-hal.git
cd quantum-hal
git checkout v1.2.0 # the HAL version your adapter implements
```

Then follow [Setup](#setup) from the checked-out tag:

1. Generate the stubs from this checkout (step 1). Re-run it after switching tags and delete any old `gen/python/`, because `conftest.py` puts `gen/python/` ahead of any installed `quantum-hal-sdk`.
2. Copy `.env.example` to `.env` and set your adapter's address (step 2).
3. Edit the `testdata/*.json` files at that tag so they hold values your adapter accepts.
4. Install `requirements.txt` and run `python -m pytest -v`.

To check a different version, check out its tag and repeat the steps.
38 changes: 38 additions & 0 deletions test/conformance/source/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Copyright 2026 Cisco Systems, Inc. and its affiliates
#
# SPDX-License-Identifier: Apache-2.0

"""Parametrize source tests over whatever kinds the JSON config lists."""
import pytest

from .request_config import (
CONFIG_ENV,
config_path,
kind_ids,
load_source_kinds,
)

_SOURCE_KINDS = load_source_kinds()


if _SOURCE_KINDS:

@pytest.fixture(
scope="class",
params=_SOURCE_KINDS,
ids=kind_ids(_SOURCE_KINDS),
)
def source_kind(request):
"""One JSON source object; class tests run all RPCs for this kind."""
return request.param

else:

@pytest.fixture
def source_kind():
"""Skip device-scoped tests when the JSON lists no source kinds."""
pytest.skip(
"No source kinds in {0} (set {1})".format(
config_path(), CONFIG_ENV
)
)
101 changes: 101 additions & 0 deletions test/conformance/source/request_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Copyright 2026 Cisco Systems, Inc. and its affiliates
#
# SPDX-License-Identifier: Apache-2.0

"""Load source request testdata. Address and metadata stay in .env."""
import json
import os

from hal.adapters.source.v1 import source_pb2

CONFIG_ENV = "SOURCE_CONFORMANCE_CONFIG"
_DEFAULT_CONFIG = os.path.join(
os.path.dirname(__file__), "..", "testdata", "source.json"
)


def config_path():
"""Resolve the source JSON path from env or the default testdata file."""
return os.path.abspath(os.getenv(CONFIG_ENV, _DEFAULT_CONFIG))


def load_source_kinds():
"""Load the source array from JSON; missing file yields []."""
path = config_path()
if not os.path.isfile(path):
return []
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
kinds = data.get("source", [])
if kinds is None:
return []
if not isinstance(kinds, list):
raise ValueError(
"{0}: 'source' must be a JSON array".format(path)
)
for index, kind in enumerate(kinds):
if not isinstance(kind, dict):
raise ValueError(
"{0}: source[{1}] must be an object".format(path, index)
)
for field in ("product_id", "resource_type"):
value = kind.get(field)
if not isinstance(value, str) or not value.strip():
raise ValueError(
"{0}: source[{1}] requires non-empty {2}".format(
path, index, field
)
)
return kinds


def kind_ids(kinds):
"""Pytest ids from resource_type, with suffixes if names repeat."""
ids = []
seen = {}
for kind in kinds:
base = str(kind.get("resource_type") or "source")
count = seen.get(base, 0)
seen[base] = count + 1
ids.append(base if count == 0 else "{0}-{1}".format(base, count))
return ids


def _normalize_fields(fields):
"""json.dumps *_json values that were written as objects in testdata."""
normalized = dict(fields)
for key, value in list(normalized.items()):
if key.endswith("_json") and isinstance(value, (dict, list)):
normalized[key] = json.dumps(value)
elif key == "config" and isinstance(value, dict):
cfg = dict(value)
config_json = cfg.get("config_json")
if isinstance(config_json, (dict, list)):
cfg["config_json"] = json.dumps(config_json)
normalized["config"] = cfg
return normalized


def rpc_request(kind, rpc_name):
"""Build one RPC's request kwargs; copy product_id/resource_type from the kind."""
rpcs = kind.get("rpcs") or {}
rpc_fields = rpcs.get(rpc_name) or {}
if not isinstance(rpc_fields, dict):
raise TypeError(
"rpcs.{0} must be an object, got {1}".format(
rpc_name, type(rpc_fields).__name__
)
)
if rpc_name == "HealthCheck":
return _normalize_fields(rpc_fields)
# Kind-level product_id and resource_type are used by every RPC.
fields = {
"product_id": kind.get("product_id", ""),
"resource_type": kind.get("resource_type", ""),
}
fields.update(rpc_fields)
fields = _normalize_fields(fields)
config = fields.get("config")
if isinstance(config, dict):
fields["config"] = source_pb2.SourceConfig(**config)
return fields
Loading
Loading