Files
Keval MorabiaandClaude Opus 5.5 1c40150d45 [OMNIML-5802] Drop Python 3.10 support and adopt Python 3.11+ features (#3007)
### What does this PR do?

Type of change: Backward breaking change (dependency / Python version
support)

Python 3.10 has reached end-of-life, so ModelOpt now requires Python
3.11+. This PR drops 3.10 support and adopts the newer Python features.

**Packaging, CI and docs**
- `requires-python = ">=3.11,<3.15"` in the root package,
`tools/launcher` and `tools/mcp`. Removes the 3.10 classifier.
- Removes the 3.10-only ONNX Runtime 1.22 / `cupy-cuda12x` dependency
markers and `tomli`.
- Ruff `target-version` and mypy `python_version` are now 3.11.
- Removes the `unit-3.10` nox session and CI matrix entry, plus the
`tomli` install in the skills job.
- Updates the install docs for Linux and Windows, and the example
READMEs.
- CHANGELOG: adds a backward-breaking entry and removes the Python 3.10
caveat from the unreleased ONNX Runtime 1.30 entry.

**Python 3.11+ features**
- `enum.StrEnum` replaces `(str, Enum)`: `RecipeType` plus the diffusers
and specdec_bench examples. `str()` and f-strings of these members now
give the value (e.g. `"ptq"`), not `"RecipeType.PTQ"`.
- `datetime.UTC`, `sys.exception()`, `hashlib.file_digest`,
`contextlib.chdir`, `tomllib`.
- `typing.Self`, `NotRequired` and `Required` come from `typing` instead
of `typing_extensions`.
- `importlib.resources.abc.Traversable` is imported directly; the
fallback imports are removed.
- Removes the Python 3.10 checks in `modelopt.onnx`. This includes the
`preload_dlls()` skip in `ort_utils.py`.
- Removes `# noqa: PERF203`. On 3.11, try/except in a loop has no
runtime cost, so ruff no longer flags it.

**`from __future__ import annotations`**
- Removed from 78 of 85 files.
- Imports that were only under `if TYPE_CHECKING:` (cheap, with no
import cycle) are now regular imports.
- Methods returning `self` or `cls(...)` are annotated `-> Self`.
- The import stays in 7 files where it is still needed:
  - `fastgen/flow_matching.py`: import cycle with `fastgen/config.py`.
- `fastgen/config.py`: an own-class `from_yaml` that doesn't return
`cls`.
  - 4 puzzletron sewing_kit/validate files: forward references.
  - `benchmark_via_builtin.py`: lazy `torch` import.

`uv.lock` is left for the weekly bump job.

### Usage

N/A

### Testing

Run locally on Python 3.11:
- `nox -s "unit-3.11(torch_214, tf_latest)"`: 5478 passed, 18 skipped,
12 xfailed.
- `pytest plugins/modelopt/skills/`: 141 passed.
- `tools/mcp` tests: 71 passed.
- `pre-commit` on all changed files: passed. This includes ruff, and
mypy with `python_version = "3.11"`.
- Imported every module whose `from __future__ import annotations` was
removed, under Python 3.11, to catch eagerly evaluated annotations. Ten
examples whose dependencies aren't installed locally (`nemo_automodel`,
`ltx_trainer`, `vllm`) were covered by ruff F821/TC004 and an AST check
instead.

Not run locally: the GPU and example tests. Their CI runs on pinned
Python 3.12 containers.
`tests/_test_utils/examples/megatron_example_runner.py` (now
`contextlib.chdir`) is only exercised there.

### Before your PR is "*Ready for review*"

