已合并
feat: 增加 ONNX Plugin Python API 基础能力 #4353
gentle-knight创建于 13 天前
feat: 增加 ONNX Plugin Python API 基础能力 #4353
已合并
共 13 个文件变更+1374-4
| @@ -20,7 +20,10 @@ import os | |||
| 20 | import sys | 20 | import sys |
| 21 | from pathlib import Path | 21 | from pathlib import Path |
| 22 | from types import ModuleType | 22 | from types import ModuleType |
| 23 | -from typing import Iterable, List | 23 | +from typing import Dict, Iterable, List, Optional |
| 24 | + | ||
| 25 | + | ||
| 26 | +_MODULE_NAME_BY_CANONICAL_PATH: Dict[Path, str] = {} | ||
| 24 | 27 | ||
| 25 | 28 | ||
| 26 | def normalize_path_list(path_value: str) -> List[str]: | 29 | def normalize_path_list(path_value: str) -> List[str]: |
| @@ -29,9 +32,23 @@ def normalize_path_list(path_value: str) -> List[str]: | |||
| 29 | return [item.strip() for item in path_value.split(os.pathsep) if item.strip()] | 32 | return [item.strip() for item in path_value.split(os.pathsep) if item.strip()] |
| 30 | 33 | ||
| 31 | 34 | ||
| 35 | +def _get_loaded_module(canonical_path: Path) -> Optional[ModuleType]: | ||
| 36 | + module_name = _MODULE_NAME_BY_CANONICAL_PATH.get(canonical_path) | ||
| 37 | + if module_name is None: | ||
| 38 | + return None | ||
| 39 | + module = sys.modules.get(module_name) | ||
| 40 | + if module is None: | ||
| 41 | + del _MODULE_NAME_BY_CANONICAL_PATH[canonical_path] | ||
| 42 | + return module | ||
| 43 | + | ||
| 44 | + | ||
| 32 | def load_module_from_file( | 45 | def load_module_from_file( |
| 33 | file_path: Path, *, module_prefix: str, plugin_kind: str | 46 | file_path: Path, *, module_prefix: str, plugin_kind: str |
| 34 | ) -> ModuleType: | 47 | ) -> ModuleType: |
| 48 | + file_path = file_path.resolve() | ||
| 49 | + loaded_module = _get_loaded_module(file_path) | ||
| 50 | + if loaded_module is not None: | ||
| 51 | + return loaded_module | ||
| 35 | module_name = f"{module_prefix}{file_path.stem}_{abs(hash(str(file_path)))}" | 52 | module_name = f"{module_prefix}{file_path.stem}_{abs(hash(str(file_path)))}" |
| 36 | if module_name in sys.modules: | 53 | if module_name in sys.modules: |
| 37 | return sys.modules[module_name] | 54 | return sys.modules[module_name] |
| @@ -40,14 +57,19 @@ def load_module_from_file( | |||
| 40 | raise ImportError(f"cannot load {plugin_kind} file: {file_path}") | 57 | raise ImportError(f"cannot load {plugin_kind} file: {file_path}") |
| 41 | module = importlib.util.module_from_spec(spec) | 58 | module = importlib.util.module_from_spec(spec) |
| 42 | sys.modules[module_name] = module | 59 | sys.modules[module_name] = module |
| 43 | - spec.loader.exec_module(module) | 60 | + try: |
| 61 | + spec.loader.exec_module(module) | ||
| 62 | + except BaseException: | ||
| 63 | + del sys.modules[module_name] | ||
| 64 | + raise | ||
| 65 | + _MODULE_NAME_BY_CANONICAL_PATH[file_path] = module_name | ||
| 44 | return module | 66 | return module |
| 45 | 67 | ||
| 46 | 68 | ||
| 47 | def scan_modules_from_path( | 69 | def scan_modules_from_path( |
| 48 | path_item: str, *, module_prefix: str, plugin_kind: str | 70 | path_item: str, *, module_prefix: str, plugin_kind: str |
| 49 | ) -> List[ModuleType]: | 71 | ) -> List[ModuleType]: |
| 50 | - path = Path(path_item) | 72 | + path = Path(path_item).resolve() |
| 51 | if not path.exists(): | 73 | if not path.exists(): |
| 52 | raise FileNotFoundError(f"{plugin_kind} path does not exist: {path}") | 74 | raise FileNotFoundError(f"{plugin_kind} path does not exist: {path}") |
| 53 | if path.is_file(): | 75 | if path.is_file(): |
| @@ -73,7 +95,12 @@ def scan_modules_from_path( | |||
| 73 | ) | 95 | ) |
| 74 | continue | 96 | continue |
| 75 | if child.is_dir() and (child / "__init__.py").exists(): | 97 | if child.is_dir() and (child / "__init__.py").exists(): |
| 76 | - modules.append(importlib.import_module(child.name)) | 98 | + package_init = (child / "__init__.py").resolve() |
| 99 | + module = _get_loaded_module(package_init) | ||
| 100 | + if module is None: | ||
| 101 | + module = importlib.import_module(child.name) | ||
| 102 | + _MODULE_NAME_BY_CANONICAL_PATH[package_init] = module.__name__ | ||
| 103 | + modules.append(module) | ||
| 77 | return modules | 104 | return modules |
| 78 | 105 | ||
| 79 | 106 | ||
| @@ -17,6 +17,7 @@ graph模块 - 图操作接口 | |||
| 17 | 这个模块提供了对Graph Engine核心图操作功能的Python封装,包括: | 17 | 这个模块提供了对Graph Engine核心图操作功能的Python封装,包括: |
| 18 | - Graph: 图对象 | 18 | - Graph: 图对象 |
| 19 | - Node: 节点对象 | 19 | - Node: 节点对象 |
| 20 | +- Operator: 算子对象,用于读写算子的属性及输入输出等定义信息 | ||
| 20 | - Tensor: 张量对象 | 21 | - Tensor: 张量对象 |
| 21 | - DataType: 数据类型 | 22 | - DataType: 数据类型 |
| 22 | - Format: 数据格式 | 23 | - Format: 数据格式 |
| @@ -28,6 +29,7 @@ graph模块 - 图操作接口 | |||
| 28 | 29 | ||
| 29 | from .graph import DumpFormat, Graph | 30 | from .graph import DumpFormat, Graph |
| 30 | from .node import Node | 31 | from .node import Node |
| 32 | +from .operator import Operator | ||
| 31 | from .tensor import Tensor | 33 | from .tensor import Tensor |
| 32 | from .tensor_desc import Shape, TensorDesc | 34 | from .tensor_desc import Shape, TensorDesc |
| 33 | from .types import DataType, Format, Placement | 35 | from .types import DataType, Format, Placement |
| @@ -35,6 +37,7 @@ from .types import DataType, Format, Placement | |||
| 35 | __all__ = [ | 37 | __all__ = [ |
| 36 | "Graph", | 38 | "Graph", |
| 37 | "Node", | 39 | "Node", |
| 40 | + "Operator", | ||
| 38 | "DataType", | 41 | "DataType", |
| 39 | "Format", | 42 | "Format", |
| 40 | "Placement", | 43 | "Placement", |
| @@ -0,0 +1,97 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# -*- coding: utf-8 -*- | ||
| 3 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 4 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 5 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 6 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 7 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 8 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 9 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 10 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 11 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 12 | + | ||
| 13 | +"""GE operator object for reading and updating definition information.""" | ||
| 14 | + | ||
| 15 | +_OPERATOR_FACTORY_TOKEN = object() | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +class Operator: | ||
| 19 | + """GE operator borrowed for the duration of a callback.""" | ||
| 20 | + | ||
| 21 | + __slots__ = ("_handle", "_valid") | ||
| 22 | + | ||
| 23 | + def __init__(self, handle=None, token=None) -> None: | ||
| 24 | + if token is not _OPERATOR_FACTORY_TOKEN: | ||
| 25 | + raise RuntimeError("Operator objects should not be created directly.") | ||
| 26 | + if handle is None: | ||
| 27 | + raise ValueError("Operator handle cannot be None") | ||
| 28 | + self._handle = handle | ||
| 29 | + self._valid = True | ||
| 30 | + | ||
| 31 | + def __copy__(self) -> None: | ||
| 32 | + raise RuntimeError("Operator does not support copy") | ||
| 33 | + | ||
| 34 | + def __deepcopy__(self, memodict) -> None: | ||
| 35 | + raise RuntimeError("Operator does not support deepcopy") | ||
| 36 | + | ||
| 37 | + def __enter__(self) -> "Operator": | ||
| 38 | + return self | ||
| 39 | + | ||
| 40 | + def __exit__(self, exc_type, exc_value, traceback) -> None: | ||
| 41 | + if not self._valid: | ||
| 42 | + return | ||
| 43 | + self._valid = False | ||
| 44 | + self._handle.invalidate() | ||
| 45 | + | ||
| 46 | + | ||
| 47 | + def _validate_name(name: str, kind: str) -> None: | ||
| 48 | + if not isinstance(name, str) or not name: | ||
| 49 | + raise TypeError(f"Operator {kind} name must be a non-empty string") | ||
| 50 | + | ||
| 51 | + | ||
| 52 | + def name(self) -> str: | ||
| 53 | + self._ensure_valid() | ||
| 54 | + return self._handle.get_name() | ||
| 55 | + | ||
| 56 | + | ||
| 57 | + def type(self) -> str: | ||
| 58 | + self._ensure_valid() | ||
| 59 | + return self._handle.get_type() | ||
| 60 | + | ||
| 61 | + def set_attr(self, name: str, value: object) -> None: | ||
| 62 | + self._ensure_valid() | ||
| 63 | + self._validate_name(name, "attribute") | ||
| 64 | + if type(value) is int: | ||
| 65 | + if value < -(1 << 63) or value >= 1 << 63: | ||
| 66 | + raise ValueError("Operator int attribute must be in int64 range") | ||
| 67 | + elif type(value) is not float: | ||
| 68 | + raise TypeError("Operator set_attr only supports int and float values") | ||
| 69 | + self._handle.set_attr(name, value) | ||
| 70 | + | ||
| 71 | + def register_dynamic_input(self, name: str, count: int) -> None: | ||
| 72 | + self._register_dynamic_port(name, count, is_input=True) | ||
| 73 | + | ||
| 74 | + def register_dynamic_output(self, name: str, count: int) -> None: | ||
| 75 | + self._register_dynamic_port(name, count, is_input=False) | ||
| 76 | + | ||
| 77 | + def _register_dynamic_port(self, name: str, count: int, *, is_input: bool) -> None: | ||
| 78 | + self._ensure_valid() | ||
| 79 | + self._validate_name(name, "dynamic port") | ||
| 80 | + if type(count) is not int: | ||
| 81 | + raise TypeError("Operator dynamic port count must be an integer") | ||
| 82 | + if count < 0 or count >= 1 << 32: | ||
| 83 | + raise ValueError("Operator dynamic port count must be in uint32 range") | ||
| 84 | + if is_input: | ||
| 85 | + self._handle.register_dynamic_input(name, count) | ||
| 86 | + else: | ||
| 87 | + self._handle.register_dynamic_output(name, count) | ||
| 88 | + | ||
| 89 | + def _ensure_valid(self) -> None: | ||
| 90 | + if not self._valid: | ||
| 91 | + raise RuntimeError("Operator is only valid inside parse_node") | ||
| 92 | + | ||
| 93 | + | ||
| 94 | +def create_operator(handle) -> Operator: | ||
| 95 | + """Create a callback-bound Operator for internal bridge use.""" | ||
| 96 | + | ||
| 97 | + return Operator(handle, _OPERATOR_FACTORY_TOKEN) | ||
| @@ -0,0 +1,18 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# -*- coding: utf-8 -*- | ||
| 3 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 4 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 5 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 6 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 7 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 8 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 9 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 10 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 11 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 12 | + | ||
| 13 | +"""Python ONNX Plugin public package.""" | ||
| 14 | + | ||
| 15 | +from .onnx_node import OnnxNode | ||
| 16 | +from .plugin import OnnxPlugin, onnx_plugin | ||
| 17 | + | ||
| 18 | +__all__ = ["OnnxNode", "OnnxPlugin", "onnx_plugin"] | ||
| @@ -0,0 +1,41 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# -*- coding: utf-8 -*- | ||
| 3 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 4 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 5 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 6 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 7 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 8 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 9 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 10 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 11 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 12 | + | ||
| 13 | +"""Bridge-facing callback dispatch for Python ONNX Plugins.""" | ||
| 14 | + | ||
| 15 | +from ge.graph.operator import create_operator | ||
| 16 | + | ||
| 17 | +from .bootstrap import load_onnx_plugins | ||
| 18 | +from .onnx_node import create_onnx_node | ||
| 19 | +from .registry import ( | ||
| 20 | + get_registered_onnx_plugin_by_origin_type, | ||
| 21 | + get_registered_onnx_plugin_dicts, | ||
| 22 | +) | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +def load_and_get_onnx_plugin_descriptors() -> list: | ||
| 26 | + load_onnx_plugins() | ||
| 27 | + return get_registered_onnx_plugin_dicts() | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +def call_parse_node(origin_type: str, node_values: dict, operator_backend) -> None: | ||
| 31 | + """Dispatch one flattened ONNX node to its registered parse_node callback.""" | ||
| 32 | + | ||
| 33 | + descriptor = get_registered_onnx_plugin_by_origin_type(origin_type) | ||
| 34 | + if descriptor is None: | ||
| 35 | + raise KeyError(f"python ONNX Plugin is not registered: {origin_type}") | ||
| 36 | + | ||
| 37 | + node = create_onnx_node(**node_values) | ||
| 38 | + with create_operator(operator_backend) as target: | ||
| 39 | + result = descriptor.parser_node(node, target) | ||
| 40 | + if result is not None: | ||
| 41 | + raise TypeError("ONNX Plugin parse_node callback must return None") | ||
| @@ -0,0 +1,31 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# -*- coding: utf-8 -*- | ||
| 3 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 4 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 5 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 6 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 7 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 8 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 9 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 10 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 11 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 12 | + | ||
| 13 | +"""Discovery and loading utilities for Python ONNX Plugins.""" | ||
| 14 | + | ||
| 15 | +from types import ModuleType | ||
| 16 | +from typing import List | ||
| 17 | + | ||
| 18 | +from ge._internal.plugin_loader import load_plugins_from_env | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +ENV_PY_ONNX_PLUGIN_PATH = "ASCEND_CUSTOM_OPP_PATH" | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +def load_onnx_plugins() -> List[ModuleType]: | ||
| 25 | + """Load ONNX Plugins from the shared custom OPP path list.""" | ||
| 26 | + | ||
| 27 | + return load_plugins_from_env( | ||
| 28 | + ENV_PY_ONNX_PLUGIN_PATH, | ||
| 29 | + module_prefix="_ge_py_onnx_plugin_", | ||
| 30 | + plugin_kind="python ONNX Plugin", | ||
| 31 | + ) | ||
| @@ -0,0 +1,114 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# -*- coding: utf-8 -*- | ||
| 3 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 4 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 5 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 6 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 7 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 8 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 9 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 10 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 11 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 12 | + | ||
| 13 | +"""Read-only ONNX node values exposed to Python parser callbacks.""" | ||
| 14 | + | ||
| 15 | +from types import MappingProxyType | ||
| 16 | +from typing import Mapping, Sequence | ||
| 17 | + | ||
| 18 | +_ONNX_NODE_FACTORY_TOKEN = object() | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +class OnnxNode: | ||
onnx_node.py可以用pybind封装为native的so作为模块导入,这样就不需要单独写python文件了 ![]() ![]() | |||
| 22 | + """Flattened ONNX source node created by the parser bridge.""" | ||
| 23 | + | ||
| 24 | + __slots__ = ("_attrs", "_inputs", "_name", "_origin_type", "_outputs") | ||
| 25 | + | ||
| 26 | + def __init__( | ||
| 27 | + self, | ||
| 28 | + *, | ||
| 29 | + name=None, | ||
| 30 | + origin_type=None, | ||
| 31 | + inputs=None, | ||
| 32 | + outputs=None, | ||
| 33 | + attrs=None, | ||
| 34 | + token=None, | ||
| 35 | + ) -> None: | ||
| 36 | + if token is not _ONNX_NODE_FACTORY_TOKEN: | ||
| 37 | + raise RuntimeError("OnnxNode objects should not be created directly.") | ||
| 38 | + if not isinstance(name, str): | ||
| 39 | + raise TypeError("OnnxNode name must be a string") | ||
| 40 | + if not isinstance(origin_type, str) or not origin_type: | ||
| 41 | + raise TypeError("OnnxNode origin_type must be a non-empty string") | ||
| 42 | + normalized_inputs = self._normalize_names(inputs, "inputs") | ||
| 43 | + normalized_outputs = self._normalize_names(outputs, "outputs") | ||
| 44 | + normalized_attrs = self._normalize_attrs(attrs) | ||
| 45 | + | ||
| 46 | + object.__setattr__(self, "_name", name) | ||
| 47 | + object.__setattr__(self, "_origin_type", origin_type) | ||
| 48 | + object.__setattr__(self, "_inputs", normalized_inputs) | ||
| 49 | + object.__setattr__(self, "_outputs", normalized_outputs) | ||
| 50 | + object.__setattr__(self, "_attrs", MappingProxyType(normalized_attrs)) | ||
| 51 | + | ||
| 52 | + def __setattr__(self, name, value) -> None: | ||
| 53 | + raise AttributeError("OnnxNode is read-only") | ||
| 54 | + | ||
| 55 | + | ||
| 56 | + def _normalize_names(values: Sequence[str], field_name: str) -> tuple: | ||
| 57 | + if isinstance(values, (str, bytes)) or not isinstance(values, Sequence): | ||
| 58 | + raise TypeError(f"OnnxNode {field_name} must be a sequence of strings") | ||
| 59 | + if any(not isinstance(value, str) for value in values): | ||
| 60 | + raise TypeError(f"OnnxNode {field_name} must contain only strings") | ||
| 61 | + return tuple(values) | ||
| 62 | + | ||
| 63 | + | ||
| 64 | + def _normalize_attrs(attrs: Mapping[str, object]) -> dict: | ||
| 65 | + if not isinstance(attrs, Mapping): | ||
| 66 | + raise TypeError("OnnxNode attrs must be a mapping") | ||
| 67 | + normalized = {} | ||
| 68 | + for name, value in attrs.items(): | ||
| 69 | + if not isinstance(name, str) or not name: | ||
| 70 | + raise TypeError("OnnxNode attribute name must be a non-empty string") | ||
| 71 | + if type(value) not in (int, float): | ||
| 72 | + raise TypeError("OnnxNode attrs only supports int and float values") | ||
| 73 | + normalized[name] = value | ||
| 74 | + return normalized | ||
| 75 | + | ||
| 76 | + | ||
| 77 | + def name(self) -> str: | ||
| 78 | + return self._name | ||
| 79 | + | ||
| 80 | + | ||
| 81 | + def origin_type(self) -> str: | ||
| 82 | + return self._origin_type | ||
| 83 | + | ||
| 84 | + | ||
| 85 | + def inputs(self) -> tuple: | ||
| 86 | + return self._inputs | ||
| 87 | + | ||
| 88 | + | ||
| 89 | + def outputs(self) -> tuple: | ||
| 90 | + return self._outputs | ||
| 91 | + | ||
| 92 | + | ||
| 93 | + def attrs(self) -> Mapping[str, object]: | ||
| 94 | + return self._attrs | ||
| 95 | + | ||
| 96 | + | ||
| 97 | +def create_onnx_node( | ||
| 98 | + *, | ||
| 99 | + name: str, | ||
| 100 | + origin_type: str, | ||
| 101 | + inputs: Sequence[str], | ||
| 102 | + outputs: Sequence[str], | ||
| 103 | + attrs: Mapping[str, object], | ||
| 104 | +) -> OnnxNode: | ||
| 105 | + """Create an OnnxNode for internal bridge use.""" | ||
| 106 | + | ||
| 107 | + return OnnxNode( | ||
| 108 | + name=name, | ||
| 109 | + origin_type=origin_type, | ||
| 110 | + inputs=inputs, | ||
| 111 | + outputs=outputs, | ||
| 112 | + attrs=attrs, | ||
| 113 | + token=_ONNX_NODE_FACTORY_TOKEN, | ||
| 114 | + ) | ||
| @@ -0,0 +1,102 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# -*- coding: utf-8 -*- | ||
| 3 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 4 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 5 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 6 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 7 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 8 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 9 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 10 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 11 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 12 | + | ||
| 13 | +"""Public descriptor and callback decorator for Python ONNX Plugins.""" | ||
| 14 | + | ||
| 15 | +import inspect | ||
| 16 | +from collections.abc import Collection | ||
| 17 | +from typing import Callable, Optional, Tuple | ||
| 18 | + | ||
| 19 | +from .registry import OnnxPluginDescriptor, register_onnx_plugin | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +def _normalize_name( | ||
| 23 | + value: str, field_name: str, *, reject_origin_separator=False | ||
| 24 | +) -> str: | ||
| 25 | + if not isinstance(value, str) or not value: | ||
| 26 | + raise TypeError(f"onnx_plugin {field_name} must be a non-empty string") | ||
| 27 | + if reject_origin_separator and "::" in value: | ||
| 28 | + raise TypeError(f"onnx_plugin {field_name} must not contain '::'") | ||
| 29 | + return value | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +def _normalize_opsets(opsets: Collection[int]) -> Tuple[int, ...]: | ||
| 33 | + if isinstance(opsets, (str, bytes)) or not isinstance(opsets, Collection): | ||
| 34 | + raise TypeError("onnx_plugin opsets must be a collection of positive integers") | ||
| 35 | + if not opsets: | ||
| 36 | + raise ValueError("onnx_plugin opsets must not be empty") | ||
| 37 | + normalized = set() | ||
| 38 | + for opset in opsets: | ||
| 39 | + if type(opset) is not int: | ||
| 40 | + raise TypeError("onnx_plugin opsets must contain only integers") | ||
| 41 | + if opset <= 0: | ||
| 42 | + raise ValueError("onnx_plugin opsets must contain only positive integers") | ||
| 43 | + normalized.add(opset) | ||
| 44 | + return tuple(sorted(normalized)) | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +class OnnxPlugin: | ||
| 48 | + """ONNX source-to-target descriptor awaiting a parse_node callback.""" | ||
| 49 | + | ||
| 50 | + __slots__ = ("_descriptor", "_domain", "_opsets", "_source", "_target") | ||
| 51 | + | ||
| 52 | + def __init__( | ||
| 53 | + self, *, source: str, domain: str, opsets: Tuple[int, ...], target: str | ||
| 54 | + ) -> None: | ||
| 55 | + self._source = source | ||
| 56 | + self._domain = domain | ||
| 57 | + self._opsets = opsets | ||
| 58 | + self._target = target | ||
| 59 | + self._descriptor: Optional[OnnxPluginDescriptor] = None | ||
| 60 | + | ||
| 61 | + def parse_node(self, fn: Callable[..., None]) -> Callable[..., None]: | ||
| 62 | + if self._descriptor is not None: | ||
| 63 | + raise ValueError("OnnxPlugin parse_node is already bound") | ||
| 64 | + if not inspect.isfunction(fn): | ||
| 65 | + raise TypeError("OnnxPlugin parse_node expects a Python function") | ||
| 66 | + | ||
| 67 | + module_name = fn.__module__ | ||
| 68 | + parser_node_name = fn.__qualname__ | ||
| 69 | + origin_types = tuple( | ||
| 70 | + f"{self._domain}::{opset}::{self._source}" for opset in self._opsets | ||
| 71 | + ) | ||
| 72 | + descriptor = register_onnx_plugin( | ||
| 73 | + OnnxPluginDescriptor( | ||
| 74 | + descriptor_key=( | ||
| 75 | + f"{module_name}:{parser_node_name}:{self._domain}:" | ||
| 76 | + f"{self._source}:{','.join(map(str, self._opsets))}" | ||
| 77 | + ), | ||
| 78 | + source=self._source, | ||
| 79 | + domain=self._domain, | ||
| 80 | + opsets=self._opsets, | ||
| 81 | + target=self._target, | ||
| 82 | + origin_types=origin_types, | ||
| 83 | + module_name=module_name, | ||
| 84 | + parser_node=fn, | ||
| 85 | + ) | ||
| 86 | + ) | ||
| 87 | + self._descriptor = descriptor | ||
| 88 | + setattr(fn, "__ge_onnx_plugin_descriptor__", descriptor) | ||
| 89 | + return fn | ||
| 90 | + | ||
| 91 | + | ||
| 92 | +def onnx_plugin( | ||
| 93 | + *, source: str, domain: str, opsets: Collection[int], target: str | ||
| 94 | +) -> OnnxPlugin: | ||
| 95 | + """Create an ONNX Plugin descriptor for binding a parse_node callback.""" | ||
| 96 | + | ||
| 97 | + return OnnxPlugin( | ||
| 98 | + source=_normalize_name(source, "source", reject_origin_separator=True), | ||
| 99 | + domain=_normalize_name(domain, "domain", reject_origin_separator=True), | ||
| 100 | + opsets=_normalize_opsets(opsets), | ||
| 101 | + target=_normalize_name(target, "target"), | ||
| 102 | + ) | ||
| @@ -0,0 +1,106 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# -*- coding: utf-8 -*- | ||
| 3 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 4 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 5 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 6 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 7 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 8 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 9 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 10 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 11 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 12 | + | ||
| 13 | +"""Python ONNX Plugin descriptor registry.""" | ||
| 14 | + | ||
| 15 | +import threading | ||
| 16 | +from dataclasses import dataclass, field | ||
| 17 | +from typing import Callable, Dict, List, Optional, Tuple | ||
| 18 | + | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +class OnnxPluginDescriptor: | ||
| 22 | + """Normalized descriptor for one parse_node callback.""" | ||
| 23 | + | ||
| 24 | + descriptor_key: str | ||
| 25 | + source: str | ||
| 26 | + domain: str | ||
| 27 | + opsets: Tuple[int, ...] | ||
| 28 | + target: str | ||
| 29 | + origin_types: Tuple[str, ...] | ||
| 30 | + module_name: str | ||
| 31 | + parser_node: Callable[..., None] = field(compare=False, repr=False) | ||
| 32 | + | ||
| 33 | + def to_bridge_dict(self) -> dict: | ||
| 34 | + return { | ||
| 35 | + "descriptor_key": self.descriptor_key, | ||
| 36 | + "source": self.source, | ||
| 37 | + "domain": self.domain, | ||
| 38 | + "opsets": list(self.opsets), | ||
| 39 | + "target": self.target, | ||
| 40 | + "origin_types": list(self.origin_types), | ||
| 41 | + "module_name": self.module_name, | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +class _OnnxPluginRegistry: | ||
| 46 | + def __init__(self) -> None: | ||
| 47 | + self._lock = threading.RLock() | ||
| 48 | + self._descriptor_key_to_desc: Dict[str, OnnxPluginDescriptor] = {} | ||
| 49 | + self._origin_type_to_desc: Dict[str, OnnxPluginDescriptor] = {} | ||
| 50 | + | ||
| 51 | + def clear(self) -> None: | ||
| 52 | + with self._lock: | ||
| 53 | + self._descriptor_key_to_desc.clear() | ||
| 54 | + self._origin_type_to_desc.clear() | ||
| 55 | + | ||
| 56 | + def register(self, descriptor: OnnxPluginDescriptor) -> OnnxPluginDescriptor: | ||
| 57 | + with self._lock: | ||
| 58 | + if descriptor.descriptor_key in self._descriptor_key_to_desc: | ||
| 59 | + raise ValueError( | ||
| 60 | + "python ONNX Plugin descriptor_key already exists: " | ||
| 61 | + f"{descriptor.descriptor_key}" | ||
| 62 | + ) | ||
| 63 | + for origin_type in descriptor.origin_types: | ||
| 64 | + if origin_type in self._origin_type_to_desc: | ||
| 65 | + raise ValueError( | ||
| 66 | + f"python ONNX Plugin origin type already exists: {origin_type}" | ||
| 67 | + ) | ||
| 68 | + self._descriptor_key_to_desc[descriptor.descriptor_key] = descriptor | ||
| 69 | + for origin_type in descriptor.origin_types: | ||
| 70 | + self._origin_type_to_desc[origin_type] = descriptor | ||
| 71 | + return descriptor | ||
| 72 | + | ||
| 73 | + def get_all(self) -> List[OnnxPluginDescriptor]: | ||
| 74 | + with self._lock: | ||
| 75 | + return list(self._descriptor_key_to_desc.values()) | ||
| 76 | + | ||
| 77 | + def get_by_origin_type(self, origin_type: str) -> Optional[OnnxPluginDescriptor]: | ||
| 78 | + # Registration finishes before parsing; parse-time lookup is read-only. | ||
| 79 | + return self._origin_type_to_desc.get(origin_type) | ||
| 80 | + | ||
| 81 | + | ||
| 82 | +_ONNX_PLUGIN_REGISTRY = _OnnxPluginRegistry() | ||
| 83 | + | ||
| 84 | + | ||
| 85 | +def register_onnx_plugin( | ||
| 86 | + descriptor: OnnxPluginDescriptor, | ||
| 87 | +) -> OnnxPluginDescriptor: | ||
| 88 | + return _ONNX_PLUGIN_REGISTRY.register(descriptor) | ||
| 89 | + | ||
| 90 | + | ||
| 91 | +def clear_registered_onnx_plugins() -> None: | ||
| 92 | + _ONNX_PLUGIN_REGISTRY.clear() | ||
| 93 | + | ||
| 94 | + | ||
| 95 | +def get_registered_onnx_plugins() -> List[OnnxPluginDescriptor]: | ||
| 96 | + return _ONNX_PLUGIN_REGISTRY.get_all() | ||
| 97 | + | ||
| 98 | + | ||
| 99 | +def get_registered_onnx_plugin_dicts() -> List[dict]: | ||
| 100 | + return [item.to_bridge_dict() for item in get_registered_onnx_plugins()] | ||
| 101 | + | ||
| 102 | + | ||
| 103 | +def get_registered_onnx_plugin_by_origin_type( | ||
| 104 | + origin_type: str, | ||
| 105 | +) -> Optional[OnnxPluginDescriptor]: | ||
| 106 | + return _ONNX_PLUGIN_REGISTRY.get_by_origin_type(origin_type) | ||
| @@ -0,0 +1,230 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# -*- coding: utf-8 -*- | ||
| 3 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 4 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 5 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 6 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 7 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 8 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 9 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 10 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 11 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 12 | + | ||
| 13 | +"""Contract tests for ONNX Plugin discovery and shared module loading.""" | ||
| 14 | + | ||
| 15 | +import os | ||
| 16 | +import textwrap | ||
| 17 | +from pathlib import Path | ||
| 18 | + | ||
| 19 | +import pytest | ||
| 20 | + | ||
| 21 | +from ge.custom_op import ( | ||
| 22 | + clear_registered_op_impls, | ||
| 23 | + get_registered_op_impls, | ||
| 24 | +) | ||
| 25 | +from ge.custom_op.bootstrap import load_custom_op_plugins | ||
| 26 | +from ge.onnx_plugin import bootstrap | ||
| 27 | +from ge.onnx_plugin._bridge import load_and_get_onnx_plugin_descriptors | ||
| 28 | +from ge.onnx_plugin.registry import ( | ||
| 29 | + clear_registered_onnx_plugins, | ||
| 30 | + get_registered_onnx_plugins, | ||
| 31 | +) | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def _write_onnx_plugin(path: Path, source: str) -> Path: | ||
| 35 | + path.write_text( | ||
| 36 | + textwrap.dedent(f""" | ||
| 37 | + from ge.onnx_plugin import onnx_plugin | ||
| 38 | + | ||
| 39 | + plugin = onnx_plugin( | ||
| 40 | + source="{source}", domain="test.domain", opsets=(1,), target="Target" | ||
| 41 | + ) | ||
| 42 | + | ||
| 43 | + | ||
| 44 | + def parse_source(node, target): | ||
| 45 | + del node, target | ||
| 46 | + """).strip() | ||
| 47 | + + "\n", | ||
| 48 | + encoding="utf-8", | ||
| 49 | + ) | ||
| 50 | + return path | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +def _write_custom_op(path: Path, op_type: str) -> Path: | ||
| 54 | + path.write_text( | ||
| 55 | + textwrap.dedent(f""" | ||
| 56 | + from ge.custom_op import EagerExecuteOp, register_op_impl | ||
| 57 | + | ||
| 58 | + | ||
| 59 | + class SeparateCustomOp(EagerExecuteOp): | ||
| 60 | + def execute(self, ctx): | ||
| 61 | + del ctx | ||
| 62 | + """).strip() | ||
| 63 | + + "\n", | ||
| 64 | + encoding="utf-8", | ||
| 65 | + ) | ||
| 66 | + return path | ||
| 67 | + | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +def clear_registries(monkeypatch): | ||
| 71 | + monkeypatch.delenv(bootstrap.ENV_PY_ONNX_PLUGIN_PATH, raising=False) | ||
| 72 | + clear_registered_onnx_plugins() | ||
| 73 | + clear_registered_op_impls() | ||
| 74 | + yield | ||
| 75 | + clear_registered_onnx_plugins() | ||
| 76 | + clear_registered_op_impls() | ||
| 77 | + | ||
| 78 | + | ||
| 79 | +def test_load_onnx_plugin_from_python_file(tmp_path, monkeypatch): | ||
| 80 | + module_path = _write_onnx_plugin(tmp_path / "file_plugin.py", "FileSource") | ||
| 81 | + monkeypatch.setenv(bootstrap.ENV_PY_ONNX_PLUGIN_PATH, str(module_path)) | ||
| 82 | + | ||
| 83 | + modules = bootstrap.load_onnx_plugins() | ||
| 84 | + | ||
| 85 | + assert len(modules) == 1 | ||
| 86 | + assert [item.source for item in get_registered_onnx_plugins()] == ["FileSource"] | ||
| 87 | + | ||
| 88 | + | ||
| 89 | +def test_bridge_loads_and_collects_descriptor_dicts(tmp_path, monkeypatch): | ||
| 90 | + module_path = _write_onnx_plugin(tmp_path / "bridge_plugin.py", "BridgeSource") | ||
| 91 | + monkeypatch.setenv(bootstrap.ENV_PY_ONNX_PLUGIN_PATH, str(module_path)) | ||
| 92 | + | ||
| 93 | + descriptors = load_and_get_onnx_plugin_descriptors() | ||
| 94 | + | ||
| 95 | + assert len(descriptors) == 1 | ||
| 96 | + assert descriptors[0]["source"] == "BridgeSource" | ||
| 97 | + assert descriptors[0]["origin_types"] == ["test.domain::1::BridgeSource"] | ||
| 98 | + | ||
| 99 | + | ||
| 100 | +def test_load_onnx_plugins_from_directory(tmp_path, monkeypatch): | ||
| 101 | + _write_onnx_plugin(tmp_path / "plugin_a.py", "SourceA") | ||
| 102 | + _write_onnx_plugin(tmp_path / "plugin_b.py", "SourceB") | ||
| 103 | + monkeypatch.setenv(bootstrap.ENV_PY_ONNX_PLUGIN_PATH, str(tmp_path)) | ||
| 104 | + | ||
| 105 | + modules = bootstrap.load_onnx_plugins() | ||
| 106 | + | ||
| 107 | + assert len(modules) == 2 | ||
| 108 | + assert sorted(item.source for item in get_registered_onnx_plugins()) == [ | ||
| 109 | + "SourceA", | ||
| 110 | + "SourceB", | ||
| 111 | + ] | ||
| 112 | + | ||
| 113 | + | ||
| 114 | +def test_load_onnx_plugin_package_from_directory(tmp_path, monkeypatch): | ||
| 115 | + package_dir = tmp_path / "plugin_package" | ||
| 116 | + package_dir.mkdir() | ||
| 117 | + _write_onnx_plugin(package_dir / "__init__.py", "PackageSource") | ||
| 118 | + monkeypatch.setenv(bootstrap.ENV_PY_ONNX_PLUGIN_PATH, str(tmp_path)) | ||
| 119 | + | ||
| 120 | + modules = bootstrap.load_onnx_plugins() | ||
| 121 | + | ||
| 122 | + assert len(modules) == 1 | ||
| 123 | + assert [item.source for item in get_registered_onnx_plugins()] == ["PackageSource"] | ||
| 124 | + | ||
| 125 | + | ||
| 126 | +def test_canonical_file_is_imported_once_for_alias_paths(tmp_path, monkeypatch): | ||
| 127 | + module_path = _write_onnx_plugin(tmp_path / "alias_plugin.py", "AliasSource") | ||
| 128 | + link_path = tmp_path / "alias_link.py" | ||
| 129 | + link_path.symlink_to(module_path) | ||
| 130 | + monkeypatch.setenv( | ||
| 131 | + bootstrap.ENV_PY_ONNX_PLUGIN_PATH, | ||
| 132 | + os.pathsep.join((str(module_path), str(link_path))), | ||
| 133 | + ) | ||
| 134 | + | ||
| 135 | + modules = bootstrap.load_onnx_plugins() | ||
| 136 | + | ||
| 137 | + assert len(modules) == 1 | ||
| 138 | + assert len(get_registered_onnx_plugins()) == 1 | ||
| 139 | + | ||
| 140 | + | ||
| 141 | +def test_canonical_package_is_imported_once_for_symlink_alias(tmp_path, monkeypatch): | ||
| 142 | + package_dir = tmp_path / "canonical_package" | ||
| 143 | + package_dir.mkdir() | ||
| 144 | + _write_onnx_plugin(package_dir / "__init__.py", "CanonicalPackageSource") | ||
| 145 | + (tmp_path / "canonical_package_alias").symlink_to( | ||
| 146 | + package_dir, target_is_directory=True | ||
| 147 | + ) | ||
| 148 | + monkeypatch.setenv(bootstrap.ENV_PY_ONNX_PLUGIN_PATH, str(tmp_path)) | ||
| 149 | + | ||
| 150 | + modules = bootstrap.load_onnx_plugins() | ||
| 151 | + | ||
| 152 | + assert len(modules) == 1 | ||
| 153 | + assert [item.source for item in get_registered_onnx_plugins()] == [ | ||
| 154 | + "CanonicalPackageSource" | ||
| 155 | + ] | ||
| 156 | + | ||
| 157 | + | ||
| 158 | +def test_shared_path_is_imported_once_across_plugin_kinds(tmp_path, monkeypatch): | ||
| 159 | + module_path = tmp_path / "mixed_plugin.py" | ||
| 160 | + module_path.write_text( | ||
| 161 | + textwrap.dedent(""" | ||
| 162 | + from ge.custom_op import EagerExecuteOp, register_op_impl | ||
| 163 | + from ge.onnx_plugin import onnx_plugin | ||
| 164 | + | ||
| 165 | + | ||
| 166 | + class MixedCustom(EagerExecuteOp): | ||
| 167 | + def execute(self, ctx): | ||
| 168 | + del ctx | ||
| 169 | + | ||
| 170 | + plugin = onnx_plugin( | ||
| 171 | + source="MixedSource", domain="test.domain", opsets=(1,), target="Target" | ||
| 172 | + ) | ||
| 173 | + | ||
| 174 | + | ||
| 175 | + def parse_source(node, target): | ||
| 176 | + del node, target | ||
| 177 | + """).strip() | ||
| 178 | + + "\n", | ||
| 179 | + encoding="utf-8", | ||
| 180 | + ) | ||
| 181 | + monkeypatch.setenv(bootstrap.ENV_PY_ONNX_PLUGIN_PATH, str(module_path)) | ||
| 182 | + | ||
| 183 | + custom_modules = load_custom_op_plugins() | ||
| 184 | + onnx_modules = bootstrap.load_onnx_plugins() | ||
| 185 | + | ||
| 186 | + assert custom_modules == onnx_modules | ||
| 187 | + assert len(get_registered_onnx_plugins()) == 1 | ||
| 188 | + | ||
| 189 | + | ||
| 190 | + | ||
| 191 | +def test_separate_custom_and_onnx_files_share_canonical_modules( | ||
| 192 | + tmp_path, monkeypatch, path_value | ||
| 193 | +): | ||
| 194 | + custom_path = _write_custom_op(tmp_path / "custom_plugin.py", "SeparateCustom") | ||
| 195 | + onnx_path = _write_onnx_plugin(tmp_path / "onnx_plugin.py", "SeparateSource") | ||
| 196 | + if path_value == "directory": | ||
| 197 | + configured_path = str(tmp_path) | ||
| 198 | + else: | ||
| 199 | + configured_path = os.pathsep.join((str(custom_path), str(onnx_path))) | ||
| 200 | + monkeypatch.setenv(bootstrap.ENV_PY_ONNX_PLUGIN_PATH, configured_path) | ||
| 201 | + | ||
| 202 | + custom_modules = load_custom_op_plugins() | ||
| 203 | + onnx_modules = bootstrap.load_onnx_plugins() | ||
| 204 | + | ||
| 205 | + assert { | ||
| 206 | + Path(getattr(module, "__file__", "")).name for module in custom_modules | ||
| 207 | + } == { | ||
| 208 | + "custom_plugin.py", | ||
| 209 | + "onnx_plugin.py", | ||
| 210 | + } | ||
| 211 | + assert {Path(getattr(module, "__file__", "")).name for module in onnx_modules} == { | ||
| 212 | + "custom_plugin.py", | ||
| 213 | + "onnx_plugin.py", | ||
| 214 | + } | ||
| 215 | + assert {id(module) for module in custom_modules} == { | ||
| 216 | + id(module) for module in onnx_modules | ||
| 217 | + } | ||
| 218 | + assert [item.op_type for item in get_registered_op_impls()] == ["SeparateCustom"] | ||
| 219 | + assert [item.source for item in get_registered_onnx_plugins()] == ["SeparateSource"] | ||
| 220 | + | ||
| 221 | + | ||
| 222 | +def test_load_onnx_plugins_rejects_missing_path(monkeypatch): | ||
| 223 | + monkeypatch.setenv( | ||
| 224 | + bootstrap.ENV_PY_ONNX_PLUGIN_PATH, "/path/not/exist/onnx_plugin.py" | ||
| 225 | + ) | ||
| 226 | + | ||
| 227 | + with pytest.raises( | ||
| 228 | + FileNotFoundError, match="python ONNX Plugin path does not exist" | ||
| 229 | + ): | ||
| 230 | + bootstrap.load_onnx_plugins() | ||
| @@ -0,0 +1,218 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# -*- coding: utf-8 -*- | ||
| 3 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 4 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 5 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 6 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 7 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 8 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 9 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 10 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 11 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 12 | + | ||
| 13 | +"""Contract tests for ONNX Plugin Python callback dispatch.""" | ||
| 14 | + | ||
| 15 | +import pytest | ||
| 16 | + | ||
| 17 | +import ge.graph as graph_api | ||
| 18 | +import ge.onnx_plugin as onnx_plugin_api | ||
| 19 | +from ge.graph import Operator | ||
| 20 | +from ge.onnx_plugin import OnnxNode, onnx_plugin | ||
| 21 | +from ge.onnx_plugin._bridge import call_parse_node | ||
| 22 | +from ge.onnx_plugin.registry import clear_registered_onnx_plugins | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class _FakeOperatorBackend: | ||
| 26 | + def __init__(self): | ||
| 27 | + self.attrs = {} | ||
| 28 | + self.dynamic_inputs = [] | ||
| 29 | + self.invalidated = False | ||
| 30 | + | ||
| 31 | + | ||
| 32 | + def get_name(): | ||
| 33 | + return "target" | ||
| 34 | + | ||
| 35 | + | ||
| 36 | + def get_type(): | ||
| 37 | + return "TargetOp" | ||
| 38 | + | ||
| 39 | + | ||
| 40 | + def register_dynamic_output(name, count): | ||
| 41 | + del name, count | ||
| 42 | + | ||
| 43 | + def set_attr(self, name, value): | ||
| 44 | + self.attrs[name] = value | ||
| 45 | + | ||
| 46 | + def register_dynamic_input(self, name, count): | ||
| 47 | + self.dynamic_inputs.append((name, count)) | ||
| 48 | + | ||
| 49 | + def invalidate(self): | ||
| 50 | + self.invalidated = True | ||
| 51 | + | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +def clear_registry(): | ||
| 55 | + clear_registered_onnx_plugins() | ||
| 56 | + yield | ||
| 57 | + clear_registered_onnx_plugins() | ||
| 58 | + | ||
| 59 | + | ||
| 60 | +def _node_values(origin_type="test.domain::1::Source"): | ||
| 61 | + return { | ||
| 62 | + "name": "source", | ||
| 63 | + "origin_type": origin_type, | ||
| 64 | + "inputs": ["x0", "x1"], | ||
| 65 | + "outputs": ["y"], | ||
| 66 | + "attrs": {"alpha": 0.5}, | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +def test_public_exports_match_pr1_support_matrix(): | ||
| 71 | + assert onnx_plugin_api.__all__ == ["OnnxNode", "OnnxPlugin", "onnx_plugin"] | ||
| 72 | + assert "Operator" in graph_api.__all__ | ||
| 73 | + | ||
| 74 | + | ||
| 75 | +def test_unsupported_pr1_interfaces_are_not_exposed(): | ||
| 76 | + for name in ("decompose", "reset", "reload"): | ||
| 77 | + assert not hasattr(onnx_plugin_api.OnnxPlugin, name) | ||
| 78 | + for name in ( | ||
| 79 | + "get_attr", | ||
| 80 | + "register_input", | ||
| 81 | + "register_optional_input", | ||
| 82 | + "register_output", | ||
| 83 | + "update_input_desc", | ||
| 84 | + "update_output_desc", | ||
| 85 | + ): | ||
| 86 | + assert not hasattr(Operator, name) | ||
| 87 | + | ||
| 88 | + | ||
| 89 | +def test_elu_and_sum_equivalent_callbacks(): | ||
| 90 | + elu = onnx_plugin( | ||
| 91 | + source="EluSource", domain="test.domain", opsets=(1,), target="EluTarget" | ||
| 92 | + ) | ||
| 93 | + sum_plugin = onnx_plugin( | ||
| 94 | + source="SumSource", domain="test.domain", opsets=(1,), target="SumTarget" | ||
| 95 | + ) | ||
| 96 | + | ||
| 97 | + | ||
| 98 | + def parse_elu(node, target): | ||
| 99 | + target.set_attr("alpha", node.attrs.get("alpha", 1.0)) | ||
| 100 | + | ||
| 101 | + | ||
| 102 | + def parse_sum(node, target): | ||
| 103 | + count = len(node.inputs) | ||
| 104 | + if count == 0: | ||
| 105 | + raise ValueError("Sum requires at least one input") | ||
| 106 | + target.register_dynamic_input("x", count) | ||
| 107 | + target.set_attr("N", count) | ||
| 108 | + | ||
| 109 | + elu_backend = _FakeOperatorBackend() | ||
| 110 | + call_parse_node( | ||
| 111 | + "test.domain::1::EluSource", | ||
| 112 | + { | ||
| 113 | + "name": "elu", | ||
| 114 | + "origin_type": "test.domain::1::EluSource", | ||
| 115 | + "inputs": ["x"], | ||
| 116 | + "outputs": ["y"], | ||
| 117 | + "attrs": {}, | ||
| 118 | + }, | ||
| 119 | + elu_backend, | ||
| 120 | + ) | ||
| 121 | + sum_backend = _FakeOperatorBackend() | ||
| 122 | + call_parse_node( | ||
| 123 | + "test.domain::1::SumSource", | ||
| 124 | + { | ||
| 125 | + "name": "sum", | ||
| 126 | + "origin_type": "test.domain::1::SumSource", | ||
| 127 | + "inputs": ["x0", "x1", "x2"], | ||
| 128 | + "outputs": ["y"], | ||
| 129 | + "attrs": {}, | ||
| 130 | + }, | ||
| 131 | + sum_backend, | ||
| 132 | + ) | ||
| 133 | + | ||
| 134 | + assert elu_backend.attrs == {"alpha": 1.0} | ||
| 135 | + assert sum_backend.attrs == {"N": 3} | ||
| 136 | + assert sum_backend.dynamic_inputs == [("x", 3)] | ||
| 137 | + assert elu_backend.invalidated is True | ||
| 138 | + assert sum_backend.invalidated is True | ||
| 139 | + | ||
| 140 | + | ||
| 141 | +def test_call_parse_node_dispatches_objects_and_mutations(): | ||
| 142 | + plugin = onnx_plugin( | ||
| 143 | + source="Source", domain="test.domain", opsets=(1,), target="TargetOp" | ||
| 144 | + ) | ||
| 145 | + seen = {} | ||
| 146 | + | ||
| 147 | + | ||
| 148 | + def parse_source(node, target): | ||
| 149 | + seen["node"] = node | ||
| 150 | + seen["target"] = target | ||
| 151 | + target.set_attr("alpha", node.attrs["alpha"]) | ||
| 152 | + target.set_attr("N", len(node.inputs)) | ||
| 153 | + target.register_dynamic_input("x", len(node.inputs)) | ||
| 154 | + | ||
| 155 | + backend = _FakeOperatorBackend() | ||
| 156 | + result = call_parse_node("test.domain::1::Source", _node_values(), backend) | ||
| 157 | + | ||
| 158 | + assert result is None | ||
| 159 | + assert isinstance(seen["node"], OnnxNode) | ||
| 160 | + assert isinstance(seen["target"], Operator) | ||
| 161 | + assert seen["node"].origin_type == "test.domain::1::Source" | ||
| 162 | + assert backend.attrs == {"alpha": 0.5, "N": 2} | ||
| 163 | + assert backend.dynamic_inputs == [("x", 2)] | ||
| 164 | + assert backend.invalidated is True | ||
| 165 | + with pytest.raises(RuntimeError, match="only valid inside parse_node"): | ||
| 166 | + _ = seen["target"].name | ||
| 167 | + | ||
| 168 | + | ||
| 169 | +def test_call_parse_node_rejects_unknown_origin_without_creating_operator(): | ||
| 170 | + backend = _FakeOperatorBackend() | ||
| 171 | + | ||
| 172 | + with pytest.raises(KeyError, match="not registered.*test.domain::1::Missing"): | ||
| 173 | + call_parse_node( | ||
| 174 | + "test.domain::1::Missing", | ||
| 175 | + _node_values("test.domain::1::Missing"), | ||
| 176 | + backend, | ||
| 177 | + ) | ||
| 178 | + | ||
| 179 | + assert backend.invalidated is False | ||
| 180 | + | ||
| 181 | + | ||
| 182 | +def test_call_parse_node_invalidates_operator_when_callback_raises(): | ||
| 183 | + plugin = onnx_plugin( | ||
| 184 | + source="Source", domain="test.domain", opsets=(1,), target="TargetOp" | ||
| 185 | + ) | ||
| 186 | + seen = {} | ||
| 187 | + | ||
| 188 | + | ||
| 189 | + def parse_source(node, target): | ||
| 190 | + del node | ||
| 191 | + seen["target"] = target | ||
| 192 | + raise LookupError("callback failed") | ||
| 193 | + | ||
| 194 | + backend = _FakeOperatorBackend() | ||
| 195 | + with pytest.raises(LookupError, match="callback failed"): | ||
| 196 | + call_parse_node("test.domain::1::Source", _node_values(), backend) | ||
| 197 | + | ||
| 198 | + assert backend.invalidated is True | ||
| 199 | + with pytest.raises(RuntimeError, match="only valid inside parse_node"): | ||
| 200 | + seen["target"].set_attr("N", 1) | ||
| 201 | + | ||
| 202 | + | ||
| 203 | + | ||
| 204 | +def test_call_parse_node_rejects_non_none_return_and_invalidates(return_value): | ||
| 205 | + plugin = onnx_plugin( | ||
| 206 | + source="Source", domain="test.domain", opsets=(1,), target="TargetOp" | ||
| 207 | + ) | ||
| 208 | + | ||
| 209 | + | ||
| 210 | + def parse_source(node, target): | ||
| 211 | + del node, target | ||
| 212 | + return return_value | ||
| 213 | + | ||
| 214 | + backend = _FakeOperatorBackend() | ||
| 215 | + with pytest.raises(TypeError, match="must return None"): | ||
| 216 | + call_parse_node("test.domain::1::Source", _node_values(), backend) | ||
| 217 | + | ||
| 218 | + assert backend.invalidated is True | ||
| @@ -0,0 +1,150 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# -*- coding: utf-8 -*- | ||
| 3 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 4 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 5 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 6 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 7 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 8 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 9 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 10 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 11 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 12 | + | ||
| 13 | +"""Contract tests for ONNX Plugin Python source and target objects.""" | ||
| 14 | + | ||
| 15 | +import copy | ||
| 16 | + | ||
| 17 | +import pytest | ||
| 18 | + | ||
| 19 | +from ge.graph import Operator | ||
| 20 | +from ge.graph.operator import create_operator | ||
| 21 | +from ge.onnx_plugin import OnnxNode | ||
| 22 | +from ge.onnx_plugin.onnx_node import create_onnx_node | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class _FakeOperatorBackend: | ||
| 26 | + def __init__(self): | ||
| 27 | + self.attrs = {} | ||
| 28 | + self.dynamic_inputs = [] | ||
| 29 | + self.dynamic_outputs = [] | ||
| 30 | + self.invalidated = False | ||
| 31 | + | ||
| 32 | + | ||
| 33 | + def get_name(): | ||
| 34 | + return "target_node" | ||
| 35 | + | ||
| 36 | + | ||
| 37 | + def get_type(): | ||
| 38 | + return "TargetOp" | ||
| 39 | + | ||
| 40 | + def set_attr(self, name, value): | ||
| 41 | + self.attrs[name] = value | ||
| 42 | + | ||
| 43 | + def register_dynamic_input(self, name, count): | ||
| 44 | + self.dynamic_inputs.append((name, count)) | ||
| 45 | + | ||
| 46 | + def register_dynamic_output(self, name, count): | ||
| 47 | + self.dynamic_outputs.append((name, count)) | ||
| 48 | + | ||
| 49 | + def invalidate(self): | ||
| 50 | + self.invalidated = True | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +def test_onnx_node_exposes_immutable_flattened_values(): | ||
| 54 | + attrs = {"alpha": 1.0, "axis": 1} | ||
| 55 | + node = create_onnx_node( | ||
| 56 | + name="elu", | ||
| 57 | + origin_type="ai.onnx::13::Elu", | ||
| 58 | + inputs=["x", ""], | ||
| 59 | + outputs=["y"], | ||
| 60 | + attrs=attrs, | ||
| 61 | + ) | ||
| 62 | + attrs["alpha"] = 2.0 | ||
| 63 | + | ||
| 64 | + assert node.name == "elu" | ||
| 65 | + assert node.origin_type == "ai.onnx::13::Elu" | ||
| 66 | + assert node.inputs == ("x", "") | ||
| 67 | + assert node.outputs == ("y",) | ||
| 68 | + assert dict(node.attrs) == {"alpha": 1.0, "axis": 1} | ||
| 69 | + | ||
| 70 | + with pytest.raises(AttributeError, match="read-only"): | ||
| 71 | + node.name = "changed" | ||
| 72 | + with pytest.raises(TypeError): | ||
| 73 | + node.attrs["alpha"] = 3.0 | ||
| 74 | + | ||
| 75 | + | ||
| 76 | +def test_onnx_node_cannot_be_created_by_plugin_author(): | ||
| 77 | + with pytest.raises(RuntimeError, match="should not be created directly"): | ||
| 78 | + OnnxNode() | ||
| 79 | + | ||
| 80 | + | ||
| 81 | + | ||
| 82 | +def test_onnx_node_rejects_unsupported_attribute_values(value): | ||
| 83 | + with pytest.raises(TypeError, match="only supports int and float"): | ||
| 84 | + create_onnx_node( | ||
| 85 | + name="node", | ||
| 86 | + origin_type="test.domain::1::Source", | ||
| 87 | + inputs=[], | ||
| 88 | + outputs=[], | ||
| 89 | + attrs={"value": value}, | ||
| 90 | + ) | ||
| 91 | + | ||
| 92 | + | ||
| 93 | +def test_operator_mutates_callback_backend(): | ||
| 94 | + backend = _FakeOperatorBackend() | ||
| 95 | + target = create_operator(backend) | ||
| 96 | + | ||
| 97 | + assert target.name == "target_node" | ||
| 98 | + assert target.type == "TargetOp" | ||
| 99 | + | ||
| 100 | + target.set_attr("alpha", 1.0) | ||
| 101 | + target.set_attr("N", 2) | ||
| 102 | + target.register_dynamic_input("x", 2) | ||
| 103 | + target.register_dynamic_output("y", 1) | ||
| 104 | + | ||
| 105 | + assert backend.attrs == {"alpha": 1.0, "N": 2} | ||
| 106 | + assert backend.dynamic_inputs == [("x", 2)] | ||
| 107 | + assert backend.dynamic_outputs == [("y", 1)] | ||
| 108 | + | ||
| 109 | + | ||
| 110 | +def test_operator_cannot_be_created_or_copied_by_plugin_author(): | ||
| 111 | + with pytest.raises(RuntimeError, match="should not be created directly"): | ||
| 112 | + Operator() | ||
| 113 | + | ||
| 114 | + target = create_operator(_FakeOperatorBackend()) | ||
| 115 | + with pytest.raises(RuntimeError, match="does not support copy"): | ||
| 116 | + copy.copy(target) | ||
| 117 | + with pytest.raises(RuntimeError, match="does not support deepcopy"): | ||
| 118 | + copy.deepcopy(target) | ||
| 119 | + | ||
| 120 | + | ||
| 121 | + | ||
| 122 | +def test_operator_rejects_unsupported_attribute_values(value): | ||
| 123 | + target = create_operator(_FakeOperatorBackend()) | ||
| 124 | + | ||
| 125 | + with pytest.raises(TypeError, match="only supports int and float"): | ||
| 126 | + target.set_attr("value", value) | ||
| 127 | + | ||
| 128 | + | ||
| 129 | + | ||
| 130 | +def test_operator_rejects_integer_attribute_outside_int64(value): | ||
| 131 | + target = create_operator(_FakeOperatorBackend()) | ||
| 132 | + | ||
| 133 | + with pytest.raises(ValueError, match="int64 range"): | ||
| 134 | + target.set_attr("value", value) | ||
| 135 | + | ||
| 136 | + | ||
| 137 | + | ||
| 138 | + ("count", "exception"), | ||
| 139 | + [ | ||
| 140 | + (True, TypeError), | ||
| 141 | + (1.0, TypeError), | ||
| 142 | + (-1, ValueError), | ||
| 143 | + (1 << 32, ValueError), | ||
| 144 | + ], | ||
| 145 | +) | ||
| 146 | +def test_operator_validates_dynamic_port_count(count, exception): | ||
| 147 | + target = create_operator(_FakeOperatorBackend()) | ||
| 148 | + | ||
| 149 | + with pytest.raises(exception, match="count"): | ||
| 150 | + target.register_dynamic_input("x", count) | ||
| @@ -0,0 +1,233 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# -*- coding: utf-8 -*- | ||
| 3 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 4 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 5 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 6 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 7 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 8 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 9 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 10 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 11 | +# ----------------------------------------------------------------------------------------------------------- | ||
| 12 | + | ||
| 13 | +"""Contract tests for ONNX Plugin descriptors and Python registry.""" | ||
| 14 | + | ||
| 15 | +from dataclasses import FrozenInstanceError | ||
| 16 | +import pytest | ||
| 17 | + | ||
| 18 | +from ge.onnx_plugin import OnnxPlugin, onnx_plugin | ||
| 19 | +from ge.onnx_plugin.registry import ( | ||
| 20 | + clear_registered_onnx_plugins, | ||
| 21 | + get_registered_onnx_plugin_by_origin_type, | ||
| 22 | + get_registered_onnx_plugin_dicts, | ||
| 23 | + get_registered_onnx_plugins, | ||
| 24 | +) | ||
| 25 | + | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +def clear_registry(): | ||
| 29 | + clear_registered_onnx_plugins() | ||
| 30 | + yield | ||
| 31 | + clear_registered_onnx_plugins() | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def test_onnx_plugin_binds_parse_node_and_expands_normalized_opsets(): | ||
| 35 | + plugin = onnx_plugin( | ||
| 36 | + source="Elu", | ||
| 37 | + domain="ai.onnx", | ||
| 38 | + opsets=[13, 11, 13], | ||
| 39 | + target="EluTarget", | ||
| 40 | + ) | ||
| 41 | + | ||
| 42 | + assert isinstance(plugin, OnnxPlugin) | ||
| 43 | + assert get_registered_onnx_plugins() == [] | ||
| 44 | + | ||
| 45 | + def parse_elu(node, target): | ||
| 46 | + del node, target | ||
| 47 | + | ||
| 48 | + decorated = plugin.parse_node(parse_elu) | ||
| 49 | + | ||
| 50 | + assert decorated is parse_elu | ||
| 51 | + descriptor = parse_elu.__ge_onnx_plugin_descriptor__ | ||
| 52 | + assert descriptor.source == "Elu" | ||
| 53 | + assert descriptor.domain == "ai.onnx" | ||
| 54 | + assert descriptor.opsets == (11, 13) | ||
| 55 | + assert descriptor.target == "EluTarget" | ||
| 56 | + assert descriptor.origin_types == ( | ||
| 57 | + "ai.onnx::11::Elu", | ||
| 58 | + "ai.onnx::13::Elu", | ||
| 59 | + ) | ||
| 60 | + assert descriptor.parser_node is parse_elu | ||
| 61 | + assert get_registered_onnx_plugins() == [descriptor] | ||
| 62 | + assert get_registered_onnx_plugin_dicts() == [descriptor.to_bridge_dict()] | ||
| 63 | + assert descriptor.to_bridge_dict() == { | ||
| 64 | + "descriptor_key": descriptor.descriptor_key, | ||
| 65 | + "source": "Elu", | ||
| 66 | + "domain": "ai.onnx", | ||
| 67 | + "opsets": [11, 13], | ||
| 68 | + "target": "EluTarget", | ||
| 69 | + "origin_types": ["ai.onnx::11::Elu", "ai.onnx::13::Elu"], | ||
| 70 | + "module_name": __name__, | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +def test_descriptor_is_frozen(): | ||
| 75 | + plugin = onnx_plugin( | ||
| 76 | + source="Source", | ||
| 77 | + domain="test.domain", | ||
| 78 | + opsets=(1,), | ||
| 79 | + target="Target", | ||
| 80 | + ) | ||
| 81 | + | ||
| 82 | + | ||
| 83 | + def parse_source(node, target): | ||
| 84 | + del node, target | ||
| 85 | + | ||
| 86 | + with pytest.raises(FrozenInstanceError): | ||
| 87 | + parse_source.__ge_onnx_plugin_descriptor__.target = "Changed" | ||
| 88 | + | ||
| 89 | + | ||
| 90 | +def test_disjoint_opsets_can_share_source_domain_and_target(): | ||
| 91 | + first = onnx_plugin( | ||
| 92 | + source="Source", | ||
| 93 | + domain="test.domain", | ||
| 94 | + opsets=(1, 2), | ||
| 95 | + target="Target", | ||
| 96 | + ) | ||
| 97 | + second = onnx_plugin( | ||
| 98 | + source="Source", | ||
| 99 | + domain="test.domain", | ||
| 100 | + opsets=(3,), | ||
| 101 | + target="Target", | ||
| 102 | + ) | ||
| 103 | + | ||
| 104 | + | ||
| 105 | + def parse_legacy(node, target): | ||
| 106 | + del node, target | ||
| 107 | + | ||
| 108 | + | ||
| 109 | + def parse_modern(node, target): | ||
| 110 | + del node, target | ||
| 111 | + | ||
| 112 | + assert len(get_registered_onnx_plugins()) == 2 | ||
| 113 | + assert ( | ||
| 114 | + get_registered_onnx_plugin_by_origin_type("test.domain::3::Source").parser_node | ||
| 115 | + is parse_modern | ||
| 116 | + ) | ||
| 117 | + | ||
| 118 | + | ||
| 119 | +def test_overlapping_origin_registration_is_atomic(): | ||
| 120 | + first = onnx_plugin( | ||
| 121 | + source="Source", | ||
| 122 | + domain="test.domain", | ||
| 123 | + opsets=(2,), | ||
| 124 | + target="TargetA", | ||
| 125 | + ) | ||
| 126 | + overlapping = onnx_plugin( | ||
| 127 | + source="Source", | ||
| 128 | + domain="test.domain", | ||
| 129 | + opsets=(1, 2), | ||
| 130 | + target="TargetB", | ||
| 131 | + ) | ||
| 132 | + | ||
| 133 | + | ||
| 134 | + def parse_first(node, target): | ||
| 135 | + del node, target | ||
| 136 | + | ||
| 137 | + with pytest.raises( | ||
| 138 | + ValueError, match="origin type already exists.*test.domain::2::Source" | ||
| 139 | + ): | ||
| 140 | + | ||
| 141 | + | ||
| 142 | + def parse_overlapping(node, target): | ||
| 143 | + del node, target | ||
| 144 | + | ||
| 145 | + assert len(get_registered_onnx_plugins()) == 1 | ||
| 146 | + assert get_registered_onnx_plugin_by_origin_type("test.domain::1::Source") is None | ||
| 147 | + assert ( | ||
| 148 | + get_registered_onnx_plugin_by_origin_type("test.domain::2::Source").parser_node | ||
| 149 | + is parse_first | ||
| 150 | + ) | ||
| 151 | + | ||
| 152 | + | ||
| 153 | +def test_parse_node_can_only_be_bound_once(): | ||
| 154 | + plugin = onnx_plugin( | ||
| 155 | + source="Source", | ||
| 156 | + domain="test.domain", | ||
| 157 | + opsets=(1,), | ||
| 158 | + target="Target", | ||
| 159 | + ) | ||
| 160 | + | ||
| 161 | + | ||
| 162 | + def parse_first(node, target): | ||
| 163 | + del node, target | ||
| 164 | + | ||
| 165 | + with pytest.raises(ValueError, match="parse_node is already bound"): | ||
| 166 | + | ||
| 167 | + | ||
| 168 | + def parse_second(node, target): | ||
| 169 | + del node, target | ||
| 170 | + | ||
| 171 | + assert len(get_registered_onnx_plugins()) == 1 | ||
| 172 | + | ||
| 173 | + | ||
| 174 | + | ||
| 175 | + ("field", "value"), | ||
| 176 | + [ | ||
| 177 | + ("source", None), | ||
| 178 | + ("source", ""), | ||
| 179 | + ("source", "ai.onnx::Elu"), | ||
| 180 | + ("domain", None), | ||
| 181 | + ("domain", ""), | ||
| 182 | + ("domain", "bad::domain"), | ||
| 183 | + ("target", None), | ||
| 184 | + ("target", ""), | ||
| 185 | + ], | ||
| 186 | +) | ||
| 187 | +def test_onnx_plugin_rejects_invalid_string_fields(field, value): | ||
| 188 | + arguments = { | ||
| 189 | + "source": "Source", | ||
| 190 | + "domain": "test.domain", | ||
| 191 | + "opsets": (1,), | ||
| 192 | + "target": "Target", | ||
| 193 | + } | ||
| 194 | + arguments[field] = value | ||
| 195 | + | ||
| 196 | + with pytest.raises(TypeError, match=field): | ||
| 197 | + onnx_plugin(**arguments) | ||
| 198 | + | ||
| 199 | + | ||
| 200 | + | ||
| 201 | + ("opsets", "exception"), | ||
| 202 | + [ | ||
| 203 | + ((), ValueError), | ||
| 204 | + ((True,), TypeError), | ||
| 205 | + ((0,), ValueError), | ||
| 206 | + ((-1,), ValueError), | ||
| 207 | + ((1.0,), TypeError), | ||
| 208 | + ("1", TypeError), | ||
| 209 | + ((item for item in (1, 2)), TypeError), | ||
| 210 | + ], | ||
| 211 | +) | ||
| 212 | +def test_onnx_plugin_rejects_invalid_opsets(opsets, exception): | ||
| 213 | + with pytest.raises(exception, match="opsets"): | ||
| 214 | + onnx_plugin( | ||
| 215 | + source="Source", | ||
| 216 | + domain="test.domain", | ||
| 217 | + opsets=opsets, | ||
| 218 | + target="Target", | ||
| 219 | + ) | ||
| 220 | + | ||
| 221 | + | ||
| 222 | +def test_parse_node_rejects_non_function_without_registering(): | ||
| 223 | + plugin = onnx_plugin( | ||
| 224 | + source="Source", | ||
| 225 | + domain="test.domain", | ||
| 226 | + opsets=(1,), | ||
| 227 | + target="Target", | ||
| 228 | + ) | ||
| 229 | + | ||
| 230 | + with pytest.raises(TypeError, match="expects a Python function"): | ||
| 231 | + plugin.parse_node(object()) | ||
| 232 | + | ||
| 233 | + assert get_registered_onnx_plugins() == [] | ||


operator不要 用pybind,这个对python版本敏感,当前graph下面的模块都是ctypes实现的python版本无感的,这个要保持