diff --git a/.github/workflows/pr_code_check.yaml b/.github/workflows/pr_code_check.yaml
index 9ed87684..876f5fd2 100644
--- a/.github/workflows/pr_code_check.yaml
+++ b/.github/workflows/pr_code_check.yaml
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- platform: [windows-latest, macos-latest, ubuntu-latest]
- python-version: ["3.9", "3.12"]
+ platform: [windows-latest, ubuntu-latest, macos-15-intel, macos-15]
+ python-version: ["3.9", "3.12", "3.14", "3.15"]
runs-on: ${{ matrix.platform }}
@@ -35,16 +35,14 @@ jobs:
- name: Upgrade pip
run: python -m pip install --upgrade pip
- - name: Installing dependencies
- uses: py-actions/py-dependency-install@v4
- with:
- path: "requirements.txt"
-
- name: Add requirements
run: python -m pip install --upgrade setuptools wheel build packaging
- name: Build and install
run: pip install --verbose .
+ - name: Check optional dependency and PyTorch wheel policy logic
+ run: python -m unittest discover -s scripts/tests -p "test_*.py"
+
- name: Smoke test
- run: python -c "import os, tempfile; os.chdir(tempfile.gettempdir()); import easygraph as eg; G = eg.Graph(); G.add_edge(1, 2); assert G.number_of_nodes() == 2; assert G.number_of_edges() == 1"
+ run: python scripts/wheel_smoke.py
diff --git a/.github/workflows/wheel_publish.yml b/.github/workflows/wheel_publish.yml
index 2065873f..b277d4e4 100644
--- a/.github/workflows/wheel_publish.yml
+++ b/.github/workflows/wheel_publish.yml
@@ -6,6 +6,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ with:
+ submodules: recursive
# sdist used to compile in user local environment
- name: Build sdist
run: pipx run build --sdist
@@ -24,13 +26,13 @@ jobs:
# macos-15-intel is for the x86_64
# note: Apple has discontinued support for the x86_64 (Intel) architecture
os: [ ubuntu-latest, windows-latest, macos-15, macos-15-intel]
-# modify python version in project.toml
+# modify Python versions in pyproject.toml
steps:
# 检出代码
- name: Checkout code
uses: actions/checkout@v4
with:
- submodules: true
+ submodules: recursive
- uses: actions/setup-python@v5
@@ -41,8 +43,18 @@ jobs:
run: python -m pip install --upgrade pip setuptools wheel build packaging
- - name: Build wheels
+ # cibuildwheel 4 supports Python 3.15 but no longer supports Python 3.8.
+ - name: Build Python 3.8-3.13 wheels
uses: pypa/cibuildwheel@v2.23.3
+ env:
+ CIBW_BUILD: "cp38-* cp39-* cp310-* cp311-* cp312-* cp313-*"
+ with:
+ output-dir: dist/
+
+ - name: Build Python 3.14-3.15 wheels
+ uses: pypa/cibuildwheel@v4.3.0
+ env:
+ CIBW_BUILD: "cp314-* cp315-*"
with:
output-dir: dist/
diff --git a/MANIFEST.in b/MANIFEST.in
index 3fe40acc..a60cd7ad 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,3 +1,8 @@
include README.md
include CMakeLists.txt
-recursive-include cpp_easygraph *
\ No newline at end of file
+include scripts/wheel_smoke.py
+include scripts/check_pytorch_support.py
+include scripts/PYTHON_RELEASE.md
+recursive-include scripts/tests *.py
+include easygraph/_torch_compatibility.json
+recursive-include cpp_easygraph *
diff --git a/README.md b/README.md
index 46eba275..dc8419a3 100644
--- a/README.md
+++ b/README.md
@@ -102,7 +102,7 @@ It bridges the gap between EasyGraph and higher-order relationships. EasyHypergr
## Supported Versions
-``3.8 <= Python <= 3.14`` is required.
+``3.8 <= Python < 3.16`` is required.
## Installation With pip
```
@@ -146,9 +146,15 @@ If prebuilt EasyGraph wheels are not supported for your platform (OS / CPU arch,
## Hint
-EasyGraph uses 1.12.1 <= [PyTorch](https://pytorch.org/get-started/locally/) < 2.0 for machine learning functions.
-Note that this does not prevent your from running non-machine learning functions normally, if there is no PyTorch in your environment.
-But you will receive some warnings which remind you some unavailable modules when they depend on it.
+EasyGraph automatically installs a compatible PyTorch version when a compatible
+stable wheel is listed in its release policy for your Python version, OS and CPU
+on PyPI. Otherwise, core graph algorithms and the C++ backend remain available,
+and importing EasyGraph shows one message linking to the [official PyTorch
+installer](https://pytorch.org/get-started/locally/). Install PyTorch from there
+and restart Python to enable its features. Additional graph-learning packages
+may require `pip install 'Python-EasyGraph[torch]'` or their own installation
+instructions. Maintainers can refresh the policy for a new Python release using
+[the release checklist](scripts/PYTHON_RELEASE.md).
# Simple Example
diff --git a/README.rst b/README.rst
index b60e85f8..1dda57c4 100644
--- a/README.rst
+++ b/README.rst
@@ -55,7 +55,7 @@ Install
- **Prerequisites**
-``3.8 <= Python <= 3.11`` is required.
+``3.8 <= Python < 3.16`` is required.
.. Installation with ``pip`` (outdated)
@@ -80,11 +80,15 @@ If prebuilt EasyGraph wheels are not supported for your platform (OS / CPU arch,
- **Hint**
- EasyGraph uses 1.12.1 <= `PyTorch `_ < 2.0 for machine
- learning functions.
- Note that this does not prevent your from running non-machine learning functions normally,
- if there is no PyTorch in your environment.
- But you will receive some warnings which remind you some unavailable modules when they depend on it.
+ EasyGraph automatically installs a compatible PyTorch version when its release
+ policy lists a compatible stable wheel for your Python version, OS and CPU
+ on PyPI. Otherwise, core graph algorithms and the C++ backend remain available.
+ Importing EasyGraph shows one message with the
+ `official PyTorch installer `_.
+ Install PyTorch there and restart Python to enable its features. Additional
+ graph-learning packages may need ``Python-EasyGraph[torch]`` or their own
+ installation instructions. See ``scripts/PYTHON_RELEASE.md`` for the
+ repeatable release checklist.
Simple Example
--------------
diff --git a/easygraph/__init__.py b/easygraph/__init__.py
index 1daedcc6..af8bc3df 100644
--- a/easygraph/__init__.py
+++ b/easygraph/__init__.py
@@ -11,6 +11,9 @@
import easygraph.readwrite
import easygraph.utils
+from easygraph._optional import HAS_TORCH as _HAS_TORCH
+from easygraph._optional import require_torch as _require_torch
+from easygraph._optional import warn_missing_torch as _warn_missing_torch
from easygraph.classes import *
from easygraph.convert import *
from easygraph.datapipe import *
@@ -25,5 +28,10 @@
from easygraph.utils import *
+_warn_missing_torch()
+
+
def __getattr__(name):
- print(f"attr {name} doesn't exist!")
+ if not _HAS_TORCH and name in {"Hypergraph", "BaseHypergraph", "load_structure"}:
+ _require_torch(name)
+ raise AttributeError("module 'easygraph' has no attribute {!r}".format(name))
diff --git a/easygraph/_optional.py b/easygraph/_optional.py
new file mode 100644
index 00000000..c7926ed4
--- /dev/null
+++ b/easygraph/_optional.py
@@ -0,0 +1,58 @@
+"""Shared handling for optional machine-learning dependencies."""
+
+import sys
+import warnings
+
+from contextlib import contextmanager
+
+
+PYTORCH_INSTALL_URL = "https://pytorch.org/get-started/locally/"
+
+try:
+ import torch as _torch
+except ModuleNotFoundError as error:
+ if error.name != "torch":
+ raise
+ _torch = None
+
+HAS_TORCH = _torch is not None
+
+
+def require_torch(feature="This feature"):
+ if _torch is None:
+ raise ImportError(
+ "{} requires PyTorch. Install a build for your Python version and "
+ "platform using {}, then restart Python.".format(
+ feature, PYTORCH_INSTALL_URL
+ )
+ )
+ return _torch
+
+
+def warn_missing_torch():
+ if not HAS_TORCH:
+ warnings.warn(
+ "EasyGraph: PyTorch is not installed for Python {}.{}. Core graph "
+ "algorithms and the C++ backend remain available. For PyTorch-based "
+ "features, install a compatible build using {} and restart Python.".format(
+ sys.version_info.major, sys.version_info.minor, PYTORCH_INSTALL_URL
+ ),
+ UserWarning,
+ stacklevel=2,
+ )
+
+
+@contextmanager
+def optional_torch_modules():
+ """Allow absent optional packages, while preserving unrelated import errors."""
+ try:
+ yield
+ except ModuleNotFoundError as error:
+ if error.name not in {
+ "torch",
+ "torch_geometric",
+ "torch_scatter",
+ "torch_sparse",
+ "sklearn",
+ }:
+ raise
diff --git a/easygraph/_torch_compatibility.json b/easygraph/_torch_compatibility.json
new file mode 100644
index 00000000..725988c8
--- /dev/null
+++ b/easygraph/_torch_compatibility.json
@@ -0,0 +1,438 @@
+{
+ "schema_version": 1,
+ "checked_at": "2026-10-12T06:05:18+00:00",
+ "source_url": "https://pypi.org/pypi/torch/json",
+ "official_install_url": "https://pytorch.org/get-started/locally/",
+ "minimum_torch_version": "2.0",
+ "availability_scope": "PyPI wheel platform/CPU family, Python .0 baseline, available torch interval, and minimum macOS wheel tag. Pip must select a compatible wheel; actual resolution, core imports, libc, GPU drivers, and transitive dependencies require validation on the intended operating system.",
+ "python_versions": [
+ "3.8",
+ "3.9",
+ "3.10",
+ "3.11",
+ "3.12",
+ "3.13",
+ "3.14",
+ "3.15"
+ ],
+ "targets": [
+ {
+ "id": "linux_x86_64",
+ "sys_platform": "linux",
+ "platform_system": "Linux",
+ "platform_machines": [
+ "x86_64",
+ "AMD64",
+ "amd64"
+ ],
+ "wheel_platform_suffixes": [
+ "_x86_64"
+ ]
+ },
+ {
+ "id": "linux_aarch64",
+ "sys_platform": "linux",
+ "platform_system": "Linux",
+ "platform_machines": [
+ "aarch64",
+ "arm64",
+ "ARM64"
+ ],
+ "wheel_platform_suffixes": [
+ "_aarch64"
+ ]
+ },
+ {
+ "id": "windows_amd64",
+ "sys_platform": "win32",
+ "platform_system": "Windows",
+ "platform_machines": [
+ "AMD64",
+ "amd64",
+ "x86_64"
+ ],
+ "wheel_platform_suffixes": [
+ "win_amd64"
+ ]
+ },
+ {
+ "id": "windows_arm64",
+ "sys_platform": "win32",
+ "platform_system": "Windows",
+ "platform_machines": [
+ "ARM64",
+ "arm64",
+ "aarch64"
+ ],
+ "wheel_platform_suffixes": [
+ "win_arm64"
+ ]
+ },
+ {
+ "id": "macos_x86_64",
+ "sys_platform": "darwin",
+ "platform_system": "Darwin",
+ "platform_machines": [
+ "x86_64",
+ "AMD64",
+ "amd64"
+ ],
+ "wheel_platform_suffixes": [
+ "_x86_64",
+ "_universal2"
+ ]
+ },
+ {
+ "id": "macos_arm64",
+ "sys_platform": "darwin",
+ "platform_system": "Darwin",
+ "platform_machines": [
+ "arm64",
+ "aarch64",
+ "ARM64"
+ ],
+ "wheel_platform_suffixes": [
+ "_arm64",
+ "_universal2"
+ ]
+ }
+ ],
+ "support": [
+ {
+ "python_version": "3.8",
+ "target_id": "linux_x86_64",
+ "torch_version": "2.4.1",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.8",
+ "target_id": "linux_aarch64",
+ "torch_version": "2.4.1",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.8",
+ "target_id": "windows_amd64",
+ "torch_version": "2.4.1",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.8",
+ "target_id": "windows_arm64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.8",
+ "target_id": "macos_x86_64",
+ "torch_version": "2.2.2",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": "10.9"
+ },
+ {
+ "python_version": "3.8",
+ "target_id": "macos_arm64",
+ "torch_version": "2.4.1",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": "11.0"
+ },
+ {
+ "python_version": "3.9",
+ "target_id": "linux_x86_64",
+ "torch_version": "2.8.0",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.9",
+ "target_id": "linux_aarch64",
+ "torch_version": "2.8.0",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.9",
+ "target_id": "windows_amd64",
+ "torch_version": "2.8.0",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.9",
+ "target_id": "windows_arm64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.9",
+ "target_id": "macos_x86_64",
+ "torch_version": "2.2.2",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": "10.9"
+ },
+ {
+ "python_version": "3.9",
+ "target_id": "macos_arm64",
+ "torch_version": "2.8.0",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": "11.0"
+ },
+ {
+ "python_version": "3.10",
+ "target_id": "linux_x86_64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.10",
+ "target_id": "linux_aarch64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.10",
+ "target_id": "windows_amd64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.10",
+ "target_id": "windows_arm64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.10",
+ "target_id": "macos_x86_64",
+ "torch_version": "2.2.2",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": "10.9"
+ },
+ {
+ "python_version": "3.10",
+ "target_id": "macos_arm64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": "11.0"
+ },
+ {
+ "python_version": "3.11",
+ "target_id": "linux_x86_64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.11",
+ "target_id": "linux_aarch64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.11",
+ "target_id": "windows_amd64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.11",
+ "target_id": "windows_arm64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.11",
+ "target_id": "macos_x86_64",
+ "torch_version": "2.2.2",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": "10.9"
+ },
+ {
+ "python_version": "3.11",
+ "target_id": "macos_arm64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.0.0",
+ "minimum_macos_version": "11.0"
+ },
+ {
+ "python_version": "3.12",
+ "target_id": "linux_x86_64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.2.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.12",
+ "target_id": "linux_aarch64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.2.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.12",
+ "target_id": "windows_amd64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.2.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.12",
+ "target_id": "windows_arm64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.12",
+ "target_id": "macos_x86_64",
+ "torch_version": "2.2.2",
+ "minimum_torch_version": "2.2.0",
+ "minimum_macos_version": "10.9"
+ },
+ {
+ "python_version": "3.12",
+ "target_id": "macos_arm64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.2.0",
+ "minimum_macos_version": "11.0"
+ },
+ {
+ "python_version": "3.13",
+ "target_id": "linux_x86_64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.5.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.13",
+ "target_id": "linux_aarch64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.6.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.13",
+ "target_id": "windows_amd64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.6.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.13",
+ "target_id": "windows_arm64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.13",
+ "target_id": "macos_x86_64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.13",
+ "target_id": "macos_arm64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.6.0",
+ "minimum_macos_version": "11.0"
+ },
+ {
+ "python_version": "3.14",
+ "target_id": "linux_x86_64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.9.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.14",
+ "target_id": "linux_aarch64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.9.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.14",
+ "target_id": "windows_amd64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.9.0",
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.14",
+ "target_id": "windows_arm64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.14",
+ "target_id": "macos_x86_64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.14",
+ "target_id": "macos_arm64",
+ "torch_version": "2.14.1",
+ "minimum_torch_version": "2.9.0",
+ "minimum_macos_version": "11.0"
+ },
+ {
+ "python_version": "3.15",
+ "target_id": "linux_x86_64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.15",
+ "target_id": "linux_aarch64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.15",
+ "target_id": "windows_amd64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.15",
+ "target_id": "windows_arm64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.15",
+ "target_id": "macos_x86_64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ },
+ {
+ "python_version": "3.15",
+ "target_id": "macos_arm64",
+ "torch_version": null,
+ "minimum_torch_version": null,
+ "minimum_macos_version": null
+ }
+ ]
+}
diff --git a/easygraph/classes/__init__.py b/easygraph/classes/__init__.py
index b5b315bd..6dcbe8eb 100644
--- a/easygraph/classes/__init__.py
+++ b/easygraph/classes/__init__.py
@@ -1,3 +1,6 @@
+from easygraph._optional import HAS_TORCH as _HAS_TORCH
+from easygraph._optional import optional_torch_modules as _torch_imports
+
from .directed_graph import DiGraph
from .directed_graph import DiGraphC
from .directed_multigraph import MultiDiGraph
@@ -8,12 +11,8 @@
from .operation import *
-try:
- from .base import BaseHypergraph
- from .base import load_structure
- from .hypergraph import Hypergraph
-except:
- print(
- "Warning raise in module:classes. Please install Pytorch before you use"
- " functions related to Hypergraph"
- )
+if _HAS_TORCH:
+ with _torch_imports():
+ from .base import BaseHypergraph
+ from .base import load_structure
+ from .hypergraph import Hypergraph
diff --git a/easygraph/classes/graph.py b/easygraph/classes/graph.py
index 07caabde..1d9d8266 100644
--- a/easygraph/classes/graph.py
+++ b/easygraph/classes/graph.py
@@ -9,6 +9,7 @@
import easygraph as eg
import easygraph.convert as convert
+from easygraph._optional import require_torch
from easygraph.utils.exception import EasyGraphError
from easygraph.utils.sparse import sparse_dropout
@@ -181,7 +182,7 @@ def e_both_side(self, weight="weight") -> Tuple[List[List], List[float]]:
def A(self):
r"""Return the adjacency matrix :math:`\mathbf{A}` of the sample graph with ``torch.sparse_coo_tensor`` format. Size :math:`(|\mathcal{V}|, |\mathcal{V}|)`.
"""
- import torch
+ torch = require_torch("Graph tensor operations")
if self.cache.get("A", None) is None:
if len(self.edges) == 0:
@@ -210,7 +211,7 @@ def D_v_neg_1_2(
):
r"""Return the normalized diagonal matrix of vertex degree :math:`\mathbf{D}_v^{-\frac{1}{2}}` with ``torch.sparse_coo_tensor`` format. Size :math:`(|\mathcal{V}|, |\mathcal{V}|)`.
"""
- import torch
+ torch = require_torch("Graph tensor operations")
if self.cache.get("D_v_neg_1_2") is None:
if self.cache.get("D_v_value") is None:
@@ -297,7 +298,7 @@ def e(self) -> Tuple[List[List[int]], List[float]]:
def D_v(self):
r"""Return the diagonal matrix of vertex degree :math:`\mathbf{D}_v` with ``torch.sparse_coo_tensor`` format. Size :math:`(|\mathcal{V}|, |\mathcal{V}|)`.
"""
- import torch
+ torch = require_torch("Graph tensor operations")
if self.cache.get("D_v") is None:
# print("self.A:",self.A)
@@ -345,7 +346,7 @@ def nbr_v(self, v_idx: int) -> Tuple[List[int], List[float]]:
return self.N_v(v_idx).cpu().numpy().tolist()
def N_v(self, v_idx: int) -> Tuple[List[int], List[float]]:
- import torch
+ torch = require_torch("Graph tensor operations")
r"""Return the neighbors of the vertex ``v_idx`` with ``torch.Tensor`` format.
@@ -486,7 +487,7 @@ def L_GCN(self):
\mathcal{L}_{GCN} = \mathbf{\hat{D}}_v^{-\frac{1}{2}} \mathbf{\hat{A}} \mathbf{\hat{D}}_v^{-\frac{1}{2}}
"""
- import torch
+ torch = require_torch("Graph tensor operations")
if self.cache.get("L_GCN") is None:
# self.add_extra_selfloop()
@@ -502,7 +503,7 @@ def smoothing_with_GCN(self, X, drop_rate=0.0):
``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`.
``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in adjacency matrix with probability ``drop_rate``. Default: ``0.0``.
"""
- import torch
+ torch = require_torch("Graph tensor operations")
if drop_rate > 0.0:
L_GCN = sparse_dropout(self.L_GCN, drop_rate)
diff --git a/easygraph/datapipe/__init__.py b/easygraph/datapipe/__init__.py
index cdc747f3..eab41ee9 100644
--- a/easygraph/datapipe/__init__.py
+++ b/easygraph/datapipe/__init__.py
@@ -1,15 +1,15 @@
-try:
- from .common import compose_pipes
- from .common import to_bool_tensor
- from .common import to_long_tensor
- from .common import to_tensor
- from .normalize import min_max_scaler
- from .normalize import norm_ft
-except:
- print(
- "Warning raise in module:datapipe. Please install Pytorch before you use"
- " functions related to nueral network"
- )
+from easygraph._optional import HAS_TORCH as _HAS_TORCH
+from easygraph._optional import optional_torch_modules as _torch_imports
+
+
+if _HAS_TORCH:
+ with _torch_imports():
+ from .common import compose_pipes
+ from .common import to_bool_tensor
+ from .common import to_long_tensor
+ from .common import to_tensor
+ from .normalize import min_max_scaler
+ from .normalize import norm_ft
from .loader import load_from_json
from .loader import load_from_pickle
diff --git a/easygraph/datasets/__init__.py b/easygraph/datasets/__init__.py
index 035ada50..9e3fb656 100644
--- a/easygraph/datasets/__init__.py
+++ b/easygraph/datasets/__init__.py
@@ -1,21 +1,23 @@
-# risky imports
-try:
- from easygraph.datasets.get_sample_graph import *
- from easygraph.datasets.gnn_benchmark import *
- from easygraph.datasets.hypergraph.coauthorship import *
- from easygraph.datasets.hypergraph.contact_primary_school import *
- from easygraph.datasets.hypergraph.cooking_200 import Cooking200
- from easygraph.datasets.hypergraph.House_Committees import House_Committees
- from easygraph.datasets.karate import KarateClubDataset
- from easygraph.datasets.mathoverflow_answers import mathoverflow_answers
+from easygraph._optional import HAS_TORCH as _HAS_TORCH
+from easygraph._optional import optional_torch_modules as _torch_imports
+from easygraph.datasets.get_sample_graph import *
- from .ppi import LegacyPPIDataset
- from .ppi import PPIDataset
-except Exception as e:
- print(
- " Please install Pytorch before use graph-related datasets and"
- " hypergraph-related datasets."
- )
+
+# Torch-dependent dataset imports
+if _HAS_TORCH:
+ with _torch_imports():
+ from easygraph.datasets.gnn_benchmark import *
+ from easygraph.datasets.hypergraph.coauthorship import *
+ from easygraph.datasets.hypergraph.contact_primary_school import *
+ from easygraph.datasets.hypergraph.cooking_200 import Cooking200
+ from easygraph.datasets.hypergraph.House_Committees import House_Committees
+ from easygraph.datasets.karate import KarateClubDataset
+ from easygraph.datasets.hypergraph.mathoverflow_answers import (
+ mathoverflow_answers,
+ )
+
+ from .ppi import LegacyPPIDataset
+ from .ppi import PPIDataset
from .amazon_photo import AmazonPhotoDataset
from .arxiv import ArxivHEPTHDataset
diff --git a/easygraph/datasets/utils.py b/easygraph/datasets/utils.py
index b48fb397..4d66aae4 100644
--- a/easygraph/datasets/utils.py
+++ b/easygraph/datasets/utils.py
@@ -7,7 +7,8 @@
import numpy as np
import requests
-import torch as th
+
+from easygraph._optional import require_torch
__all__ = [
@@ -58,11 +59,13 @@ def _set_features(G, features):
def nonzero_1d(input):
+ th = require_torch("Dataset tensor operations")
x = th.nonzero(input, as_tuple=False).squeeze()
return x if x.dim() == 1 else x.view(-1)
def tensor(data, dtype=None):
+ th = require_torch("Dataset tensor operations")
if isinstance(data, numbers.Number):
data = [data]
if isinstance(data, list) and len(data) > 0 and isinstance(data[0], th.Tensor):
@@ -77,6 +80,7 @@ def tensor(data, dtype=None):
def data_type_dict():
+ th = require_torch("Dataset tensor operations")
return {
"float16": th.float16,
"float32": th.float32,
diff --git a/easygraph/experiments/__init__.py b/easygraph/experiments/__init__.py
index 8cc6a559..e5652231 100644
--- a/easygraph/experiments/__init__.py
+++ b/easygraph/experiments/__init__.py
@@ -1,10 +1,8 @@
-try:
- from .base import BaseTask
- from .hypergraphs import HypergraphVertexClassificationTask
+from easygraph._optional import HAS_TORCH as _HAS_TORCH
+from easygraph._optional import optional_torch_modules as _torch_imports
-except:
- print(
- "Warning raise in module: experiments. Please install Pytorch before you use"
- " functions related to nueral network"
- )
+if _HAS_TORCH:
+ with _torch_imports():
+ from .base import BaseTask
+ from .hypergraphs import HypergraphVertexClassificationTask
diff --git a/easygraph/functions/__init__.py b/easygraph/functions/__init__.py
index 9e4b095f..61c344e1 100644
--- a/easygraph/functions/__init__.py
+++ b/easygraph/functions/__init__.py
@@ -1,3 +1,5 @@
+from easygraph._optional import HAS_TORCH as _HAS_TORCH
+from easygraph._optional import optional_torch_modules as _torch_imports
from easygraph.functions.basic import *
from easygraph.functions.centrality import *
from easygraph.functions.community import *
@@ -11,11 +13,6 @@
from easygraph.functions.structural_holes import *
-try:
- from easygraph.functions.hypergraph import *
-except:
- print(
- "Warning raise in module:model.Please install "
- "Pytorch before you use functions"
- " related to Hypergraph"
- )
+if _HAS_TORCH:
+ with _torch_imports():
+ from easygraph.functions.hypergraph import *
diff --git a/easygraph/functions/graph_embedding/__init__.py b/easygraph/functions/graph_embedding/__init__.py
index 7bdfadaa..4968d5fc 100644
--- a/easygraph/functions/graph_embedding/__init__.py
+++ b/easygraph/functions/graph_embedding/__init__.py
@@ -1,13 +1,12 @@
+from easygraph._optional import HAS_TORCH as _HAS_TORCH
+from easygraph._optional import optional_torch_modules as _torch_imports
+
from .deepwalk import *
from .NOBE import *
from .node2vec import *
-try:
- from .line import *
- from .sdne import *
-except:
- print(
- "Warning raise in module:graph_embedding. Please install packages Pytorch"
- " before you use functions related to graph_embedding"
- )
+if _HAS_TORCH:
+ with _torch_imports():
+ from .line import *
+ from .sdne import *
diff --git a/easygraph/ml_metrics/__init__.py b/easygraph/ml_metrics/__init__.py
index 7e92121d..c43461cc 100644
--- a/easygraph/ml_metrics/__init__.py
+++ b/easygraph/ml_metrics/__init__.py
@@ -1,19 +1,19 @@
-try:
- from typing import Dict
- from typing import List
- from typing import Union
+from typing import Dict
+from typing import List
+from typing import Union
- from easygraph._global import AUTHOR_EMAIL
+from easygraph._global import AUTHOR_EMAIL
+from easygraph._optional import HAS_TORCH as _HAS_TORCH
+from easygraph._optional import optional_torch_modules as _torch_imports
+from easygraph._optional import require_torch as _require_torch
- from .base import BaseEvaluator
- from .classification import VertexClassificationEvaluator
- from .classification import available_classification_metrics
- from .hypergraphs import HypergraphVertexClassificationEvaluator
-except:
- print(
- "Warning raise in module:ml_metrics. Please install Pytorch before you use"
- " functions related to nueral network"
- )
+
+if _HAS_TORCH:
+ with _torch_imports():
+ from .base import BaseEvaluator
+ from .classification import VertexClassificationEvaluator
+ from .classification import available_classification_metrics
+ from .hypergraphs import HypergraphVertexClassificationEvaluator
def build_evaluator(
@@ -28,6 +28,7 @@ def build_evaluator(
``metric_configs`` (``List[Union[str, Dict[str, dict]]]``): The list of metric names.
``validate_index`` (``int``): The specified metric index used for validation. Defaults to ``0``.
"""
+ _require_torch("Machine-learning evaluators")
if task == "hypergraph_vertex_classification":
return HypergraphVertexClassificationEvaluator(metric_configs, validate_index)
else:
diff --git a/easygraph/model/__init__.py b/easygraph/model/__init__.py
index 37132127..27667726 100644
--- a/easygraph/model/__init__.py
+++ b/easygraph/model/__init__.py
@@ -1,19 +1,17 @@
-try:
- from .hypergraphs import DHCF
- from .hypergraphs import HGNN
- from .hypergraphs import HGNNP
- from .hypergraphs import HNHN
- from .hypergraphs import HWNN
- from .hypergraphs import HyperGCN
- from .hypergraphs import SetGNN
- from .hypergraphs import UniGAT
- from .hypergraphs import UniGCN
- from .hypergraphs import UniGIN
- from .hypergraphs import UniSAGE
+from easygraph._optional import HAS_TORCH as _HAS_TORCH
+from easygraph._optional import optional_torch_modules as _torch_imports
-except:
- print(
- "Warning raise in module:model.Please install "
- "Pytorch before you use hypergraph neural networks"
- " related to Hypergraph"
- )
+
+if _HAS_TORCH:
+ with _torch_imports():
+ from .hypergraphs import DHCF
+ from .hypergraphs import HGNN
+ from .hypergraphs import HGNNP
+ from .hypergraphs import HNHN
+ from .hypergraphs import HWNN
+ from .hypergraphs import HyperGCN
+ from .hypergraphs import SetGNN
+ from .hypergraphs import UniGAT
+ from .hypergraphs import UniGCN
+ from .hypergraphs import UniGIN
+ from .hypergraphs import UniSAGE
diff --git a/easygraph/nn/__init__.py b/easygraph/nn/__init__.py
index 0c2386cf..2f73d452 100644
--- a/easygraph/nn/__init__.py
+++ b/easygraph/nn/__init__.py
@@ -1,23 +1,22 @@
-try:
- from .convs.common import MLP
- from .convs.common import MultiHeadWrapper
- from .convs.hypergraphs import HGNNConv
- from .convs.hypergraphs import HGNNPConv
- from .convs.hypergraphs import HNHNConv
- from .convs.hypergraphs import HWNNConv
- from .convs.hypergraphs import HyperGCNConv
- from .convs.hypergraphs import JHConv
- from .convs.hypergraphs import UniGATConv
- from .convs.hypergraphs import UniGCNConv
- from .convs.hypergraphs import UniGINConv
- from .convs.hypergraphs import UniSAGEConv
- from .convs.hypergraphs.halfnlh_conv import HalfNLHconv
- from .convs.pma import PMA
- from .loss import BPRLoss
- from .regularization import EmbeddingRegularization
-except:
- print(
- "Warning raise in module:nn. Please install Pytorch, torch_geometric,"
- " torch_scatter before you use functions related to AllDeepSet and"
- " AllSetTransformer."
- )
+from easygraph._optional import HAS_TORCH as _HAS_TORCH
+from easygraph._optional import optional_torch_modules as _torch_imports
+
+
+if _HAS_TORCH:
+ with _torch_imports():
+ from .convs.common import MLP
+ from .convs.common import MultiHeadWrapper
+ from .convs.hypergraphs import HGNNConv
+ from .convs.hypergraphs import HGNNPConv
+ from .convs.hypergraphs import HNHNConv
+ from .convs.hypergraphs import HWNNConv
+ from .convs.hypergraphs import HyperGCNConv
+ from .convs.hypergraphs import JHConv
+ from .convs.hypergraphs import UniGATConv
+ from .convs.hypergraphs import UniGCNConv
+ from .convs.hypergraphs import UniGINConv
+ from .convs.hypergraphs import UniSAGEConv
+ from .convs.hypergraphs.halfnlh_conv import HalfNLHconv
+ from .convs.pma import PMA
+ from .loss import BPRLoss
+ from .regularization import EmbeddingRegularization
diff --git a/easygraph/utils/HIF.py b/easygraph/utils/HIF.py
index eb8543c3..25a1e97d 100644
--- a/easygraph/utils/HIF.py
+++ b/easygraph/utils/HIF.py
@@ -1,19 +1,37 @@
+from __future__ import annotations
+
import json
-import requests
-import fastjsonschema
+
from copy import deepcopy
-from typing import Optional, Union, List, Dict, Any
from pathlib import Path
-from easygraph.classes.hypergraph import Hypergraph
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import Dict
+from typing import List
+from typing import Optional
+from typing import Union
+
+import fastjsonschema
+import requests
+
+from easygraph._optional import require_torch
+
+
+if TYPE_CHECKING:
+ from easygraph.classes.hypergraph import Hypergraph
schema_url = "https://raw.githubusercontent.com/pszufe/HIF_validators/main/schemas/hif_schema_v0.1.0.json"
+
class EasyGraphHIFError(Exception):
"""Custom exception for HIF conversion errors."""
+
pass
+
_hif_validator = None
+
def _get_hif_validator():
global _hif_validator
if _hif_validator is None:
@@ -25,9 +43,10 @@ def _get_hif_validator():
except Exception:
print("Warning: HIF Schema could not be fetched. Validation skipped.")
_hif_validator = lambda x: True
-
+
return _hif_validator if _hif_validator else (lambda x: True)
+
def hypergraph_to_hif(
hg: Hypergraph,
filename: Optional[Union[str, Path]] = None,
@@ -38,17 +57,26 @@ def hypergraph_to_hif(
Converts an EasyGraph Hypergraph to HIF JSON.
Correctly handles hg.e tuple structure ((edges), (weights), (props)).
"""
-
+
if hasattr(hg, "custom_hif_nodes"):
nodj = hg.custom_hif_nodes
else:
nodj = []
- num_v = hg.num_v if hasattr(hg, "num_v") else len(hg.v_property) if hasattr(hg, "v_property") else 0
+ num_v = (
+ hg.num_v
+ if hasattr(hg, "num_v")
+ else len(hg.v_property)
+ if hasattr(hg, "v_property")
+ else 0
+ )
v_props = getattr(hg, "v_property", [{} for _ in range(num_v)])
- if not v_props and num_v > 0: v_props = [{} for _ in range(num_v)]
+ if not v_props and num_v > 0:
+ v_props = [{} for _ in range(num_v)]
for i in range(num_v):
- props = v_props[i] if i < len(v_props) and isinstance(v_props[i], dict) else {}
+ props = (
+ v_props[i] if i < len(v_props) and isinstance(v_props[i], dict) else {}
+ )
p = props.copy()
weight = p.pop("weight", 1.0)
if node_label in p:
@@ -63,26 +91,33 @@ def hypergraph_to_hif(
e_weights = []
e_props = []
- if hasattr(hg, "e") and isinstance(hg.e, tuple) and len(hg.e) == 3 and \
- isinstance(hg.e[0], (list, tuple)) and isinstance(hg.e[1], (list, tuple)):
+ if (
+ hasattr(hg, "e")
+ and isinstance(hg.e, tuple)
+ and len(hg.e) == 3
+ and isinstance(hg.e[0], (list, tuple))
+ and isinstance(hg.e[1], (list, tuple))
+ ):
e_structure = hg.e[0]
e_weights = hg.e[1]
e_props = hg.e[2]
-
+
elif hasattr(hg, "e_list") and hg.e_list:
e_structure = hg.e_list
e_weights = getattr(hg, "e_weight", [1.0] * len(e_structure))
e_props = getattr(hg, "e_property_full", [{} for _ in range(len(e_structure))])
-
+
elif hasattr(hg, "e") and isinstance(hg.e, (list, tuple)):
e_structure = hg.e
e_weights = getattr(hg, "e_weight", [1.0] * len(e_structure))
e_props = getattr(hg, "e_property_full", [{} for _ in range(len(e_structure))])
num_e = len(e_structure)
-
- if len(e_weights) < num_e: e_weights = [1.0] * num_e
- if len(e_props) < num_e: e_props = [{} for _ in range(num_e)]
+
+ if len(e_weights) < num_e:
+ e_weights = [1.0] * num_e
+ if len(e_props) < num_e:
+ e_props = [{} for _ in range(num_e)]
if hasattr(hg, "custom_hif_edges"):
edgj = hg.custom_hif_edges
@@ -98,7 +133,7 @@ def hypergraph_to_hif(
if edge_label == "name":
props.pop("name", None)
else:
- edge_id = props.pop("name", str(i))
+ edge_id = props.pop("name", str(i))
edgj.append({"edge": edge_id, "weight": weight, "attrs": props})
if hasattr(hg, "custom_hif_incidences"):
@@ -107,11 +142,12 @@ def hypergraph_to_hif(
incj = []
node_id_list = [n["node"] for n in nodj]
edge_id_list = [e["edge"] for e in edgj]
-
+
for e_idx, nodes_in_edge in enumerate(e_structure):
- if e_idx >= len(edge_id_list): break
+ if e_idx >= len(edge_id_list):
+ break
edge_name = edge_id_list[e_idx]
-
+
flat_nodes = []
if isinstance(nodes_in_edge, (list, tuple)):
for item in nodes_in_edge:
@@ -126,11 +162,13 @@ def hypergraph_to_hif(
try:
n_idx_int = int(n_idx)
if 0 <= n_idx_int < len(node_id_list):
- incj.append({
- "edge": edge_name,
- "node": node_id_list[n_idx_int],
- "weight": 1.0,
- })
+ incj.append(
+ {
+ "edge": edge_name,
+ "node": node_id_list[n_idx_int],
+ "weight": 1.0,
+ }
+ )
except (ValueError, TypeError):
continue
@@ -142,19 +180,19 @@ def hypergraph_to_hif(
"edges": edgj,
"incidences": incj,
"network-type": network_type,
- "metadata": metadata
+ "metadata": metadata,
}
try:
validator = _get_hif_validator()
validator(hif)
except Exception as e:
- print(f"Validation Warning: {e}")
+ print(f"Validation Warning: {e}")
if filename:
- with open(filename, "w", encoding='utf-8') as f:
+ with open(filename, "w", encoding="utf-8") as f:
json.dump(hif, f, indent=4, ensure_ascii=False)
-
+
return hif
@@ -173,7 +211,7 @@ def hif_to_hypergraph(
if filename is None:
raise EasyGraphHIFError("No HIF data or filename provided.")
try:
- with open(filename, "r", encoding='utf-8') as f:
+ with open(filename, "r", encoding="utf-8") as f:
hif = json.load(f)
except Exception as e:
raise EasyGraphHIFError(f"Failed to load HIF file {filename}: {e}")
@@ -190,7 +228,6 @@ def hif_to_hypergraph(
for rec in nodes_list:
idx = node_name_to_idx.get(rec["node"])
if idx is not None:
-
prop = rec.get("attrs", {}).copy()
if node_label in prop:
prop["name"] = str(prop[node_label])
@@ -201,7 +238,7 @@ def hif_to_hypergraph(
e_property_full = [{} for _ in range(num_e)]
e_weight = [1.0] * num_e
-
+
for rec in edges_list:
idx = edge_name_to_idx.get(rec["edge"])
if idx is not None:
@@ -217,50 +254,50 @@ def hif_to_hypergraph(
e_weight[idx] = prop["weight"]
raw_groups = [[] for _ in range(num_e)]
-
+
incidences_list = hif.get("incidences", [])
-
+
for inc in incidences_list:
e_name = inc.get("edge")
n_name = inc.get("node")
-
+
e_idx = edge_name_to_idx.get(e_name)
n_idx = node_name_to_idx.get(n_name)
-
+
if e_idx is not None and n_idx is not None:
raw_groups[e_idx].append(n_idx)
+ require_torch("HIF hypergraph loading")
+ from easygraph.classes.hypergraph import Hypergraph
+
hg = Hypergraph(
- num_v=num_v,
- e_list=raw_groups,
- e_weight=e_weight,
- v_property=v_property
+ num_v=num_v, e_list=raw_groups, e_weight=e_weight, v_property=v_property
)
-
+
hg.node_label_index = {}
for i in range(num_v):
name = v_property[i].get("name")
if name:
hg.node_label_index[name] = i
-
+
hg.edge_label_index = {}
for i in range(num_e):
name = e_property_full[i].get("name")
if name:
hg.edge_label_index[name] = i
-
+
hg.custom_hif_nodes = deepcopy(nodes_list)
hg.custom_hif_edges = deepcopy(edges_list)
hg.custom_hif_incidences = deepcopy(incidences_list)
-
+
if "metadata" in hif:
hg.metadata = deepcopy(hif["metadata"])
else:
hg.metadata = {}
-
+
if "network-type" in hif:
hg.network_type = hif["network-type"]
-
+
hg.e_property_full = e_property_full
- return hg
\ No newline at end of file
+ return hg
diff --git a/easygraph/utils/sparse.py b/easygraph/utils/sparse.py
index f5d2906b..1adb5b1f 100644
--- a/easygraph/utils/sparse.py
+++ b/easygraph/utils/sparse.py
@@ -4,6 +4,8 @@
# if not type checking
from typing import TYPE_CHECKING
+from easygraph._optional import require_torch
+
if TYPE_CHECKING:
import torch
@@ -12,7 +14,7 @@
def sparse_dropout(
sp_mat: "torch.Tensor", p: float, fill_value: float = 0.0
) -> "torch.Tensor":
- import torch
+ torch = require_torch("Sparse tensor dropout")
r"""Dropout function for sparse matrix. This function will return a new sparse matrix with the same shape as the input sparse matrix, but with some elements dropped out.
diff --git a/pyproject.toml b/pyproject.toml
index a501dfd7..5180744a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -175,7 +175,8 @@ exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
# test-requires = ["pytest", "lxml", "torch"]
# test-command = "pytest"
-build = ["cp38-*", "cp39-*", "cp310-*", "cp311-*", "cp312-*", "cp313-*", "cp314-*"]
+build = ["cp38-*", "cp39-*", "cp310-*", "cp311-*", "cp312-*", "cp313-*", "cp314-*", "cp315-*"]
+test-command = "python {project}/scripts/wheel_smoke.py"
# Skip building free-threaded compatible wheels on Windows
skip = "*t-win*"
@@ -186,6 +187,7 @@ skip = "*t-win*"
[build-system]
requires = [
"setuptools>=42",
+ "packaging>=20.9",
"wheel",
"Cython",
"pybind11",
diff --git a/scripts/PYTHON_RELEASE.md b/scripts/PYTHON_RELEASE.md
new file mode 100644
index 00000000..80802461
--- /dev/null
+++ b/scripts/PYTHON_RELEASE.md
@@ -0,0 +1,74 @@
+# Preparing EasyGraph for a new Python version
+
+EasyGraph installs a compatible PyTorch release automatically when its saved policy
+lists an ordinary CPython wheel for the user's Python minor, OS and CPU family
+on PyPI. Where no such wheel is listed, the default installation omits PyTorch.
+Core graph algorithms and the C++ extension remain usable. Importing EasyGraph
+without PyTorch emits one warning with the official installation page:
+https://pytorch.org/get-started/locally/.
+
+The policy is a release-time snapshot, not a network check during installation
+or import. Updating the file does not change already-published wheels: publish a
+new EasyGraph version after checking the resulting installation.
+
+## Refresh the policy
+
+From the repository root, run:
+
+```sh
+python -m pip install packaging
+python scripts/check_pytorch_support.py \
+ --python-versions 3.8 3.9 3.10 3.11 3.12 3.13 3.14 3.15 \
+ --write-policy
+```
+
+Append the new Python minor to this command when it is released. The checker
+uses https://pypi.org/pypi/torch/json and only counts stable, non-yanked torch>=2.0
+wheels matching the Python version, architecture and Requires-Python metadata.
+It handles both ordinary CPython ABI tags and older macOS `cpXY-none` wheels.
+Free-threaded/debug wheels do not enable ordinary CPython dependencies.
+
+No PyPI wheel does **not** mean PyTorch has no compatible build: its official
+index can provide additional builds. For example, PyTorch's Python 3.15 builds
+are currently distributed through the official index rather than PyPI. After
+installing one, restart Python; EasyGraph will detect and use it normally.
+
+The table checks platform/CPU families at the Python minor's `.0` baseline.
+It does not prove support for every OS release, libc, GPU or dependent package.
+Default Linux PyTorch dependencies are omitted when building on musl/Alpine;
+the current PyPI PyTorch Linux wheels require glibc. Keep the same libc family
+when building and installing the corresponding platform wheel.
+
+## Update and validate the release
+
+1. Review `easygraph/_torch_compatibility.json`; every non-null row becomes a
+ platform- and Python-specific PyTorch version interval in `setup.py`. Pip can
+ choose a compatible older wheel based on OS/libc tags. The policy records
+ minimum macOS versions for release review; do not encode them using
+ `platform_release` comparisons, because older pip versions evaluate those
+ comparisons on other operating systems and reject non-version kernel names.
+ If that minimum rises above EasyGraph's wheel deployment target, review the
+ target and default dependency policy before publishing.
+ Intervals that include PyTorch before 2.3 require NumPy<2 for compatibility
+ with its older binary ABI; newer Python intervals retain NumPy 2 support.
+2. Update EasyGraph's version, `python_requires` upper bound, classifier and
+ `cpXY-*` selector in `pyproject.toml`. Retain the existing minimum Python
+ version unless dropping it is an explicit project decision.
+3. Select a cibuildwheel release that supports the new interpreter. Keep a
+ legacy builder if the latest version has dropped older supported versions.
+4. Build the wheel and source distribution. Install the wheel into a fresh
+ environment **without** manually installing torch. Verify that the expected
+ dependency is installed or omitted automatically.
+5. Run `python scripts/wheel_smoke.py` outside the source directory. It checks
+ one missing-Torch warning, Graph/DiGraph and C++ conversion, degree centrality,
+ Python and C++ PageRank, SciPy conversion, and either PyTorch hypergraph
+ message passing or a clear dependency error with the installation URL.
+6. Run the same checks on Linux, Windows, macOS Intel and macOS Apple Silicon.
+ Then manually dispatch `wheel_publish.yml` to publish. That workflow uploads
+ to PyPI after all builds and smoke checks pass.
+
+The `[torch]` extra remains an explicit request for PyTorch and graph-learning
+dependencies. Installing that extra on a platform with no PyPI PyTorch wheel
+can fail during dependency resolution; use the official installer first where
+necessary. Optional `torch_geometric`, `torch_scatter` and scikit-learn features
+need their own dependencies; the core smoke check does not claim full ML coverage.
diff --git a/scripts/check_pytorch_support.py b/scripts/check_pytorch_support.py
new file mode 100644
index 00000000..42be1e7d
--- /dev/null
+++ b/scripts/check_pytorch_support.py
@@ -0,0 +1,304 @@
+#!/usr/bin/env python3
+"""Refresh EasyGraph's static PyTorch policy from published PyPI wheels.
+
+Run explicitly when preparing a release; package installation and imports do
+not query the network. The policy records an available version interval so pip
+can fall back according to wheel tags, plus the minimum macOS wheel tag. This
+does not validate every libc, OS release, GPU driver, or transitive dependency.
+Verify actual pip resolution and core imports on the intended operating system.
+Python minor versions are checked at their .0 baseline. Only ordinary GIL
+CPython wheels and compatible CPython abi3 wheels count, never cpXYt wheels.
+Requires the ``packaging`` library (``python -m pip install packaging``).
+"""
+
+import argparse
+import json
+import re
+import sys
+import urllib.error
+import urllib.request
+
+from datetime import datetime
+from datetime import timezone
+from pathlib import Path
+
+
+try:
+ from packaging.specifiers import InvalidSpecifier
+ from packaging.specifiers import SpecifierSet
+ from packaging.utils import InvalidWheelFilename
+ from packaging.utils import parse_wheel_filename
+ from packaging.version import InvalidVersion
+ from packaging.version import Version
+except ImportError:
+ raise SystemExit("Install the checker dependency: python -m pip install packaging")
+
+
+SOURCE_URL = "https://pypi.org/pypi/torch/json"
+INSTALL_URL = "https://pytorch.org/get-started/locally/"
+MINIMUM_TORCH = Version("2.0")
+DEFAULT_PYTHONS = ["3.{}".format(minor) for minor in range(8, 16)]
+POLICY_PATH = (
+ Path(__file__).resolve().parents[1] / "easygraph" / "_torch_compatibility.json"
+)
+TARGETS = [
+ {
+ "id": "linux_x86_64",
+ "sys_platform": "linux",
+ "platform_system": "Linux",
+ "platform_machines": ["x86_64", "AMD64", "amd64"],
+ "wheel_platform_suffixes": ["_x86_64"],
+ },
+ {
+ "id": "linux_aarch64",
+ "sys_platform": "linux",
+ "platform_system": "Linux",
+ "platform_machines": ["aarch64", "arm64", "ARM64"],
+ "wheel_platform_suffixes": ["_aarch64"],
+ },
+ {
+ "id": "windows_amd64",
+ "sys_platform": "win32",
+ "platform_system": "Windows",
+ "platform_machines": ["AMD64", "amd64", "x86_64"],
+ "wheel_platform_suffixes": ["win_amd64"],
+ },
+ {
+ "id": "windows_arm64",
+ "sys_platform": "win32",
+ "platform_system": "Windows",
+ "platform_machines": ["ARM64", "arm64", "aarch64"],
+ "wheel_platform_suffixes": ["win_arm64"],
+ },
+ {
+ "id": "macos_x86_64",
+ "sys_platform": "darwin",
+ "platform_system": "Darwin",
+ "platform_machines": ["x86_64", "AMD64", "amd64"],
+ "wheel_platform_suffixes": ["_x86_64", "_universal2"],
+ },
+ {
+ "id": "macos_arm64",
+ "sys_platform": "darwin",
+ "platform_system": "Darwin",
+ "platform_machines": ["arm64", "aarch64", "ARM64"],
+ "wheel_platform_suffixes": ["_arm64", "_universal2"],
+ },
+]
+
+
+def _python_pair(value):
+ match = re.fullmatch(r"(\d+)\.(\d+)", value)
+ if not match:
+ raise ValueError("Python versions must be major.minor, e.g. 3.15: " + value)
+ return tuple(int(part) for part in match.groups())
+
+
+def _matches_platform(wheel_platform, target):
+ system = target["sys_platform"]
+ if system == "linux":
+ valid_family = wheel_platform.startswith(("linux_", "manylinux", "musllinux"))
+ elif system == "darwin":
+ valid_family = wheel_platform.startswith("macosx_")
+ else:
+ valid_family = wheel_platform.startswith("win_")
+ return valid_family and any(
+ wheel_platform.endswith(suffix) for suffix in target["wheel_platform_suffixes"]
+ )
+
+
+def _matches_python(tag, python_pair):
+ match = re.fullmatch(r"cp(\d)(\d+)", tag.interpreter)
+ if not match:
+ return False
+ wheel_python = tuple(int(part) for part in match.groups())
+ if tag.abi == "abi3":
+ return wheel_python[0] == python_pair[0] and wheel_python <= python_pair
+ # Older macOS torch wheels use a normal CPython interpreter and ABI "none".
+ # Both forms still require this exact interpreter; cpXYt/debug are excluded.
+ return wheel_python == python_pair and tag.abi in (tag.interpreter, "none")
+
+
+def build_policy(metadata, python_versions, checked_at=None):
+ """Return the eligible torch interval and OS floor per Python/target pair.
+
+ ``metadata`` has the PyPI JSON API's ``releases`` mapping. Invalid release,
+ filename, and Requires-Python metadata are ignored conservatively. The
+ interval can contain releases with no matching wheel: pip must select the
+ actual compatible wheel. minimum_macos_version is the lowest tag among all
+ eligible wheels for this Python/target, not the latest torch release's floor.
+ """
+ python_versions = sorted(set(python_versions), key=_python_pair)
+ python_pairs = {value: _python_pair(value) for value in python_versions}
+ candidates = []
+ for release, files in metadata.get("releases", {}).items():
+ try:
+ version = Version(release)
+ except InvalidVersion:
+ continue
+ if version < MINIMUM_TORCH or version.is_prerelease or version.is_devrelease:
+ continue
+ for file_info in files:
+ if file_info.get("packagetype") != "bdist_wheel" or file_info.get("yanked"):
+ continue
+ try:
+ name, wheel_version, _, tags = parse_wheel_filename(
+ file_info["filename"]
+ )
+ requires_python = SpecifierSet(file_info.get("requires_python") or "")
+ except (InvalidWheelFilename, InvalidSpecifier, KeyError, TypeError):
+ continue
+ if name != "torch" or wheel_version != version:
+ continue
+ candidates.append((version, tags, requires_python))
+ candidates.sort(key=lambda candidate: candidate[0], reverse=True)
+
+ support = []
+ for python_version in python_versions:
+ for target in TARGETS:
+ available_versions = set()
+ macos_versions = set()
+ for version, tags, requires_python in candidates:
+ if not requires_python.contains(
+ python_version + ".0", prereleases=False
+ ):
+ continue
+ for tag in tags:
+ if not (
+ _matches_python(tag, python_pairs[python_version])
+ and _matches_platform(tag.platform, target)
+ ):
+ continue
+ if target["sys_platform"] == "darwin":
+ match = re.match(r"macosx_(\d+)_(\d+)_", tag.platform)
+ if not match:
+ continue
+ macos_versions.add(tuple(int(part) for part in match.groups()))
+ available_versions.add(version)
+ support.append(
+ {
+ "python_version": python_version,
+ "target_id": target["id"],
+ "torch_version": str(max(available_versions))
+ if available_versions
+ else None,
+ "minimum_torch_version": str(min(available_versions))
+ if available_versions
+ else None,
+ "minimum_macos_version": (
+ "{}.{}".format(*min(macos_versions)) if macos_versions else None
+ ),
+ }
+ )
+ return {
+ "schema_version": 1,
+ "checked_at": checked_at
+ or datetime.now(timezone.utc).isoformat(timespec="seconds"),
+ "source_url": SOURCE_URL,
+ "official_install_url": INSTALL_URL,
+ "minimum_torch_version": str(MINIMUM_TORCH),
+ "availability_scope": (
+ "PyPI wheel platform/CPU family, Python .0 baseline, available torch"
+ " interval, and minimum macOS wheel tag. Pip must select a compatible"
+ " wheel; actual resolution, core imports, libc, GPU drivers, and transitive"
+ " dependencies require validation on the intended operating system."
+ ),
+ "python_versions": python_versions,
+ "targets": TARGETS,
+ "support": support,
+ }
+
+
+def fetch_metadata():
+ request = urllib.request.Request(
+ SOURCE_URL,
+ headers={
+ "User-Agent": "EasyGraph-PyTorch-compatibility-checker/1",
+ "Accept": "application/json",
+ },
+ )
+ with urllib.request.urlopen(request, timeout=60) as response:
+ return json.load(response)
+
+
+def write_policy(policy, destination):
+ destination = Path(destination)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_text(json.dumps(policy, indent=2) + "\n", encoding="utf-8")
+
+
+def print_table(policy):
+ print(
+ "Python Target PyPI torch interval macOS floor Default policy"
+ )
+ print(
+ "------ ----------------- -------------------- ----------- --------------"
+ )
+ for row in policy["support"]:
+ version = row["torch_version"]
+ interval = (
+ ">={},<={}".format(row["minimum_torch_version"], version)
+ if version
+ else "no wheel"
+ )
+ print(
+ "{:<6} {:<17} {:<20} {:<11} {}".format(
+ row["python_version"],
+ row["target_id"],
+ interval,
+ row["minimum_macos_version"] or "-",
+ "eligible; pip selects wheel"
+ if version
+ else "no; see official installer",
+ )
+ )
+ print("\nNo PyPI wheel does not mean PyTorch is unsupported.")
+ print("Check official installation options: " + INSTALL_URL)
+ print(
+ "Python minor versions are checked at their .0 baseline. An available version"
+ )
+ print(
+ "interval permits fallback; pip resolution and core imports still need"
+ " validation"
+ )
+ print("on the intended OS, including libc and transitive dependency requirements.")
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--python-versions", nargs="+", default=DEFAULT_PYTHONS, metavar="MAJOR.MINOR"
+ )
+ parser.add_argument(
+ "--output", type=Path, help="write the resulting JSON to this path"
+ )
+ parser.add_argument(
+ "--write-policy",
+ action="store_true",
+ help="explicitly refresh easygraph/_torch_compatibility.json",
+ )
+ parser.add_argument(
+ "--pypi-json",
+ type=Path,
+ help="read a previously saved PyPI JSON response instead of fetching",
+ )
+ args = parser.parse_args(argv)
+ try:
+ metadata = (
+ json.loads(args.pypi_json.read_text(encoding="utf-8"))
+ if args.pypi_json
+ else fetch_metadata()
+ )
+ policy = build_policy(metadata, args.python_versions)
+ if args.output:
+ write_policy(policy, args.output)
+ if args.write_policy:
+ write_policy(policy, POLICY_PATH)
+ except (OSError, ValueError, urllib.error.URLError) as error:
+ parser.exit(1, "PyTorch compatibility check failed: {}\n".format(error))
+ print_table(policy)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/tests/test_optional_dependency_logic.py b/scripts/tests/test_optional_dependency_logic.py
new file mode 100644
index 00000000..35288849
--- /dev/null
+++ b/scripts/tests/test_optional_dependency_logic.py
@@ -0,0 +1,160 @@
+"""Offline regression tests for optional dependencies without importing EasyGraph."""
+
+import builtins
+import importlib.util
+import sys
+import types
+import unittest
+import warnings
+
+from pathlib import Path
+from unittest.mock import patch
+
+
+OPTIONAL_PATH = Path(__file__).resolve().parents[2] / "easygraph" / "_optional.py"
+INSTALL_URL = "https://pytorch.org/get-started/locally/"
+
+
+def load_optional(torch_module=None, torch_error=None):
+ """Load only the helper, with the Torch import fully controlled by the test."""
+ spec = importlib.util.spec_from_file_location(
+ "optional_dependency_under_test", OPTIONAL_PATH
+ )
+ module = importlib.util.module_from_spec(spec)
+ original_import = builtins.__import__
+ torch_imports = []
+
+ def controlled_import(name, globals=None, locals=None, fromlist=(), level=0):
+ if name == "easygraph" or name.startswith("easygraph."):
+ raise AssertionError("This test must not import the EasyGraph package")
+ if name == "torch":
+ torch_imports.append(name)
+ if torch_error is not None:
+ raise torch_error
+ if torch_module is None:
+ raise ModuleNotFoundError("No module named 'torch'", name="torch")
+ return torch_module
+ return original_import(name, globals, locals, fromlist, level)
+
+ with patch("builtins.__import__", side_effect=controlled_import):
+ spec.loader.exec_module(module)
+ if torch_imports != ["torch"]:
+ raise AssertionError("Expected exactly one controlled Torch import")
+ return module
+
+
+class OptionalDependencyTests(unittest.TestCase):
+ def test_missing_torch_warns_with_current_python_and_official_install_link(self):
+ helper = load_optional()
+ self.assertFalse(helper.HAS_TORCH)
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ helper.warn_missing_torch()
+ self.assertEqual(len(caught), 1)
+ self.assertIs(caught[0].category, UserWarning)
+ message = str(caught[0].message)
+ self.assertIn(INSTALL_URL, message)
+ self.assertIn("Python {}.{}".format(*sys.version_info[:2]), message)
+ self.assertIn("Core graph algorithms", message)
+ self.assertIn("C++ backend remain available", message)
+ # The warning should point to the caller, where the user imported EasyGraph.
+ self.assertEqual(Path(caught[0].filename).resolve(), Path(__file__).resolve())
+
+ def test_require_torch_explains_feature_and_installation_when_missing(self):
+ helper = load_optional()
+ with self.assertRaises(ImportError) as caught:
+ helper.require_torch("Hypergraph message passing")
+ message = str(caught.exception)
+ self.assertIn("Hypergraph message passing requires PyTorch", message)
+ self.assertIn(INSTALL_URL, message)
+ self.assertIn("Python version", message)
+ self.assertIn("restart Python", message)
+
+ def test_available_torch_is_returned_without_warning(self):
+ fake_torch = types.ModuleType("torch")
+ fake_torch.__version__ = "test-only"
+ helper = load_optional(torch_module=fake_torch)
+ self.assertTrue(helper.HAS_TORCH)
+ self.assertIs(helper.require_torch("Tensor processing"), fake_torch)
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ helper.warn_missing_torch()
+ self.assertEqual(caught, [])
+
+ def test_torch_internal_missing_module_is_not_classified_as_absent_torch(self):
+ for name in ("torch._C", "torch._prims", "numpy", "typing_extensions"):
+ with self.subTest(name=name):
+ error = ModuleNotFoundError(
+ "Broken Torch dependency: " + name, name=name
+ )
+ with self.assertRaises(ModuleNotFoundError) as caught:
+ load_optional(torch_error=error)
+ self.assertIs(caught.exception, error)
+
+ def test_broken_native_torch_or_other_import_errors_are_preserved(self):
+ for error in (
+ OSError("dlopen: incompatible architecture"),
+ ImportError("cannot import name from torch"),
+ RuntimeError("Torch initialization failed"),
+ ):
+ with self.subTest(error=type(error).__name__):
+ with self.assertRaises(type(error)) as caught:
+ load_optional(torch_error=error)
+ self.assertIs(caught.exception, error)
+
+ def test_context_allows_explicitly_optional_absent_packages(self):
+ helper = load_optional()
+ for name in (
+ "torch",
+ "torch_geometric",
+ "torch_scatter",
+ "torch_sparse",
+ "sklearn",
+ ):
+ with self.subTest(name=name):
+ with helper.optional_torch_modules():
+ raise ModuleNotFoundError(
+ "No module named '" + name + "'", name=name
+ )
+
+ def test_context_preserves_missing_internal_or_unrelated_dependencies(self):
+ helper = load_optional()
+ for name in (
+ "torch._C",
+ "torch_geometric.typo",
+ "numpy",
+ "easygraph_typo",
+ None,
+ ):
+ with self.subTest(name=name):
+ error = ModuleNotFoundError("Unrelated dependency failure", name=name)
+ with self.assertRaises(ModuleNotFoundError) as caught:
+ with helper.optional_torch_modules():
+ raise error
+ self.assertIs(caught.exception, error)
+
+ def test_context_does_not_swallow_code_errors_or_native_library_failures(self):
+ helper = load_optional()
+ for error in (
+ ValueError("bad configuration"),
+ AttributeError("unexpected API"),
+ OSError("shared library could not be loaded"),
+ ImportError("incompatible Torch API"),
+ ):
+ with self.subTest(error=type(error).__name__):
+ with self.assertRaises(type(error)) as caught:
+ with helper.optional_torch_modules():
+ raise error
+ self.assertIs(caught.exception, error)
+
+ def test_context_runs_successful_body_normally(self):
+ helper = load_optional()
+ visited = []
+ with helper.optional_torch_modules():
+ visited.append("body")
+ visited.append("after")
+ self.assertEqual(visited, ["body", "after"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/tests/test_pytorch_support.py b/scripts/tests/test_pytorch_support.py
new file mode 100644
index 00000000..6649fa39
--- /dev/null
+++ b/scripts/tests/test_pytorch_support.py
@@ -0,0 +1,245 @@
+"""Offline tests of PyPI wheel selection; no EasyGraph imports or network."""
+
+import importlib.util
+import unittest
+
+from pathlib import Path
+
+
+SPEC = importlib.util.spec_from_file_location(
+ "check_pytorch_support",
+ Path(__file__).resolve().parents[1] / "check_pytorch_support.py",
+)
+checker = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(checker)
+
+
+def wheel(
+ version,
+ interpreter="cp315",
+ abi="cp315",
+ platform="macosx_14_0_arm64",
+ requires_python=">=3.8",
+ yanked=False,
+):
+ return {
+ "filename": "torch-{}-{}-{}-{}.whl".format(version, interpreter, abi, platform),
+ "packagetype": "bdist_wheel",
+ "requires_python": requires_python,
+ "yanked": yanked,
+ }
+
+
+def selected_row(metadata, python="3.15", target="macos_arm64"):
+ policy = checker.build_policy(
+ metadata, [python], checked_at="2026-10-12T00:00:00+00:00"
+ )
+ return next(row for row in policy["support"] if row["target_id"] == target)
+
+
+def selected(metadata, python="3.15", target="macos_arm64"):
+ return selected_row(metadata, python, target)["torch_version"]
+
+
+class PyTorchSupportTests(unittest.TestCase):
+ def test_latest_stable_non_yanked_release_and_minimum(self):
+ metadata = {
+ "releases": {
+ "1.13.0": [wheel("1.13.0")],
+ "2.9.0": [wheel("2.9.0")],
+ "2.10.0": [wheel("2.10.0")],
+ "2.11.0": [wheel("2.11.0", yanked=True)],
+ "2.12.0rc1": [wheel("2.12.0rc1")],
+ "2.12.0.dev1": [wheel("2.12.0.dev1")],
+ "invalid": [wheel("2.13.0")],
+ }
+ }
+ self.assertEqual(selected(metadata), "2.10.0")
+ self.assertIsNone(selected({"releases": {"1.13.0": [wheel("1.13.0")]}}))
+
+ def test_free_threaded_and_debug_wheels_do_not_count(self):
+ for interpreter, abi in [
+ ("cp315", "cp315t"),
+ ("cp315t", "cp315t"),
+ ("cp315", "cp315d"),
+ ]:
+ with self.subTest(interpreter=interpreter, abi=abi):
+ metadata = {"releases": {"2.10.0": [wheel("2.10.0", interpreter, abi)]}}
+ self.assertIsNone(selected(metadata))
+ self.assertEqual(
+ selected({"releases": {"2.10.0": [wheel("2.10.0")]}}), "2.10.0"
+ )
+
+ def test_abi3_supports_newer_cpython_only(self):
+ metadata = {"releases": {"2.10.0": [wheel("2.10.0", "cp39", "abi3")]}}
+ self.assertEqual(selected(metadata, python="3.15"), "2.10.0")
+ self.assertEqual(selected(metadata, python="3.9"), "2.10.0")
+ self.assertIsNone(selected(metadata, python="3.8"))
+ self.assertIsNone(selected(metadata, python="4.0"))
+
+ def test_older_macos_cpython_none_abi_wheels(self):
+ # Real PyPI format: torch-2.4.1-cp38-none-macosx_11_0_arm64.whl.
+ metadata = {
+ "releases": {
+ "2.4.1": [
+ wheel(
+ "2.4.1",
+ "cp38",
+ "none",
+ "macosx_11_0_arm64",
+ requires_python=">=3.8.0",
+ )
+ ]
+ }
+ }
+ self.assertEqual(selected(metadata, python="3.8"), "2.4.1")
+ self.assertIsNone(selected(metadata, python="3.9"))
+ self.assertIsNone(selected(metadata, python="3.8", target="macos_x86_64"))
+
+ def test_version_interval_and_macos_floor_allow_older_os_fallback(self):
+ metadata = {
+ "releases": {
+ "2.0.0": [
+ wheel("2.0.0", "cp311", "none", "macosx_11_0_arm64", yanked=True)
+ ],
+ "2.1.0": [
+ wheel(
+ "2.1.0",
+ "cp311",
+ "none",
+ "macosx_10_9_arm64",
+ requires_python=">=3.12",
+ )
+ ],
+ "2.2.2": [wheel("2.2.2", "cp311", "none", "macosx_11_0_arm64")],
+ "2.14.1": [wheel("2.14.1", "cp311", "cp311", "macosx_14_0_arm64")],
+ "2.15.0rc1": [
+ wheel("2.15.0rc1", "cp311", "cp311", "macosx_10_9_arm64")
+ ],
+ }
+ }
+ row = selected_row(metadata, python="3.11")
+ self.assertEqual(row["torch_version"], "2.14.1")
+ self.assertEqual(row["minimum_torch_version"], "2.2.2")
+ self.assertEqual(row["minimum_macos_version"], "11.0")
+
+ def test_macos_minimum_uses_numeric_tag_order_and_universal2(self):
+ metadata = {
+ "releases": {
+ "2.2.2": [wheel("2.2.2", platform="macosx_10_9_universal2")],
+ "2.10.0": [wheel("2.10.0", platform="macosx_11_0_arm64")],
+ "2.11.0": [wheel("2.11.0", platform="macosx_14_0_universal2")],
+ }
+ }
+ for target in ("macos_x86_64", "macos_arm64"):
+ with self.subTest(target=target):
+ row = selected_row(metadata, target=target)
+ self.assertEqual(row["minimum_torch_version"], "2.2.2")
+ self.assertEqual(row["torch_version"], "2.11.0")
+ self.assertEqual(row["minimum_macos_version"], "10.9")
+
+ def test_other_platform_has_version_interval_without_macos_floor(self):
+ metadata = {
+ "releases": {
+ "2.0.0": [wheel("2.0.0", platform="manylinux2014_x86_64")],
+ "2.14.1": [wheel("2.14.1", platform="manylinux_2_28_x86_64")],
+ }
+ }
+ row = selected_row(metadata, target="linux_x86_64")
+ self.assertEqual(row["minimum_torch_version"], "2.0.0")
+ self.assertEqual(row["torch_version"], "2.14.1")
+ self.assertIsNone(row["minimum_macos_version"])
+
+ def test_no_matching_wheel_sets_all_availability_fields_to_null(self):
+ row = selected_row({"releases": {}})
+ for field in (
+ "torch_version",
+ "minimum_torch_version",
+ "minimum_macos_version",
+ ):
+ self.assertIsNone(row[field])
+
+ def test_requires_python_applies_to_each_file_at_patch_zero(self):
+ for requirement in (">=3.16", "<3.15", ">=3.15.1", "not valid"):
+ with self.subTest(requirement=requirement):
+ metadata = {
+ "releases": {
+ "2.10.0": [wheel("2.10.0", requires_python=requirement)]
+ }
+ }
+ self.assertIsNone(selected(metadata))
+ for requirement in (">=3.15,<3.16", None):
+ with self.subTest(requirement=requirement):
+ metadata = {
+ "releases": {
+ "2.10.0": [wheel("2.10.0", requires_python=requirement)]
+ }
+ }
+ self.assertEqual(selected(metadata), "2.10.0")
+
+ def test_platform_and_cpu_families(self):
+ platforms = {
+ "linux_x86_64": "manylinux_2_28_x86_64",
+ "linux_aarch64": "manylinux_2_28_aarch64",
+ "windows_amd64": "win_amd64",
+ "windows_arm64": "win_arm64",
+ "macos_x86_64": "macosx_10_9_x86_64",
+ "macos_arm64": "macosx_14_0_arm64",
+ }
+ for wanted, platform in platforms.items():
+ metadata = {"releases": {"2.10.0": [wheel("2.10.0", platform=platform)]}}
+ for target in platforms:
+ with self.subTest(wanted=wanted, target=target):
+ self.assertEqual(
+ selected(metadata, target=target),
+ "2.10.0" if target == wanted else None,
+ )
+
+ def test_universal2_counts_for_both_mac_architectures(self):
+ metadata = {
+ "releases": {"2.10.0": [wheel("2.10.0", platform="macosx_11_0_universal2")]}
+ }
+ self.assertEqual(selected(metadata, target="macos_arm64"), "2.10.0")
+ self.assertEqual(selected(metadata, target="macos_x86_64"), "2.10.0")
+ self.assertIsNone(selected(metadata, target="linux_aarch64"))
+
+ def test_compressed_platform_tags(self):
+ metadata = {
+ "releases": {
+ "2.10.0": [
+ wheel(
+ "2.10.0", platform="manylinux_2_17_x86_64.manylinux2014_x86_64"
+ )
+ ]
+ }
+ }
+ self.assertEqual(selected(metadata, target="linux_x86_64"), "2.10.0")
+
+ def test_sdist_pure_python_invalid_filename_and_wrong_version_are_ignored(self):
+ files = [
+ {"filename": "torch-2.10.0.tar.gz", "packagetype": "sdist"},
+ wheel("2.10.0", "py3", "none", "any"),
+ wheel("2.10.0", "cp314", "cp314"),
+ wheel("2.9.0"),
+ {"filename": "broken.whl", "packagetype": "bdist_wheel"},
+ ]
+ self.assertIsNone(selected({"releases": {"2.10.0": files}}))
+
+ def test_schema_and_sorted_versions(self):
+ policy = checker.build_policy(
+ {"releases": {}},
+ ["3.15", "3.8", "3.8"],
+ checked_at="2026-10-12T00:00:00+00:00",
+ )
+ self.assertEqual(policy["schema_version"], 1)
+ self.assertEqual(policy["python_versions"], ["3.8", "3.15"])
+ self.assertEqual(len(policy["targets"]), 6)
+ self.assertEqual(len(policy["support"]), 12)
+ self.assertEqual(policy["source_url"], checker.SOURCE_URL)
+ self.assertEqual(policy["official_install_url"], checker.INSTALL_URL)
+ with self.assertRaises(ValueError):
+ checker.build_policy({"releases": {}}, ["3.15.0"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/wheel_smoke.py b/scripts/wheel_smoke.py
new file mode 100644
index 00000000..74f30f48
--- /dev/null
+++ b/scripts/wheel_smoke.py
@@ -0,0 +1,98 @@
+"""Check the installed Python package, native extension and SciPy conversion."""
+
+import math
+import os
+import tempfile
+import warnings
+
+from importlib.metadata import version
+from importlib.util import find_spec
+
+
+# Run outside the source directory so an uninstalled checkout cannot pass.
+os.chdir(tempfile.gettempdir())
+
+import cpp_easygraph
+
+
+has_torch = find_spec("torch") is not None
+with warnings.catch_warnings(record=True) as import_warnings:
+ warnings.simplefilter("always")
+ import easygraph
+ import easygraph as eg
+
+torch_warnings = [
+ item
+ for item in import_warnings
+ if "EasyGraph: PyTorch is not installed" in str(item.message)
+]
+assert len(torch_warnings) == (0 if has_torch else 1)
+if torch_warnings:
+ print(str(torch_warnings[0].message))
+
+from scipy.sparse import csr_matrix
+
+
+for graph_type in (eg.Graph, eg.DiGraph):
+ graph = graph_type()
+ graph.add_edges_from([(0, 1), (1, 2), (2, 0)])
+ native = graph.cpp()
+ assert isinstance(native, (cpp_easygraph.Graph, cpp_easygraph.DiGraph))
+ assert native.number_of_nodes() == 3
+ assert native.number_of_edges() == 3
+ native.add_edge(2, 3, weight=2.0)
+ assert native.number_of_nodes() == 4
+ assert native.number_of_edges() == 4
+ restored = native.py()
+ assert restored.is_directed() == graph.is_directed()
+ assert restored[2][3]["weight"] == 2.0
+ native.remove_node(3)
+ assert native.number_of_nodes() == 3
+ assert native.number_of_edges() == 3
+
+graph = eg.Graph()
+graph.add_edges_from([(0, 1), (1, 2), (2, 0)])
+native = graph.cpp()
+python_pagerank = eg.pagerank(graph)
+assert set(python_pagerank) == {0, 1, 2}
+assert all(math.isclose(value, 1.0 / 3.0) for value in python_pagerank.values())
+centrality = cpp_easygraph.cpp_degree_centrality(native)
+assert set(centrality) == {0, 1, 2}
+assert all(math.isclose(value, 1.0) for value in centrality.values())
+pagerank = cpp_easygraph.cpp_pagerank(native)
+assert len(pagerank) == 3
+assert all(math.isclose(value, 1.0 / 3.0) for value in pagerank)
+
+matrix = csr_matrix([[0.0, 2.0], [2.0, 0.0]])
+converted = eg.from_scipy_sparse_matrix(matrix)
+assert converted.number_of_nodes() == 2
+assert converted.number_of_edges() == 1
+assert converted[0][1]["weight"] == 2.0
+
+if has_torch:
+ import numpy as np
+ import torch
+
+ array = np.array([1.0, 2.0], dtype=np.float32)
+ assert np.array_equal(torch.from_numpy(array).numpy(), array)
+ hypergraph = eg.Hypergraph(num_v=3, e_list=[[0, 1], [1, 2]])
+ features = torch.tensor([[1.0], [2.0], [3.0]])
+ assert torch.allclose(hypergraph.v2e(features), torch.tensor([[1.5], [2.5]]))
+ assert torch.allclose(hypergraph.v2v(features), torch.tensor([[1.5], [2.0], [2.5]]))
+ print("PyTorch {}: hypergraph message passing passed".format(torch.__version__))
+else:
+ for operation in (lambda: eg.Hypergraph(num_v=3), lambda: graph.A):
+ try:
+ operation()
+ except ImportError as error:
+ assert "https://pytorch.org/get-started/locally/" in str(error)
+ else:
+ raise AssertionError(
+ "A PyTorch-dependent feature did not report its missing dependency"
+ )
+
+print(
+ "EasyGraph {}: Python, C++ and SciPy smoke checks passed".format(
+ version("Python-EasyGraph")
+ )
+)
diff --git a/setup.py b/setup.py
index 9498e69b..1b88d85c 100644
--- a/setup.py
+++ b/setup.py
@@ -1,3 +1,4 @@
+import json
import os
import platform
import re
@@ -9,6 +10,7 @@
import setuptools
+from packaging.version import Version
from setuptools.command.build_ext import build_ext
@@ -110,9 +112,52 @@ def build_extension(self, ext: CMakeExtension) -> None:
long_description = fh.read()
CYTHON_STR = "Cython"
+
+
+def default_torch_requirements():
+ # Refresh explicitly with scripts/check_pytorch_support.py before releases.
+ # Never query the network while building package metadata.
+ policy_path = Path(__file__).parent / "easygraph" / "_torch_compatibility.json"
+ with policy_path.open(encoding="utf-8") as stream:
+ policy = json.load(stream)
+ if policy["schema_version"] != 1:
+ raise ValueError("Unsupported PyTorch compatibility policy schema")
+ targets = {target["id"]: target for target in policy["targets"]}
+ requirements = []
+ for row in policy["support"]:
+ if row["torch_version"] is None:
+ continue
+ target = targets[row["target_id"]]
+ # PyPI's Linux torch wheels target glibc, not musl/Alpine.
+ if (target["sys_platform"] == "linux" and sys.platform == "linux"
+ and platform.libc_ver()[0] != "glibc"):
+ continue
+ machines = " or ".join(
+ "platform_machine == '{}'".format(machine)
+ for machine in target["platform_machines"]
+ )
+ marker = (
+ "python_version == '{}' and sys_platform == '{}' and "
+ "platform_system == '{}' and platform_python_implementation == 'CPython' "
+ "and ({})"
+ ).format(row["python_version"], target["sys_platform"],
+ target["platform_system"], machines)
+ # Pip selects compatible OS versions from wheel platform tags. Do not
+ # compare platform_release: older marker parsers evaluate every branch
+ # and reject Linux kernel strings such as "6.8.0-1064-azure" as versions.
+ minimum = row["minimum_torch_version"]
+ requirements.append("torch>={},<={}; {}".format(
+ minimum, row["torch_version"], marker
+ ))
+ # Older macOS torch binaries use NumPy 1's C ABI.
+ if Version(minimum) < Version("2.3"):
+ requirements.append("numpy<2; {}".format(marker))
+ return requirements
+
+
setuptools.setup(
name="Python-EasyGraph",
- version="1.6.2",
+ version="1.6.3",
author="Fudan DataNET Group",
author_email="mgao21@m.fudan.edu.cn",
description="Easy Graph",
@@ -120,6 +165,7 @@ def build_extension(self, ext: CMakeExtension) -> None:
long_description_content_type="text/markdown",
url="https://github.com/easy-graph/Easy-Graph",
packages=setuptools.find_packages(),
+ package_data={"easygraph": ["_torch_compatibility.json"]},
classifiers=[
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
@@ -128,10 +174,11 @@ def build_extension(self, ext: CMakeExtension) -> None:
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
+ "Programming Language :: Python :: 3.15",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
],
- python_requires=">=3.8, <3.15",
+ python_requires=">=3.8, <3.16",
install_requires=[
"numpy>=1.23; python_version < '3.14'",
"numpy>=2.0; python_version >= '3.14'",
@@ -144,6 +191,7 @@ def build_extension(self, ext: CMakeExtension) -> None:
"scikit-learn>=0.24.0; python_version>='3.8' and python_version<'3.14'",
"scipy>=1.5.0, <=1.7.3; python_version=='3.7'",
"scipy>=1.8.0; python_version>='3.8' and python_version<'3.14'",
+ "scipy>=1.18.1; python_version>='3.14'",
"statsmodels>=0.12.0; python_version>='3.7' and python_version<'3.14'",
"progressbar>=2.5",
"nose>=0.10.1",
@@ -153,7 +201,7 @@ def build_extension(self, ext: CMakeExtension) -> None:
"requests",
"optuna",
"fastjsonschema",
- ],
+ ] + default_torch_requirements(),
extras_require={
"torch": [
"torch>=2.0",