Make sure you read and follow [Contributor
guidelines](https://github.com/NVIDIA/Model-Optimizer/blob/main/CONTRIBUTING.md)
and your commits are signed (`git commit -s -S`).

Make sure you read and follow the [Security Best
Practices](https://github.com/NVIDIA/Model-Optimizer/blob/main/SECURITY.md#security-coding-practices-for-contributors)
(e.g. avoiding hardcoded `trust_remote_code=True`, `torch.load(...,
weights_only=False)`, `pickle`, etc.).

- Is this change backward compatible?: ❌ Python 3.10 is no longer
supported.
- If you copied code from any other sources or added a new PIP
dependency, did you follow guidance in `CONTRIBUTING.md`: N/A
- Did you write any new necessary tests?: N/A
- Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?:
✅
- Did you get Claude approval on this PR?: ❌

### Additional Information

The CHANGELOG has announced since an earlier release that Python 3.10
support would be dropped in the next release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Breaking Changes**
* Python 3.10 is no longer supported; Python 3.11 or later is required.
* **Compatibility**
* Linux x86-64 ONNX installations now default to CUDA 13 packages and
ONNX Runtime GPU 1.30.x, regardless of Python version. GPU calibration
requires a compatible CUDA 13 runtime and driver.
* CUDA 12.8 remains an alternative for Linux x86-64 with CUDA
12-compatible PyTorch. ONNX package selections vary by platform,
including CPU packages for aarch64 and macOS.
* **Documentation**
* Installation guides and example prerequisites reflect the updated
Python requirement and ONNX package options.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:03:39 +00:00

165 lines
6.0 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Portable and contained path contract for the shared FastGen cache."""
import json
import pathlib
import sys
import types
import pytest
torch = pytest.importorskip("torch")
pytest.importorskip("nemo_automodel")
pytest.importorskip("torchdata")
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[4]
_FASTGEN_DIR = _REPO_ROOT / "examples" / "diffusers" / "fastgen"
if str(_FASTGEN_DIR) not in sys.path:
sys.path.insert(0, str(_FASTGEN_DIR))
from fastgen_data import build_text_to_image_multiresolution_dataloader
from fastgen_data.paths import resolve_cache_root, resolve_under_root
from fastgen_data.text_to_image_dataset import TextToImageDataset
def test_cache_root_resolves_configured_directory(make_fastgen_cache, tmp_path):
cache = make_fastgen_cache(tmp_path / "cache")
assert resolve_cache_root(cache) == cache.resolve()
def test_cache_root_rejects_missing_or_non_directory(tmp_path):
with pytest.raises(FileNotFoundError):
resolve_cache_root(tmp_path / "missing")
regular_file = tmp_path / "file"
regular_file.write_text("not a directory")
with pytest.raises(NotADirectoryError):
resolve_cache_root(regular_file)
def test_resolve_under_root_rejects_traversal_absolute_and_symlink_escape(tmp_path):
root = tmp_path / "cache"
root.mkdir()
inside = root / "inside.pt"
inside.write_bytes(b"inside")
outside = tmp_path / "outside.pt"
outside.write_bytes(b"outside")
(root / "escape.pt").symlink_to(outside)
assert resolve_under_root(root, "inside.pt", "sample") == inside.resolve()
for candidate in ("../outside.pt", outside, "escape.pt"):
with pytest.raises(ValueError, match="sample"):
resolve_under_root(root, candidate, "sample")
def test_dataset_rejects_metadata_shard_escape(make_fastgen_cache, tmp_path):
cache = make_fastgen_cache(tmp_path / "cache")
safe_item = json.loads((cache / "metadata_shard_0.json").read_text())[0]
outside_shard = tmp_path / "outside.json"
outside_shard.write_text(json.dumps([safe_item]))
(cache / "metadata.json").write_text(json.dumps({"shards": [str(outside_shard)]}))
with pytest.raises(ValueError, match="metadata shard"):
TextToImageDataset(cache)
def test_dataset_rejects_payload_symlink_escape(make_fastgen_cache, tmp_path):
cache = make_fastgen_cache(tmp_path / "cache")
outside = tmp_path / "outside.pt"
torch.save({"latent": torch.zeros(1)}, outside)
(cache / "escape.pt").symlink_to(outside)
shard = json.loads((cache / "metadata_shard_0.json").read_text())
shard[0]["cache_file"] = "escape.pt"
(cache / "metadata_shard_0.json").write_text(json.dumps(shard))
dataset = TextToImageDataset(cache)
with pytest.raises(ValueError, match="sample cache file"):
dataset[0]
def test_dataset_accepts_absolute_payload_beneath_root(make_fastgen_cache, tmp_path):
cache = make_fastgen_cache(tmp_path / "cache", absolute_payloads=True)
dataset = TextToImageDataset(cache)
assert "latent" in dataset[0]
def test_prompt_only_dataset_and_loader_do_not_emit_image_latents(make_fastgen_cache, tmp_path):
cache = make_fastgen_cache(tmp_path / "cache")
dataset = TextToImageDataset(cache, prompt_only=True)
assert "latent" not in dataset[0]
loader, _ = build_text_to_image_multiresolution_dataloader(
cache_dir=str(cache),
prompt_only=True,
batch_size=1,
num_workers=0,
shuffle=False,
negative_prompt_embedding_path="negative_prompt_embedding.pt",
)
batch = next(iter(loader))
assert "image_latents" not in batch
assert {"text_embeddings", "text_embeddings_mask"}.issubset(batch)
assert "negative_text_embeddings" in batch
def test_builder_rejects_negative_embedding_escape(make_fastgen_cache, tmp_path):
cache = make_fastgen_cache(tmp_path / "cache")
outside = tmp_path / "negative.pt"
torch.save(torch.zeros(2, 3), outside)
with pytest.raises(ValueError, match="negative prompt embedding"):
build_text_to_image_multiresolution_dataloader(
cache_dir=str(cache),
num_workers=0,
negative_prompt_embedding_path=str(outside),
)
def test_preprocessing_publishes_relocatable_paths(monkeypatch, tmp_path):
# The metadata publisher does not use OpenCV. Stub that optional video dependency so this
# CPU-only test executes the real publisher in the lean AutoModel test environment.
monkeypatch.setitem(sys.modules, "cv2", types.ModuleType("cv2"))
from preprocess.preprocessing_multiprocess import _save_metadata_shards
monkeypatch.chdir(tmp_path)
output = pathlib.Path("relative-cache")
output.mkdir()
payload = output / "sample.pt"
torch.save({"latent": torch.zeros(1)}, payload)
_save_metadata_shards(
[{"cache_file": str(payload)}],
output,
"qwen_image",
"Qwen/Qwen-Image",
"qwen_image",
10,
{},
)
shard = json.loads((output / "metadata_shard_s0000.json").read_text())
published = pathlib.Path(shard[0]["cache_file"])
assert published == pathlib.Path("sample.pt")
relocated = tmp_path / "relocated-cache"
output.rename(relocated)
resolved = resolve_under_root(relocated, published, "sample cache file")
assert torch.equal(torch.load(resolved, weights_only=True)["latent"], torch.zeros(1))