Skip to content

Commit 25f82cc

Browse files
author
Christopher Rowley
committed
Merge remote-tracking branch 'origin/main' into v1
2 parents 2c711d3 + f661843 commit 25f82cc

25 files changed

Lines changed: 786 additions & 543 deletions

‎.github/workflows/tests.yml‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,23 @@ jobs:
5757
uses: julia-actions/julia-buildpkg@v1
5858
env:
5959
PYTHON: python
60-
60+
6161
- name: Install Python
6262
id: setup-python
6363
uses: actions/setup-python@v7
6464
if: ${{ matrix.pythonexe == 'python' }}
6565
with:
6666
python-version: ${{ matrix.pyversion }}
6767

68+
# Always build PyCall (and hence Conda) so that Conda's root env directory exists;
69+
# otherwise Conda fails to precompile on cached runners, which can cause errors.
70+
# Use a temporary environment because `--project=test` has no manifest on Julia
71+
# versions without workspace support (< 1.12).
6872
- name: Build PyCall
69-
if: ${{ matrix.pythonexe == 'python' }}
7073
run: |
71-
julia --project=test -e 'import Pkg; Pkg.build("PyCall")'
74+
julia -e 'import Pkg; Pkg.activate(temp=true); Pkg.add("PyCall"); Pkg.build("Conda"); Pkg.build("PyCall")'
7275
env:
73-
PYTHON: ${{ steps.setup-python.outputs.python-path }}
76+
PYTHON: ${{ case(matrix.pythonexe == 'python', steps.setup-python.outputs.python-path, 'python') }}
7477

7578
- name: Run tests
7679
uses: julia-actions/julia-runtest@v1
@@ -116,7 +119,7 @@ jobs:
116119
uses: actions/setup-python@v7
117120
with:
118121
python-version: ${{ matrix.pyversion }}
119-
122+
120123
# - name: Check Python OpenSSL version (see setup_julia)
121124
# shell: python
122125
# run: |

‎AGENTS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ The majority of tests live in the Julia package; Python tests cover functionalit
2121

2222
## Branch and CI compatibility
2323
- On `v1`, JuliaCall uses `jl_eval` (returning a Julia wrapper), whereas `main` uses `seval`. Adapt tests brought over from `main` accordingly.
24+
- The `v1` Python wrapper hierarchy uses `JlBase`, `Jl`, and `JlCollection`, unlike
25+
`main`'s `ValueBase`/`AnyValue` hierarchy. Keep the collection wrapper when porting
26+
`_jlwrap` changes. Wrapper modules load before `juliacall.Base` is assigned, so
27+
constructors must look it up through `juliacall` when called.
2428
- The benchmark suite is shared across the compared revisions: `main` uses `pydel!`, while `v1` uses `unsafe_pydel`.
2529
- `main` does not have `@pyregion`. Benchmark region wrappers must select a plain-body fallback before macro expansion; a runtime conditional around an unavailable macro still fails when loading the suite.
2630
- Julia CI caches can preserve Conda's build metadata without its root environment. Rebuild `Conda` before building `PyCall` or running tests to recreate that directory.

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
* New functions: `pyjlarray`, `pyjldict`, `pyjlset`.
4747

4848
## Unreleased
49+
* Add `exe=@ENV` preference to read `JULIA_PYTHONCALL_EXE` without loading CondaPkg.
50+
* Improved TTFX in JuliaCall.
4951
* Minimum supported Python version is now 3.11.
5052

5153
## 0.9.37 (2026-10-12)

‎docs/src/pythoncall.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,9 @@ special values:
310310
- `@CondaPkg`: Use Python from CondaPkg (the default).
311311
- `@PyCall`: Use the same Python as PyCall. [See here](@ref faq-pycall).
312312
- `@venv`: Use Python from a `.venv` virtual environment in the current active project.
313+
- `@ENV` (preference only): Use `JULIA_PYTHONCALL_EXE`, which must be set to a Python
314+
executable path or command name (see below). Avoids loading CondaPkg while allowing
315+
Python environments to be changed via the environment variable.
313316

314317
Otherwise, the value is interpreted as:
315318
- An absolute path to a Python executable.

‎pysrc/juliacall/__init__.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@ def exception(self):
3737
def backtrace(self):
3838
return self.args[1]
3939

40+
def _init_jlwrap():
41+
# Called by PythonCall once the native JlBase type is available.
42+
global JlBase2, JlIter, Jl, JlCollection, JlArray, JlVector, JlDict, JlSet, JlIOBase, JlBinaryIO, JlTextIO
43+
from ._jlwrap import (
44+
JlBase2,
45+
JlIter,
46+
Jl,
47+
JlCollection,
48+
JlArray,
49+
JlVector,
50+
JlDict,
51+
JlSet,
52+
JlIOBase,
53+
JlBinaryIO,
54+
JlTextIO,
55+
)
56+
57+
4058
CONFIG = {'inited': False}
4159

