Skip to content

Commit 0c51a16

Browse files
authored
Merge pull request #3038 from ERGO-Code/highspy-extras-tests
Added unit tests for highspy-extras
2 parents c227572 + 2f63366 commit 0c51a16

10 files changed

Lines changed: 227 additions & 46 deletions

File tree

.github/workflows/build-python-package.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
- name: Test highspy
2727
run: |
2828
python3 -m pip install pytest
29-
python3 -m pytest $GITHUB_WORKSPACE
29+
python3 -m pytest ./highspy
3030
3131
build_sdist_mac:
3232
runs-on: macos-latest

.github/workflows/build-python-sdist.yml

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,6 @@ jobs:
152152
source .venv/bin/activate
153153
python3 -m pip install dist/*.tar.gz
154154
155-
- name: Debug extras loading
156-
run: |
157-
source .venv/bin/activate
158-
python -c "import highspy._core; print(highspy._core.getExtrasLoadStatus())"
159-
python -c "import highspy_extras; print(highspy_extras.__file__); print(highspy_extras._PACKAGE_DIR)"
160-
python -c "import highspy_extras; highspy_extras._load_library()"
161-
162155
- name: Test highspy
163156
run: |
164157
source .venv/bin/activate
@@ -194,12 +187,6 @@ jobs:
194187
- name: install highspy and highspy-extras
195188
run: python -m pip install (Get-ChildItem dist\*.tar.gz)
196189

197-
- name: Debug extras loading
198-
run: |
199-
python -c "import highspy._core; print(highspy._core.getExtrasLoadStatus())"
200-
python -c "import highspy_extras; print(highspy_extras.__file__); print(highspy_extras._PACKAGE_DIR)"
201-
python -c "import highspy_extras; highspy_extras._load_library()"
202-
203190
- name: Test highspy
204191
run: |
205192
python -m pip install pytest

extern/HighsExtrasApi.cpp

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
using namespace HighsExtras;
1717

18+
namespace {
19+
1820
template <class Methods, class... Fn>
1921
void bind_api(HighsExtrasApi* api, const std::tuple<Fn...>& funcs) {
2022
static_assert(std::tuple_size<Methods>::value == sizeof...(Fn),
@@ -23,14 +25,38 @@ void bind_api(HighsExtrasApi* api, const std::tuple<Fn...>& funcs) {
2325
api->template as<Methods>(), funcs);
2426
}
2527

28+
// Get feature name by index at runtime via template recursion
29+
template <size_t N>
30+
struct feature_name {
31+
static const char* get(size_t index) {
32+
return (index == N - 1) ? extras_feature<N - 1>::name()
33+
: feature_name<N - 1>::get(index);
34+
}
35+
};
36+
37+
template <>
38+
struct feature_name<0> {
39+
static const char* get(size_t) { return nullptr; }
40+
};
41+
} // namespace
42+
2643
extern "C" {
2744

2845
HIGHS_EXTRAS_API const char* HighsExtras_getVersion(void) {
2946
return HIGHS_EXTRAS_VERSION;
3047
}
3148

49+
HIGHS_EXTRAS_API size_t HighsExtras_getFeatureCount(void) {
50+
return feature_count<extrasAll>::value;
51+
}
52+
53+
HIGHS_EXTRAS_API const char* HighsExtras_getFeatureName(size_t index) {
54+
if (index >= feature_count<extrasAll>::value) return nullptr;
55+
return feature_name<feature_count<extrasAll>::value>::get(index);
56+
}
57+
3258
HIGHS_EXTRAS_API const HighsExtrasFeatureInfo* HighsExtras_getFeatureInfo() {
33-
return extras_feature_info;
59+
return extras_family::getInfo();
3460
}
3561

3662
HIGHS_EXTRAS_API bool HighsExtras_getApi(HighsExtrasApi* api) {

extern/HighsExtrasApi.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ extern "C" {
6262
HIGHS_EXTRAS_API const char* HighsExtras_getVersion(void);
6363

6464
// Get metadata for all features
65+
HIGHS_EXTRAS_API size_t HighsExtras_getFeatureCount(void);
66+
HIGHS_EXTRAS_API const char* HighsExtras_getFeatureName(size_t index);
6567
HIGHS_EXTRAS_API const HighsExtrasFeatureInfo* HighsExtras_getFeatureInfo();
6668

6769
// Get the HighsExtrasApi, with appropriate function pointers

extern/HighsExtrasApiBinding.h

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111
#ifndef HIGHS_EXTRAS_API_BINDING_H_
1212
#define HIGHS_EXTRAS_API_BINDING_H_
1313

14+
#include <stddef.h>
15+
1416
#include <tuple>
1517

18+
1619
// provide metadata info for each feature
1720
struct HighsExtrasFeatureInfo {
1821
HighsExtrasFeatureInfo(const char* provider_ = nullptr,
@@ -34,6 +37,25 @@ namespace HighsExtras {
3437
template <class... Features>
3538
struct require {};
3639

40+
// compile-time count of nested features in a list,
41+
// e.g., feature_count<require<amd, blas>, rcm>::value == 3
42+
template <class T>
43+
struct feature_count : std::integral_constant<size_t, 1> {};
44+
45+
template <class... Fs>
46+
struct sum_feature_counts;
47+
48+
template <>
49+
struct sum_feature_counts<> : std::integral_constant<size_t, 0> {};
50+
51+
template <class F, class... Rest>
52+
struct sum_feature_counts<F, Rest...>
53+
: std::integral_constant<size_t, feature_count<F>::value +
54+
sum_feature_counts<Rest...>::value> {};
55+
56+
template <class... Features>
57+
struct feature_count<require<Features...>> : sum_feature_counts<Features...> {};
58+
3759
template <class Methods>
3860
struct feature_api;
3961

extern/HighsExtrasExternalDeps.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,8 @@ const HighsExtrasFeatureInfo extras_feature_info[] = {
2929
{"METIS-GKlib", "5.2.1+", "Apache-2.0", __hipo_enabled},
3030
{"SPARSEPAK", "unversioned+", "MIT", __hipo_enabled}};
3131

32+
const HighsExtrasFeatureInfo* extras_family::getInfo() {
33+
return extras_feature_info;
34+
}
35+
3236
} // namespace HighsExtras

extern/HighsExtrasExternalDeps.h

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@
3535
// 0. Define Features
3636
//
3737
namespace HighsExtras {
38-
struct extras_family {};
39-
extern const HighsExtrasFeatureInfo extras_feature_info[];
38+
struct extras_family {
39+
static const HighsExtrasFeatureInfo* getInfo();
40+
};
4041

4142
template <>
4243
struct wrapper_storage<extras_family> {

highspy-extras/highspy_extras/__init__.py

Lines changed: 110 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,50 +4,132 @@
44

55
import ctypes
66
import sys
7+
from dataclasses import dataclass
8+
from functools import cached_property
79
from importlib.metadata import version
810
from pathlib import Path
911

10-
__all__ = ["__version__", "get_library_version"]
12+
__all__ = ["__version__", "HighsExtrasFeatureInfo", "library"]
1113

1214
__version__ = version("highspy-extras")
13-
1415
_PACKAGE_DIR = Path(__file__).resolve().parent
15-
_library_handle: ctypes.CDLL | None = None
1616

1717

18-
def _load_library() -> ctypes.CDLL:
19-
global _library_handle
18+
class _HighsExtrasFeatureInfoRaw(ctypes.Structure):
19+
"""Raw feature info layout exposed by the shared library."""
20+
21+
_fields_ = [
22+
("provider", ctypes.c_char_p),
23+
("version", ctypes.c_char_p),
24+
("license", ctypes.c_char_p),
25+
("enabled", ctypes.c_bool),
26+
]
27+
2028

21-
if _library_handle is not None:
22-
return _library_handle
29+
@dataclass(frozen=True)
30+
class HighsExtrasFeatureInfo:
31+
"""Feature metadata for an external dependency."""
2332

24-
if sys.platform == "win32":
25-
library_name = "highs_extras.dll"
26-
elif sys.platform == "darwin":
27-
library_name = "libhighs_extras.dylib"
28-
else:
29-
library_name = "libhighs_extras.so"
33+
provider: str
34+
version: str
35+
license: str
36+
enabled: bool
3037

31-
library_path = _PACKAGE_DIR / library_name
32-
if not library_path.is_file():
33-
raise FileNotFoundError(
34-
f"Could not find the highs_extras shared library at {library_path}"
38+
@classmethod
39+
def from_raw(cls, raw: _HighsExtrasFeatureInfoRaw) -> HighsExtrasFeatureInfo:
40+
return cls(
41+
provider=raw.provider.decode("utf-8"),
42+
version=raw.version.decode("utf-8"),
43+
license=raw.license.decode("utf-8"),
44+
enabled=bool(raw.enabled),
3545
)
3646

37-
_library_handle = ctypes.CDLL(str(library_path))
38-
return _library_handle
3947

48+
class HighsExtrasLibrary:
49+
"""Wrapper around the highs_extras shared library."""
50+
51+
@cached_property
52+
def handle(self) -> ctypes.CDLL:
53+
if sys.platform == "win32":
54+
library_name = "highs_extras.dll"
55+
elif sys.platform == "darwin":
56+
library_name = "libhighs_extras.dylib"
57+
else:
58+
library_name = "libhighs_extras.so"
59+
60+
library_path = _PACKAGE_DIR / library_name
61+
if not library_path.is_file():
62+
raise FileNotFoundError(f"Could not find the shared library at {library_path}")
63+
64+
handle = ctypes.CDLL(str(library_path))
65+
66+
handle.HighsExtras_getVersion.argtypes = []
67+
handle.HighsExtras_getVersion.restype = ctypes.c_char_p
68+
69+
handle.HighsExtras_getFeatureCount.argtypes = []
70+
handle.HighsExtras_getFeatureCount.restype = ctypes.c_size_t
71+
72+
handle.HighsExtras_getFeatureName.argtypes = [ctypes.c_size_t]
73+
handle.HighsExtras_getFeatureName.restype = ctypes.c_char_p
74+
75+
handle.HighsExtras_getFeatureInfo.argtypes = []
76+
handle.HighsExtras_getFeatureInfo.restype = ctypes.POINTER(_HighsExtrasFeatureInfoRaw)
77+
78+
return handle
79+
80+
@property
81+
def version(self) -> str:
82+
version_bytes = self.handle.HighsExtras_getVersion()
83+
if version_bytes is None:
84+
raise RuntimeError("HighsExtras_getVersion() returned NULL")
85+
return version_bytes.decode("utf-8")
86+
87+
def _feature_name(self, index: int) -> str:
88+
name_bytes = self.handle.HighsExtras_getFeatureName(index)
89+
if name_bytes is None:
90+
raise RuntimeError(f"HighsExtras_getFeatureName({index}) returned NULL")
91+
return name_bytes.decode("utf-8")
92+
93+
@cached_property
94+
def features(self) -> dict[str, HighsExtrasFeatureInfo]:
95+
info_ptr = self.handle.HighsExtras_getFeatureInfo()
96+
if not info_ptr:
97+
raise RuntimeError("HighsExtras_getFeatureInfo() returned NULL")
98+
99+
count = int(self.handle.HighsExtras_getFeatureCount())
100+
return {self._feature_name(index): HighsExtrasFeatureInfo.from_raw(info_ptr[index]) for index in range(count)}
101+
102+
def __getitem__(self, name: str) -> HighsExtrasFeatureInfo:
103+
return self.features[name]
104+
105+
@cached_property
106+
def feature_table(self) -> str:
107+
"""Return a human-readable table describing the external dependency features."""
108+
109+
headers = ("key", "name", "version", "license", "enabled")
110+
rows = [
111+
(
112+
name,
113+
info.provider,
114+
info.version,
115+
info.license,
116+
"yes" if info.enabled else "no",
117+
)
118+
for name, info in self.features.items()
119+
]
120+
121+
widths = [max(len(headers[i]), *(len(row[i]) for row in rows)) if rows else len(headers[i]) for i in range(len(headers))]
40122

41-
def get_library_version() -> str:
42-
"""Return the ABI version string exported by the highs_extras library."""
123+
def _fmt(row: tuple[str, ...]) -> str:
124+
return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(row))
43125

44-
get_version = _load_library().highs_extras_get_version
45-
get_version.argtypes = []
46-
get_version.restype = ctypes.c_char_p
126+
separator = " ".join("-" * w for w in widths)
127+
lines = [_fmt(headers), separator]
128+
lines.extend(_fmt(row) for row in rows)
129+
return "\n".join(lines)
47130

48-
version_bytes = get_version()
49-
if version_bytes is None:
50-
raise RuntimeError("highs_extras_get_version() returned NULL")
131+
def __str__(self) -> str:
132+
return self.feature_table
51133

52-
return version_bytes.decode("utf-8")
53134

135+
library = HighsExtrasLibrary()

highspy-extras/pyproject.toml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ classifiers = [
3030
"Programming Language :: Python :: 3.14",
3131
]
3232

33+
[project.optional-dependencies]
34+
test = ["pytest"]
35+
3336
[project.urls]
3437
"Source Code" = "https://github.com/ERGO-Code/HiGHS"
3538
"Bug Tracker" = "https://github.com/ERGO-Code/HiGHS/issues"
@@ -46,7 +49,8 @@ sdist.include = [
4649
"extern",
4750
"cmake",
4851
"Version.txt",
49-
"highs/HConfig.h.in"
52+
"highs/HConfig.h.in",
53+
"tests/test_highspy_extras.py"
5054
]
5155

5256
sdist.exclude = [
@@ -76,10 +80,22 @@ sdist.exclude = [
7680
]
7781

7882
[tool.scikit-build.cmake.define]
83+
#HIGHS_VCPKG="ON"
7984
BUILD_OPENBLAS = "ON"
8085
HIPO = "ON"
8186

87+
[tool.pytest.ini_options]
88+
minversion = "6.0"
89+
addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"]
90+
xfail_strict = true
91+
log_cli_level = "INFO"
92+
filterwarnings = ["error"]
93+
testpaths = ["tests"]
94+
norecursedirs = ["check", ".git", "build", "dist", "*.egg-info"]
95+
8296
[tool.cibuildwheel]
8397
build = "*"
8498
archs = ["auto64", "auto32"]
8599
build-verbosity = 1
100+
test-command = "pytest {project}/highspy-extras/tests"
101+
test-extras = ["test"]
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import re
2+
import unittest
3+
4+
import highspy_extras
5+
6+
7+
class TestHighsPyExtras(unittest.TestCase):
8+
def test_version(self):
9+
# Ensure the library version matches the release part of the package version,
10+
# e.g., "1.14.0.dev1" -> "1.14.0"
11+
package_release = re.match(r"\d+(?:\.\d+)*", highspy_extras.__version__)
12+
if package_release is None:
13+
self.fail(f"Could not parse a release version from {highspy_extras.__version__!r}")
14+
self.assertEqual(highspy_extras.library.version, package_release.group(0))
15+
16+
def test_features(self):
17+
# Ensure the library has non-zero features available
18+
features = highspy_extras.library.features
19+
self.assertIsInstance(features, dict)
20+
self.assertGreater(len(features), 0)
21+
22+
def test_blas_enabled(self):
23+
# Ensure BLAS feature is present and enabled in this build
24+
self.assertIn("blas", highspy_extras.library.features)
25+
blas_feature = highspy_extras.library["blas"]
26+
self.assertTrue(blas_feature.enabled)
27+
28+
def test_getitem_matches_features_mapping(self):
29+
# Ensure __getitem__ and features[] are the same
30+
self.assertIs(
31+
highspy_extras.library["blas"],
32+
highspy_extras.library.features["blas"],
33+
)
34+
35+
def test_feature_table(self):
36+
# Basic sanity check for the feature table
37+
table = highspy_extras.library.feature_table
38+
self.assertIsInstance(table, str)
39+
self.assertIn("key", table)
40+
self.assertIn("version", table)
41+
self.assertIn("blas", table)

0 commit comments

Comments
 (0)