已合并
feat: 增加 ONNX Plugin Python API 基础能力 #4353
feat: 增加 ONNX Plugin Python API 基础能力 #4353
已合并
gentle-knight创建于 13 天前
13 个文件变更+1374-4
Mapi/python/ge/ge/_internal/plugin_loader.py+31-4
@@ -20,7 +20,10 @@ import os
20import sys20import sys
21from pathlib import Path21from pathlib import Path
22from types import ModuleType22from types import ModuleType
23-from typing import Iterable, List23+from typing import Dict, Iterable, List, Optional
24+ 
25+ 
26+_MODULE_NAME_BY_CANONICAL_PATH: Dict[Path, str] = {}
24 27 
25 28 
26def normalize_path_list(path_value: str) -> List[str]:29def 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+ 
32def load_module_from_file(45def load_module_from_file(
33 file_path: Path, *, module_prefix: str, plugin_kind: str46 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] = module59 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 module66 return module
45 67 
46 68 
47def scan_modules_from_path(69def scan_modules_from_path(
48 path_item: str, *, module_prefix: str, plugin_kind: str70 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 continue96 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 modules104 return modules
78 105 
79 106 
Mapi/python/ge/ge/graph/__init__.py+3-0
@@ -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 
29from .graph import DumpFormat, Graph30from .graph import DumpFormat, Graph
30from .node import Node31from .node import Node
32+from .operator import Operator
31from .tensor import Tensor33from .tensor import Tensor
32from .tensor_desc import Shape, TensorDesc34from .tensor_desc import Shape, TensorDesc
33from .types import DataType, Format, Placement35from .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",
Aapi/python/ge/ge/graph/operator.py+97-0
@@ -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:
GengChao
GengChaoGengChao13 天前

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

likedislike
gentle-knight
gentle-knight
12 天前 评论:
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+ @staticmethod
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+ @property
52+ def name(self) -> str:
53+ self._ensure_valid()
54+ return self._handle.get_name()
55+ 
56+ @property
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)
Aapi/python/ge/ge/onnx_plugin/__init__.py+18-0
@@ -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"]
Aapi/python/ge/ge/onnx_plugin/_bridge.py+41-0
@@ -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")
Aapi/python/ge/ge/onnx_plugin/bootstrap.py+31-0
@@ -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+ )
Aapi/python/ge/ge/onnx_plugin/onnx_node.py+114-0
@@ -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:
GengChao
GengChaoGengChao13 天前

onnx_node.py可以用pybind封装为native的so作为模块导入,这样就不需要单独写python文件了

likedislike
gentle-knight
gentle-knight
12 天前 评论:
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+ @staticmethod
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+ @staticmethod
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+ @property
77+ def name(self) -> str:
78+ return self._name
79+ 
80+ @property
81+ def origin_type(self) -> str:
82+ return self._origin_type
83+ 
84+ @property
85+ def inputs(self) -> tuple:
86+ return self._inputs
87+ 
88+ @property
89+ def outputs(self) -> tuple:
90+ return self._outputs
91+ 
92+ @property
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+ )
Aapi/python/ge/ge/onnx_plugin/plugin.py+102-0
@@ -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+ )
Aapi/python/ge/ge/onnx_plugin/registry.py+106-0
@@ -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+@dataclass(frozen=True)
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)
Atests/ge/ut/ge/graph/pyge_tests/python_onnx_plugin_bootstrap_test.py+230-0
@@ -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+ @plugin.parse_node
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+ @register_op_impl(op_type="{op_type}")
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+@pytest.fixture(autouse=True)
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+ @register_op_impl(op_type="MixedCustom")
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+ @plugin.parse_node
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+@pytest.mark.parametrize("path_value", ["directory", "files"])
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()
Atests/ge/ut/ge/graph/pyge_tests/python_onnx_plugin_bridge_test.py+218-0
@@ -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+ @staticmethod
32+ def get_name():
33+ return "target"
34+ 
35+ @staticmethod
36+ def get_type():
37+ return "TargetOp"
38+ 
39+ @staticmethod
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+@pytest.fixture(autouse=True)
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+ @elu.parse_node
98+ def parse_elu(node, target):
99+ target.set_attr("alpha", node.attrs.get("alpha", 1.0))
100+ 
101+ @sum_plugin.parse_node
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+ @plugin.parse_node
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+ @plugin.parse_node
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+@pytest.mark.parametrize("return_value", [False, 0, "", object()])
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+ @plugin.parse_node
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
Atests/ge/ut/ge/graph/pyge_tests/python_onnx_plugin_objects_test.py+150-0
@@ -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+ @staticmethod
33+ def get_name():
34+ return "target_node"
35+ 
36+ @staticmethod
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+@pytest.mark.parametrize("value", [True, "value", [1], None])
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+@pytest.mark.parametrize("value", [True, "value", [1], None])
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+@pytest.mark.parametrize("value", [-(1 << 63) - 1, 1 << 63])
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+@pytest.mark.parametrize(
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)
Atests/ge/ut/ge/graph/pyge_tests/python_onnx_plugin_registry_test.py+233-0
@@ -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+@pytest.fixture(autouse=True)
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+ @plugin.parse_node
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+ @first.parse_node
105+ def parse_legacy(node, target):
106+ del node, target
107+ 
108+ @second.parse_node
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+ @first.parse_node
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+ @overlapping.parse_node
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+ @plugin.parse_node
162+ def parse_first(node, target):
163+ del node, target
164+ 
165+ with pytest.raises(ValueError, match="parse_node is already bound"):
166+ 
167+ @plugin.parse_node
168+ def parse_second(node, target):
169+ del node, target
170+ 
171+ assert len(get_registered_onnx_plugins()) == 1
172+ 
173+ 
174+@pytest.mark.parametrize(
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+@pytest.mark.parametrize(
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() == []