4260
def init():
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Python implementations of Julia wrappers, initialized by PythonCall."""
2+
3+
from .any import JlBase2, JlIter, Jl
4+
from .collection import JlCollection
5+
from .array import JlArray
6+
from .vector import JlVector
7+
from .dict import JlDict
8+
from .set import JlSet
9+
from .io import JlIOBase, JlBinaryIO, JlTextIO

‎pysrc/juliacall/_jlwrap/any.py‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Julia wrappers initialized by PythonCall."""
2+
3+
from juliacall import JlBase
4+
5+
# Populated by Julia during initialization.
6+
_jl_methods = {}
7+
8+
9+
class JlBase2(JlBase):
10+
__module__ = "juliacall"
11+
__slots__ = ()
12+
def __repr__(self):
13+
t = type(self)
14+
if t is Jl:
15+
name = "Julia"
16+
else:
17+
name = t.__name__
18+
return name + ":" + self._jl_callmethod(_jl_methods["__repr__"])
19+
class JlIter(JlBase2):
20+
__module__ = "juliacall"
21+
__slots__ = ()
22+
def __iter__(self):
23+
return self
24+
def __hash__(self):
25+
return self._jl_callmethod(_jl_methods["__hash__"])
26+
def __next__(self):
27+
return self._jl_callmethod(_jl_methods["iterator_next"])
28+
class Jl(JlBase2):
29+
__module__ = "juliacall"
30+
__slots__ = ()
31+
def __str__(self):
32+
return self._jl_callmethod(_jl_methods["__str__"])
33+
def __getattr__(self, k):
34+
if k.startswith("__") and k.endswith("__"):
35+
raise AttributeError(k)
36+
else:
37+
return self._jl_callmethod(_jl_methods["__getattr__"], k)
38+
def __setattr__(self, k, v):
39+
try:
40+
JlBase.__setattr__(self, k, v)
41+
except AttributeError:
42+
if k.startswith("__") and k.endswith("__"):
43+
raise
44+
else:
45+
return
46+
self._jl_callmethod(_jl_methods["__setattr__"], k, v)
47+
def __dir__(self):
48+
return JlBase.__dir__(self) + self._jl_callmethod(_jl_methods["__dir__"])
49+
def __call__(self, *args, **kwargs):
50+
return self._jl_callmethod(_jl_methods["__call__"], args, kwargs)
51+
def __bool__(self):
52+
return self._jl_callmethod(_jl_methods["__bool__"])
53+
def __len__(self):
54+
return self._jl_callmethod(_jl_methods["__len__"])
55+
def __getitem__(self, k):
56+
return self._jl_callmethod(_jl_methods["__getitem__"], k)
57+
def __setitem__(self, k, v):
58+
self._jl_callmethod(_jl_methods["__setitem__"], k, v)
59+
def __delitem__(self, k):
60+
self._jl_callmethod(_jl_methods["__delitem__"], k)
61+
def __iter__(self):
62+
return self._jl_callmethod(_jl_methods["__iter__"])
63+
def __next__(self):
64+
return self._jl_callmethod(_jl_methods["__next__"])
65+
def __reversed__(self):
66+
return self._jl_callmethod(_jl_methods["__reversed__"])
67+
def __contains__(self, v):
68+
return self._jl_callmethod(_jl_methods["__contains__"], v)
69+
def __pos__(self):
70+
return self._jl_callmethod(_jl_methods["__pos__"])
71+
def __neg__(self):
72+
return self._jl_callmethod(_jl_methods["__neg__"])
73+
def __abs__(self):
74+
return self._jl_callmethod(_jl_methods["__abs__"])
75+
def abs(self):
76+
return self._jl_callmethod(_jl_methods["__abs__"])
77+
def __invert__(self):
78+
return self._jl_callmethod(_jl_methods["__invert__"])
79+
def __add__(self, other):
80+
return self._jl_callmethod(_jl_methods["__add__"], other)
81+
def __sub__(self, other):
82+
return self._jl_callmethod(_jl_methods["__sub__"], other)
83+
def __mul__(self, other):
84+
return self._jl_callmethod(_jl_methods["__mul__"], other)
85+
def __truediv__(self, other):
86+
return self._jl_callmethod(_jl_methods["__truediv__"], other)
87+
def __floordiv__(self, other):
88+
return self._jl_callmethod(_jl_methods["__floordiv__"], other)
89+
def __mod__(self, other):
90+
return self._jl_callmethod(_jl_methods["__mod__"], other)
91+
def __pow__(self, other, modulo=None):
92+
if modulo is None:
93+
return self._jl_callmethod(_jl_methods["__pow__"], other)
94+
else:
95+
return self._jl_callmethod(_jl_methods["__pow_modulo__"], other, modulo)
96+
def __lshift__(self, other):
97+
return self._jl_callmethod(_jl_methods["__lshift__"], other)
98+
def __rshift__(self, other):
99+
return self._jl_callmethod(_jl_methods["__rshift__"], other)
100+
def __and__(self, other):
101+
return self._jl_callmethod(_jl_methods["__and__"], other)
102+
def __xor__(self, other):
103+
return self._jl_callmethod(_jl_methods["__xor__"], other)
104+
def __or__(self, other):
105+
return self._jl_callmethod(_jl_methods["__or__"], other)
106+
def __radd__(self, other):
107+
return self._jl_callmethod(_jl_methods["__radd__"], other)
108+
def __rsub__(self, other):
109+
return self._jl_callmethod(_jl_methods["__rsub__"], other)
110+
def __rmul__(self, other):
111+
return self._jl_callmethod(_jl_methods["__rmul__"], other)
112+
def __rtruediv__(self, other):
113+
return self._jl_callmethod(_jl_methods["__rtruediv__"], other)
114+
def __rfloordiv__(self, other):
115+
return self._jl_callmethod(_jl_methods["__rfloordiv__"], other)
116+
def __rmod__(self, other):
117+
return self._jl_callmethod(_jl_methods["__rmod__"], other)
118+
def __rpow__(self, other, modulo=None):
119+
if modulo is None:
120+
return self._jl_callmethod(_jl_methods["__rpow__"], other)
121+
else:
122+
return self._jl_callmethod(_jl_methods["__rpow_modulo__"], other, modulo)
123+
def __rlshift__(self, other):
124+
return self._jl_callmethod(_jl_methods["__rlshift__"], other)
125+
def __rrshift__(self, other):
126+
return self._jl_callmethod(_jl_methods["__rrshift__"], other)
127+
def __rand__(self, other):
128+
return self._jl_callmethod(_jl_methods["__rand__"], other)
129+
def __rxor__(self, other):
130+
return self._jl_callmethod(_jl_methods["__rxor__"], other)
131+
def __ror__(self, other):
132+
return self._jl_callmethod(_jl_methods["__ror__"], other)
133+
def __hash__(self):
134+
return self._jl_callmethod(_jl_methods["__hash__"])
135+
def __eq__(self, other):
136+
return self._jl_callmethod(_jl_methods["__eq__"], other)
137+
def __ne__(self, other):
138+
return self._jl_callmethod(_jl_methods["__ne__"], other)
139+
def __le__(self, other):
140+
return self._jl_callmethod(_jl_methods["__le__"], other)
141+
def __lt__(self, other):
142+
return self._jl_callmethod(_jl_methods["__lt__"], other)
143+
def __ge__(self, other):
144+
return self._jl_callmethod(_jl_methods["__ge__"], other)
145+
def __gt__(self, other):
146+
return self._jl_callmethod(_jl_methods["__gt__"], other)
147+
def __int__(self):
148+
return self._jl_callmethod(_jl_methods["__int__"])
149+
def __float__(self):
150+
return self._jl_callmethod(_jl_methods["__float__"])
151+
def __complex__(self):
152+
return self._jl_callmethod(_jl_methods["__complex__"])
153+
def __index__(self):
154+
return self._jl_callmethod(_jl_methods["__index__"])
155+
def __trunc__(self):
156+
return self._jl_callmethod(_jl_methods["__trunc__"])
157+
def __floor__(self):
158+
return self._jl_callmethod(_jl_methods["__floor__"])
159+
def __ceil__(self):
160+
return self._jl_callmethod(_jl_methods["__ceil__"])
161+
def __round__(self, ndigits=None):
162+
if ndigits is None:
163+
return self._jl_callmethod(_jl_methods["__round__"])
164+
else:
165+
return self._jl_callmethod(_jl_methods["__round__"], ndigits)
166+
@property
167+
def __name__(self):
168+
return self._jl_callmethod(_jl_methods["__name__"])
169+
def jl_display(self, mime=None):
170+
'''Display this, optionally specifying the MIME type.'''
171+
return self._jl_callmethod(_jl_methods["jl_display"], mime)
172+
def jl_help(self, mime=None):
173+
'''Show help for this Julia object.'''
174+
return self._jl_callmethod(_jl_methods["jl_help"], mime)
175+
def jl_eval(self, expr):
176+
return self._jl_callmethod(_jl_methods["jl_eval"], expr)
177+
def jl_to_py(self):
178+
return self._jl_callmethod(_jl_methods["jl_to_py"])
179+
def jl_callback(self, *args, **kwargs):
180+
return self._jl_callmethod(_jl_methods["jl_callback"], args, kwargs)
181+
def _repr_mimebundle_(self, include=None, exclude=None):
182+
return self._jl_callmethod(_jl_methods["_repr_mimebundle_"], include, exclude)
183+
@property
184+
def __numpy_dtype__(self):
185+
return self._jl_callmethod(_jl_methods["__numpy_dtype__"])

