From 93b23bd6c1c64be08fa40acdb2ed12f813241357 Mon Sep 17 00:00:00 2001 From: Haoran Du Date: Mon, 12 Oct 2026 14:40:50 +0800 Subject: [PATCH 1/2] Support Python 3.15 with platform-aware optional PyTorch dependencies --- .github/workflows/pr_code_check.yaml | 14 +- .github/workflows/wheel_publish.yml | 18 +- MANIFEST.in | 7 +- README.md | 14 +- README.rst | 16 +- easygraph/__init__.py | 10 +- easygraph/_optional.py | 58 +++ easygraph/_torch_compatibility.json | 438 ++++++++++++++++++ easygraph/classes/__init__.py | 17 +- easygraph/classes/graph.py | 13 +- easygraph/datapipe/__init__.py | 24 +- easygraph/datasets/__init__.py | 36 +- easygraph/datasets/utils.py | 6 +- easygraph/experiments/__init__.py | 14 +- easygraph/functions/__init__.py | 13 +- .../functions/graph_embedding/__init__.py | 15 +- easygraph/ml_metrics/__init__.py | 29 +- easygraph/model/__init__.py | 34 +- easygraph/nn/__init__.py | 45 +- easygraph/utils/HIF.py | 131 ++++-- easygraph/utils/sparse.py | 4 +- pyproject.toml | 4 +- scripts/PYTHON_RELEASE.md | 70 +++ scripts/check_pytorch_support.py | 304 ++++++++++++ .../tests/test_optional_dependency_logic.py | 160 +++++++ scripts/tests/test_pytorch_support.py | 245 ++++++++++ scripts/wheel_smoke.py | 98 ++++ setup.py | 55 ++- 28 files changed, 1693 insertions(+), 199 deletions(-) create mode 100644 easygraph/_optional.py create mode 100644 easygraph/_torch_compatibility.json create mode 100644 scripts/PYTHON_RELEASE.md create mode 100644 scripts/check_pytorch_support.py create mode 100644 scripts/tests/test_optional_dependency_logic.py create mode 100644 scripts/tests/test_pytorch_support.py create mode 100644 scripts/wheel_smoke.py 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..3c767eda --- /dev/null +++ b/scripts/PYTHON_RELEASE.md @@ -0,0 +1,70 @@ +# 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. macOS default PyTorch + markers also require the minimum OS family observed in the available wheels. + 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..32086c7d 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +import json import os import platform import re @@ -6,6 +7,7 @@ from distutils import sysconfig from pathlib import Path +from packaging.version import Version import setuptools @@ -110,9 +112,53 @@ 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) + if target["sys_platform"] == "darwin": + major, minor = map(int, row["minimum_macos_version"].split(".")) + darwin_major = major + 9 if major >= 11 else minor + 4 + marker += " and platform_release >= '{}'".format(darwin_major) + 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 +166,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 +175,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 +192,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 +202,7 @@ def build_extension(self, ext: CMakeExtension) -> None: "requests", "optuna", "fastjsonschema", - ], + ] + default_torch_requirements(), extras_require={ "torch": [ "torch>=2.0", From c823ed9cb62ce62320399b11ddcdb0414fec373b Mon Sep 17 00:00:00 2001 From: Haoran Du Date: Mon, 12 Oct 2026 14:54:15 +0800 Subject: [PATCH 2/2] Keep dependency markers compatible with older pip on Linux --- scripts/PYTHON_RELEASE.md | 8 ++++++-- setup.py | 9 ++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/scripts/PYTHON_RELEASE.md b/scripts/PYTHON_RELEASE.md index 3c767eda..80802461 100644 --- a/scripts/PYTHON_RELEASE.md +++ b/scripts/PYTHON_RELEASE.md @@ -43,8 +43,12 @@ when building and installing the corresponding platform wheel. 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. macOS default PyTorch - markers also require the minimum OS family observed in the available wheels. + 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 diff --git a/setup.py b/setup.py index 32086c7d..1b88d85c 100644 --- a/setup.py +++ b/setup.py @@ -7,10 +7,10 @@ from distutils import sysconfig from pathlib import Path -from packaging.version import Version import setuptools +from packaging.version import Version from setuptools.command.build_ext import build_ext @@ -142,10 +142,9 @@ def default_torch_requirements(): "and ({})" ).format(row["python_version"], target["sys_platform"], target["platform_system"], machines) - if target["sys_platform"] == "darwin": - major, minor = map(int, row["minimum_macos_version"].split(".")) - darwin_major = major + 9 if major >= 11 else minor + 4 - marker += " and platform_release >= '{}'".format(darwin_major) + # 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