Files
wehub-resource-sync e06fe8e8c6
Secret Leaks / trufflehog (push) Failing after 1s
Build documentation / build (push) Failing after 1s
Build documentation / build_other_lang (push) Failing after 0s
CodeQL Security Analysis / CodeQL Analysis (push) Failing after 0s
PR CI / pr-ci (push) Failing after 1s
Slow tests on important models (on Push - A10) / Get all modified files (push) Failing after 1s
Slow tests on important models (on Push - A10) / Model CI (push) Has been skipped
Self-hosted runner (benchmark) / Benchmark (aws-g5-4xlarge-cache) (push) Has been cancelled
New model PR merged notification / Notify new model (push) Has been cancelled
Update Transformers metadata / build_and_package (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:57:37 +08:00

285 lines
13 KiB
Python

# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# 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.
"""Unit tests for the ``transformers.exporters`` pieces the per-model export tests don't reach.
The per-model exporter mixins in ``tests/exporters/test_export.py`` end-to-end-exercise
``prepare_for_export``, ``apply_patches`` / ``apply_fx_*_fixes``, the leaf-tensor helpers,
and the bundled input preparers — so those get real coverage on every CI run. What they
DON'T touch:
- The **auto factory** (``AutoExportConfig`` / ``AutoHfExporter``) — models bypass it and
instantiate concrete exporters directly.
- **Config dict round-trips** — configs are built via constructor calls, never serialised.
- **Registration edge cases** — collision warnings and type-check rejections in
``register_exporter`` / ``register_export_config``.
- **`patch_attributes` restore-on-exception** — the happy path is exercised but the exception
branch never fires in real exports.
- The **`decompose_prefill_decode` guard** against generators that bypass the top-level
forward — real generators call ``forward`` many times, so the guard is dead code without a
targeted test.
- **`register_patch`** unresolvable-path fallback — real registrations point at real paths.
Everything below targets one of those gaps.
"""
import unittest
from unittest import mock
from transformers.exporters import utils as exporter_utils
from transformers.exporters.auto import (
AUTO_EXPORT_CONFIG_MAPPING,
AUTO_EXPORTER_MAPPING,
AutoExportConfig,
AutoHfExporter,
register_export_config,
register_exporter,
)
from transformers.exporters.base import HfExporter
from transformers.exporters.configs import DynamoConfig, ExecutorchConfig, ExportFormat, OnnxConfig
from transformers.testing_utils import require_executorch, require_onnx, require_onnxscript, require_torch
from transformers.utils.import_utils import is_torch_available
if is_torch_available():
import torch
from torch import nn
from transformers.exporters.utils import (
cast_leaf_tensors,
decompose_prefill_decode,
duplicate_leaf_tensors,
patch_attributes,
register_patch,
)
CONCRETE_CONFIGS = [
(OnnxConfig, ExportFormat.ONNX),
(DynamoConfig, ExportFormat.DYNAMO),
(ExecutorchConfig, ExportFormat.EXECUTORCH),
]
# ─────────────────────────────────────────────────────────────────────────────
# Auto factory + config serialisation
# ─────────────────────────────────────────────────────────────────────────────
class ExportConfigMixinTest(unittest.TestCase):
def test_to_dict_from_dict_roundtrip(self):
for config_cls, export_format in CONCRETE_CONFIGS:
with self.subTest(config_cls.__name__):
original = config_cls(dynamic=True)
restored = config_cls.from_dict(original.to_dict())
self.assertEqual(restored, original)
self.assertIs(restored.export_format, export_format)
class AutoExportConfigTest(unittest.TestCase):
def test_from_dict_dispatches_to_concrete_config(self):
for config_cls, export_format in CONCRETE_CONFIGS:
with self.subTest(config_cls.__name__):
self.assertIsInstance(AutoExportConfig.from_dict({"export_format": export_format.value}), config_cls)
# Enum inputs also work — serialised configs may hold either form.
self.assertIsInstance(AutoExportConfig.from_dict({"export_format": export_format}), config_cls)
def test_from_dict_missing_export_format_raises(self):
with self.assertRaisesRegex(ValueError, "export_format"):
AutoExportConfig.from_dict({})
def test_from_dict_unknown_format_raises(self):
with self.assertRaisesRegex(ValueError, "Unknown exporter type"):
AutoExportConfig.from_dict({"export_format": "not_a_real_backend"})
class AutoHfExporterTest(unittest.TestCase):
def _check_dispatch(self, config):
expected_cls = AUTO_EXPORTER_MAPPING[config.export_format.value]
self.assertIsInstance(AutoHfExporter.from_config(config), expected_cls)
# Same dispatch works when starting from a plain dict.
self.assertIsInstance(AutoHfExporter.from_config(config.to_dict()), expected_cls)
@require_torch
def test_from_config_dispatches_dynamo(self):
self._check_dispatch(DynamoConfig())
@require_torch
@require_onnx
@require_onnxscript
def test_from_config_dispatches_onnx(self):
self._check_dispatch(OnnxConfig())
@require_torch
@require_executorch
def test_from_config_dispatches_executorch(self):
self._check_dispatch(ExecutorchConfig())
def test_from_config_raises_on_unknown_format(self):
with self.assertRaisesRegex(ValueError, "Unsupported export config"):
AutoHfExporter.from_config({"export_format": "not_a_real_backend"})
with self.assertRaisesRegex(ValueError, "Unsupported export config"):
AutoHfExporter.from_config({})
class RegistrationTest(unittest.TestCase):
"""Cover the edge cases of `register_exporter` / `register_export_config` that normal
registrations at module load don't hit — the type-check rejection paths. The mappings are
temporarily patched so registrations never leak into other tests."""
def test_register_exporter_rejects_non_subclass(self):
with mock.patch.dict(AUTO_EXPORTER_MAPPING):
with self.assertRaisesRegex(TypeError, "HfExporter"):
@register_exporter("bad")
class _NotAnExporter:
pass
def test_register_export_config_rejects_non_subclass(self):
with mock.patch.dict(AUTO_EXPORT_CONFIG_MAPPING):
with self.assertRaisesRegex(TypeError, "ExportConfigMixin"):
@register_export_config("bad_config")
class _NotAConfig:
pass
def test_register_exporter_installs_stub(self):
# Sanity check that a legit registration is wired through — protects against a future
# refactor that would break the decorator without breaking any real export test.
with mock.patch.dict(AUTO_EXPORTER_MAPPING):
@register_exporter("stub_exporter")
class _StubExporter(HfExporter):
required_packages = []
def export(self, model, sample_inputs, config):
return None
self.assertIs(AUTO_EXPORTER_MAPPING["stub_exporter"], _StubExporter)
self.assertNotIn("stub_exporter", AUTO_EXPORTER_MAPPING)
# ─────────────────────────────────────────────────────────────────────────────
# Registry edge cases the happy-path exports don't exercise
# ─────────────────────────────────────────────────────────────────────────────
class _Owner:
def method(self):
return "original"
@require_torch
class PatchRegistryEdgeCasesTest(unittest.TestCase):
def test_patch_attributes_roll_back_on_exception(self):
# Real exports never exit the trace via exception, so this rollback path is untested by
# integration. If it ever regressed to leave already-installed patches in place when a
# later factory raises, the *next* export would run against a leaked patch and fail in
# a way that looks unrelated. Only this test would catch that.
a, b = _Owner(), _Owner()
def _bad_factory(original):
raise RuntimeError("factory boom")
with self.assertRaisesRegex(RuntimeError, "factory boom"):
with patch_attributes(
[
(a, "method", lambda original: (lambda: "a-patched")),
(b, "method", _bad_factory),
]
):
pass
self.assertEqual(a.method(), "original")
self.assertEqual(b.method(), "original")
def test_register_patch_skips_unresolvable_path(self):
# Real backends only register paths that resolve; the silent-skip fallback is what lets
# `exporter_onnx.py` and `exporter_executorch.py` co-exist when only one backend is
# installed. If it ever started raising, one of the two backends would fail to import.
backend = "_test_unresolvable"
@register_patch(backend, "does.not.exist.at.all")
def _patch(original):
return original
try:
self.assertEqual(exporter_utils._PATCHES.get(backend, []), [])
finally:
exporter_utils._PATCHES.pop(backend, None)
# ─────────────────────────────────────────────────────────────────────────────
# Leaf-tensor invariants that integration tests wouldn't visibly catch
# ─────────────────────────────────────────────────────────────────────────────
@require_torch
class LeafTensorInvariantsTest(unittest.TestCase):
def test_duplicate_leaf_tensors_only_clones_repeats(self):
# If this ever regressed to ``.clone()``-everything, ONNX exports would still succeed
# and just get a bit bigger — no integration test would notice. Similarly, if it
# stopped cloning the second occurrence, ONNX's output-node dedup would rename ports
# in a way that only manifests as a stale name mapping.
shared = torch.zeros(2)
distinct = torch.ones(3)
result = duplicate_leaf_tensors({"a": shared, "b": shared, "c": distinct})
self.assertIs(result["a"], shared)
self.assertIsNot(result["b"], shared)
self.assertTrue(torch.equal(result["b"], shared))
self.assertIs(result["c"], distinct)
def test_cast_leaf_tensors_preserves_integer_dtypes(self):
# ``prepare_for_export`` casts input trees to the model's dtype. If this ever started
# coercing integer tensors (``input_ids``, indices, positions) to float, most exports
# would still trace but embedding-lookup / bincount / index-select paths would fail
# far downstream with confusing errors. Only this test would attribute it to the cast.
out = cast_leaf_tensors(
{
"input_ids": torch.zeros(2, dtype=torch.int64),
"attention_mask": torch.ones(2, dtype=torch.int32),
"hidden": torch.zeros(2, dtype=torch.float32),
},
dtype=torch.float16,
device=torch.device("cpu"),
)
self.assertEqual(out["input_ids"].dtype, torch.int64)
self.assertEqual(out["attention_mask"].dtype, torch.int32)
self.assertEqual(out["hidden"].dtype, torch.float16)
# ─────────────────────────────────────────────────────────────────────────────
# decompose_prefill_decode guard (dead code without this test — no real generator
# calls forward < 2 times, so the branch would rot silently)
# ─────────────────────────────────────────────────────────────────────────────
@require_torch
class DecomposePrefillDecodeGuardTest(unittest.TestCase):
def test_raises_when_generate_bypasses_forward(self):
# Guards against generators that delegate to an inner model — the top-level ``forward``
# captures at most one call, so the ``calls[0] / calls[1]`` indexing would raise a
# confusing IndexError instead of the helpful RuntimeError below.
class _FakeGenerator(nn.Module):
def __init__(self):
super().__init__()
self.linear = nn.Linear(1, 1)
def forward(self, input_ids=None, **kwargs):
return input_ids
def generate(self, input_ids=None, max_new_tokens=None, min_new_tokens=None, **kwargs):
return self.forward(input_ids=input_ids) # a single top-level forward call
with self.assertRaisesRegex(RuntimeError, "captured 1"):
decompose_prefill_decode(_FakeGenerator(), {"input_ids": torch.zeros(1, 1, dtype=torch.long)})