‎pysrc/juliacall/_jlwrap/array.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Julia wrappers initialized by PythonCall."""
2+
3+
import juliacall as jl
4+
from juliacall import JlBase
5+
from .collection import JlCollection
6+
7+
# Populated by Julia during initialization.
8+
_jl_methods = {}
9+
10+
11+
class JlArray(JlCollection):
12+
__module__ = "juliacall"
13+
__slots__ = ()
14+
_jl_buffer_info = None
15+
def __init__(self, value):
16+
JlBase.__init__(self, value, jl.Base.AbstractArray)
17+
@property
18+
def ndim(self):
19+
return self._jl_callmethod(_jl_methods["ndim"])
20+
@property
21+
def shape(self):
22+
return self._jl_callmethod(_jl_methods["shape"])
23+
def reshape(self, shape):
24+
return self._jl_callmethod(_jl_methods["reshape"], shape)
25+
def __getitem__(self, k):
26+
return self._jl_callmethod(_jl_methods["__getitem__"], k)
27+
def __setitem__(self, k, v):
28+
self._jl_callmethod(_jl_methods["__setitem__"], k, v)
29+
def __delitem__(self, k):
30+
self._jl_callmethod(_jl_methods["__delitem__"], k)
31+
@property
32+
def __array_interface__(self):
33+
return self._jl_callmethod(_jl_methods["__array_interface__"])
34+
def __array__(self, dtype=None, copy=None):
35+
import numpy
36+
# convert to an array-like object
37+
arr = self
38+
if not (hasattr(arr, "__array_interface__") or hasattr(arr, "__array_struct__")):
39+
if copy is False:
40+
raise ValueError("copy=False is not supported when collecting ArrayValue data")
41+
# the first attempt collects into an Array
42+
arr = self._jl_callmethod(_jl_methods["__array__"])
43+
if not (hasattr(arr, "__array_interface__") or hasattr(arr, "__array_struct__")):
44+
# the second attempt collects into a PyObjectArray
45+
arr = self._jl_callmethod(_jl_methods["__array___2"])
46+
# convert to a numpy array if numpy is available
47+
return numpy.array(arr, dtype=dtype, copy=copy)
48+
def to_numpy(self, dtype=None, copy=True, order="K"):
49+
import numpy
50+
return numpy.array(self, dtype=dtype, copy=copy, order=order)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Helpers for wrapping Julia callbacks as Python functions."""
2+
3+
4+
def wrap_callback(f):
5+
return lambda *args, **kwargs: f(*args, **kwargs)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Julia wrappers initialized by PythonCall."""
2+
3+
from juliacall import JlBase
4+
from .any import JlBase2
5+
6+
# Populated by Julia during initialization.
7+
_jl_methods = {}
8+
9+
10+
class JlCollection(JlBase2):
11+
__module__ = "juliacall"
12+
__slots__ = ()
13+
def __len__(self):
14+
return self._jl_callmethod(_jl_methods["__len__"])
15+
def __bool__(self):
16+
return self._jl_callmethod(_jl_methods["__bool__"])
17+
def __iter__(self):
18+
return self._jl_callmethod(_jl_methods["__iter__"])
19+
def __hash__(self):
20+
return self._jl_callmethod(_jl_methods["__hash__"])
21+
def __eq__(self, other):
22+
if isinstance(self, type(other)) or isinstance(other, type(self)):
23+
return self._jl_callmethod(_jl_methods["__eq__"], other)
24+
else:
25+
return NotImplemented
26+
def __contains__(self, v):
27+
return self._jl_callmethod(_jl_methods["__contains__"], v)
28+
def copy(self):
29+
return self._jl_callmethod(_jl_methods["copy"])
30+
def clear(self):
31+
return self._jl_callmethod(_jl_methods["clear"])
32+
import collections.abc
33+
collections.abc.Collection.register(JlCollection)
34+
del collections

0 commit comments

Comments
 (0)