1+ # ---------------------------------------------------------
2+ # Copyright (c) Microsoft Corporation. All rights reserved.
3+ # ---------------------------------------------------------
4+
5+ import functools
6+ import inspect
7+ import logging
8+ import sys
9+ from typing import Callable , Type , TypeVar , Union
10+
11+ from typing_extensions import ParamSpec
12+
13+ DOCSTRING_TEMPLATE = ".. note:: {0} {1}\n \n "
14+ DOCSTRING_DEFAULT_INDENTATION = 8
15+ EXPERIMENTAL_CLASS_MESSAGE = "This is an experimental class,"
16+ EXPERIMENTAL_METHOD_MESSAGE = "This is an experimental method,"
17+ EXPERIMENTAL_FIELD_MESSAGE = "This is an experimental field,"
18+ EXPERIMENTAL_LINK_MESSAGE = (
19+ "and may change at any time. Please see https://aka.ms/azuremlexperimental for more information."
20+ )
21+
22+ _warning_cache = set ()
23+ module_logger = logging .getLogger (__name__ )
24+
25+ TExperimental = TypeVar ("TExperimental" , bound = Union [Type , Callable ])
26+ P = ParamSpec ("P" )
27+ T = TypeVar ("T" )
28+
29+
30+ def experimental (wrapped : TExperimental ) -> TExperimental :
31+ """Add experimental tag to a class or a method.
32+
33+ :param wrapped: Either a Class or Function to mark as experimental
34+ :type wrapped: TExperimental
35+ :return: The wrapped class or method
36+ :rtype: TExperimental
37+ """
38+ if inspect .isclass (wrapped ):
39+ return _add_class_docstring (wrapped )
40+ if inspect .isfunction (wrapped ):
41+ return _add_method_docstring (wrapped )
42+ return wrapped
43+
44+
45+ def _add_class_docstring (cls : Type [T ]) -> Type [T ]:
46+ """Add experimental tag to the class doc string.
47+
48+ :return: The updated class
49+ :rtype: Type[T]
50+ """
51+
52+ P2 = ParamSpec ("P2" )
53+
54+ def _add_class_warning (func : Callable [P2 , None ]) -> Callable [P2 , None ]:
55+ """Add warning message for class __init__.
56+
57+ :param func: The original __init__ function
58+ :type func: Callable[P2, None]
59+ :return: Updated __init__
60+ :rtype: Callable[P2, None]
61+ """
62+
63+ @functools .wraps (func )
64+ def wrapped (* args , ** kwargs ):
65+ message = "Class {0}: {1} {2}" .format (cls .__name__ , EXPERIMENTAL_CLASS_MESSAGE , EXPERIMENTAL_LINK_MESSAGE )
66+ if not _should_skip_warning () and not _is_warning_cached (message ):
67+ module_logger .warning (message )
68+ return func (* args , ** kwargs )
69+
70+ return wrapped
71+
72+ doc_string = DOCSTRING_TEMPLATE .format (EXPERIMENTAL_CLASS_MESSAGE , EXPERIMENTAL_LINK_MESSAGE )
73+ if cls .__doc__ :
74+ cls .__doc__ = _add_note_to_docstring (cls .__doc__ , doc_string )
75+ else :
76+ cls .__doc__ = doc_string + ">"
77+ cls .__init__ = _add_class_warning (cls .__init__ )
78+ return cls
79+
80+
81+ def _add_method_docstring (func : Callable [P , T ] = None ) -> Callable [P , T ]:
82+ """Add experimental tag to the method doc string.
83+
84+ :param func: The function to update
85+ :type func: Callable[P, T]
86+ :return: A wrapped method marked as experimental
87+ :rtype: Callable[P,T]
88+ """
89+ doc_string = DOCSTRING_TEMPLATE .format (EXPERIMENTAL_METHOD_MESSAGE , EXPERIMENTAL_LINK_MESSAGE )
90+ if func .__doc__ :
91+ func .__doc__ = _add_note_to_docstring (func .__doc__ , doc_string )
92+ else :
93+ # '>' is required. Otherwise the note section can't be generated
94+ func .__doc__ = doc_string + ">"
95+
96+ @functools .wraps (func )
97+ def wrapped (* args : P .args , ** kwargs : P .kwargs ) -> T :
98+ message = "Method {0}: {1} {2}" .format (func .__name__ , EXPERIMENTAL_METHOD_MESSAGE , EXPERIMENTAL_LINK_MESSAGE )
99+ if not _should_skip_warning () and not _is_warning_cached (message ):
100+ module_logger .warning (message )
101+ return func (* args , ** kwargs )
102+
103+ return wrapped
104+
105+
106+ def _add_note_to_docstring (doc_string : str , note : str ) -> str :
107+ """Adds experimental note to docstring at the top and correctly indents original docstring.
108+
109+ :param doc_string: The docstring
110+ :type doc_string: str
111+ :param note: The note to add to the docstring
112+ :type note: str
113+ :return: Updated docstring
114+ :rtype: str
115+ """
116+ indent = _get_indentation_size (doc_string )
117+ doc_string = doc_string .rjust (len (doc_string ) + indent )
118+ return note + doc_string
119+
120+
121+ def _get_indentation_size (doc_string : str ) -> int :
122+ """Finds the minimum indentation of all non-blank lines after the first line.
123+
124+ :param doc_string: The docstring
125+ :type doc_string: str
126+ :return: Minimum number of indentation of the docstring
127+ :rtype: int
128+ """
129+ lines = doc_string .expandtabs ().splitlines ()
130+ indent = sys .maxsize
131+ for line in lines [1 :]:
132+ stripped = line .lstrip ()
133+ if stripped :
134+ indent = min (indent , len (line ) - len (stripped ))
135+ return indent if indent < sys .maxsize else DOCSTRING_DEFAULT_INDENTATION
136+
137+
138+ def _should_skip_warning ():
139+ skip_warning_msg = False
140+
141+ # Cases where we want to suppress the warning:
142+ # 1. When converting from REST object to SDK object
143+ for frame in inspect .stack ():
144+ if frame .function == "_from_rest_object" :
145+ skip_warning_msg = True
146+ break
147+
148+ return skip_warning_msg
149+
150+
151+ def _is_warning_cached (warning_msg ):
152+ # use cache to make sure we only print same warning message once under same session
153+ # this prevents duplicated warnings got printed when user does a loop call on a method or a class
154+ if warning_msg in _warning_cache :
155+ return True
156+ _warning_cache .add (warning_msg )
157+ return False
0 commit comments