Skip to content

Commit 10cc33a

Browse files
authored
Add juliacall backend (#1)
* Add juliacall backend * Fix * use import * Remove pycache * Use genopt * Fix * Fixes
1 parent c488ef5 commit 10cc33a

13 files changed

Lines changed: 746 additions & 24 deletions

.github/workflows/ci.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test-expressions:
11+
name: Python expression tests
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
python-version: ["3.10", "3.11", "3.12", "3.13"]
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- uses: actions/setup-python@v5
20+
with:
21+
python-version: ${{ matrix.python-version }}
22+
23+
- name: Run expression tests
24+
run: python tests/test_expressions.py
25+
26+
test-solve:
27+
name: End-to-end solve (juliacall + HiGHS)
28+
runs-on: ubuntu-latest
29+
strategy:
30+
matrix:
31+
python-version: ["3.11", "3.12"]
32+
julia-version: ["1.11", "1.12"]
33+
steps:
34+
- uses: actions/checkout@v4
35+
36+
- uses: actions/setup-python@v5
37+
with:
38+
python-version: ${{ matrix.python-version }}
39+
40+
- uses: julia-actions/setup-julia@v2
41+
with:
42+
version: ${{ matrix.julia-version }}
43+
44+
- uses: julia-actions/cache@v2
45+
46+
- name: Install juliacall
47+
run: pip install juliacall
48+
49+
- name: Preinstall Julia packages
50+
run: |
51+
julia -e '
52+
using Pkg
53+
Pkg.add(["MathOptInterface", "HiGHS"])
54+
using MathOptInterface
55+
using HiGHS
56+
'
57+
58+
- name: Run solve tests
59+
run: python tests/test_solve.py

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
__pycache__/
2+
*.pyc
3+
*.egg-info/
4+
dist/
5+
build/

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ version = "0.1.0"
88
description = "A Python interface to JuMP's MathOptInterface via GeneratorOptInterface"
99
requires-python = ">=3.10"
1010
license = "MIT"
11-
dependencies = [
12-
"numpy",
13-
]
11+
dependencies = []
12+
13+
[project.optional-dependencies]
14+
juliacall = ["juliacall>=0.9.23"]
1415

1516
[tool.hatch.build.targets.wheel]
1617
packages = ["src/jumpy"]
-1.16 KB
Binary file not shown.
-16.9 KB
Binary file not shown.
-1.96 KB
Binary file not shown.
-11.6 KB
Binary file not shown.
-5.8 KB
Binary file not shown.

src/jumpy/backend.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""
2+
Solver backend abstraction.
3+
4+
Two backends:
5+
- "juliac" (default): calls a precompiled shared library via ctypes.
6+
No Julia installation required.
7+
- "juliacall": calls MOI + GenOpt + HiGHS through juliacall.
8+
Requires Julia (installed lazily by juliacall on first use).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from abc import ABC, abstractmethod
14+
from typing import TYPE_CHECKING
15+
16+
if TYPE_CHECKING:
17+
from jumpy.model import Model
18+
19+
20+
class Backend(ABC):
21+
"""Abstract solver backend."""
22+
23+
@abstractmethod
24+
def optimize(self, model: Model) -> list[float]:
25+
"""Solve the model and return the solution vector."""
26+
...
27+
28+
29+
class JuliacBackend(Backend):
30+
"""
31+
Default backend: calls a precompiled Julia shared library via ctypes.
32+
33+
The library is built with juliac from:
34+
MOI + GenOpt + Bridges + HiGHS
35+
36+
No Julia installation required.
37+
"""
38+
39+
def __init__(self):
40+
self._lib = None
41+
42+
def _load_lib(self):
43+
if self._lib is not None:
44+
return
45+
import ctypes
46+
import importlib.resources
47+
# TODO: resolve platform-specific library path
48+
# For now, search standard locations
49+
import os
50+
lib_names = [
51+
"libjumpy_backend.so",
52+
"libjumpy_backend.dylib",
53+
"jumpy_backend.dll",
54+
]
55+
for name in lib_names:
56+
for search_dir in [os.path.dirname(__file__), os.getcwd(), "/usr/local/lib"]:
57+
path = os.path.join(search_dir, name)
58+
if os.path.exists(path):
59+
self._lib = ctypes.CDLL(path)
60+
return
61+
raise FileNotFoundError(
62+
"Could not find the compiled JuMPy backend library.\n"
63+
"The juliac-compiled shared library (libjumpy_backend.so) is not installed.\n"
64+
"Either:\n"
65+
" 1. Install the pre-built wheel: pip install jumpy\n"
66+
" 2. Use the juliacall backend: jp.Model(backend='juliacall')\n"
67+
)
68+
69+
def optimize(self, model: Model) -> list[float]:
70+
self._load_lib()
71+
data = model._serialize()
72+
# TODO: implement ctypes calls to the compiled library
73+
raise NotImplementedError(
74+
"juliac backend not yet compiled. "
75+
"Use jp.Model(backend='juliacall') for now."
76+
)
77+
78+
79+
class JuliaCallBackend(Backend):
80+
"""
81+
Optional backend: calls Julia directly through juliacall.
82+
83+
Requires `pip install jumpy[juliacall]`. Julia is installed lazily
84+
by juliacall on first use if not already present.
85+
86+
This backend has full flexibility — it can use any solver or MOI
87+
feature, not just what's compiled into the juliac library.
88+
"""
89+
90+
def __init__(self):
91+
self._jl = None
92+
93+
def _init_julia(self):
94+
if self._jl is not None:
95+
return
96+
try:
97+
from juliacall import Main as jl
98+
except ImportError:
99+
raise ImportError(
100+
"juliacall is not installed.\n"
101+
"Install it with: pip install jumpy[juliacall]\n"
102+
"This will also install Julia automatically if needed."
103+
) from None
104+
# Install and load Julia packages on first use
105+
jl.seval("using Pkg")
106+
for pkg in ["MathOptInterface", "HiGHS", "GenOpt"]:
107+
jl.seval(f"""
108+
if !haskey(Pkg.project().dependencies, "{pkg}")
109+
Pkg.add("{pkg}")
110+
end
111+
""")
112+
jl.seval("import MathOptInterface as MOI")
113+
jl.seval("import GenOpt")
114+
jl.seval("import HiGHS")
115+
# TODO: load GenOpt once it's registered / available
116+
self._jl = jl
117+
118+
def optimize(self, model: Model) -> list[float]:
119+
self._init_julia()
120+
jl = self._jl
121+
return self._build_and_solve(jl, model)
122+
123+
def _build_and_solve(self, jl, model: Model) -> list[float]:
124+
from jumpy.bridge_juliacall import build_moi_model
125+
return build_moi_model(jl, model)
126+
127+
128+
_BACKENDS = {
129+
"juliac": JuliacBackend,
130+
"juliacall": JuliaCallBackend,
131+
}
132+
133+
134+
def get_backend(name: str) -> Backend:
135+
cls = _BACKENDS.get(name)
136+
if cls is None:
137+
raise ValueError(
138+
f"Unknown backend '{name}'. Choose from: {list(_BACKENDS.keys())}"
139+
)
140+
return cls()

0 commit comments

Comments
 (0)