Skip to content

Commit 51520e6

Browse files
authored
Python Worker Extension Interface (sdk) (#81)
* Added interface for extension registration Fix nit Ensure extension can be loaded successfully Add unittests Modify documentations Change trace context attribute names Fix wrongly declared abstract method Accept Revert changes in Context Fix unittests * Remove FuncExtensionInitError * Add back metaclass * Snapshot * Add extension class * Clean up repo * Fix Python 36 mypy syntax * Remove logger from function load hook * Update unit test cases * Rename hook as discussed offline * Fix comment * Change exception name * Demonstrate trigger name in function extension is in script root * Fix nits * Refactor func extension and app extension generation * Refactor NewAppExtension code
1 parent 9b1f179 commit 51520e6

12 files changed

+1288
-2
lines changed

azure/functions/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from ._servicebus import ServiceBusMessage
1414
from ._durable_functions import OrchestrationContext, EntityContext
1515
from .meta import get_binding_registry
16+
from .extension import (ExtensionMeta, FunctionExtensionException,
17+
FuncExtensionBase, AppExtensionBase)
1618

1719
# Import binding implementations to register them
1820
from . import blob # NoQA
@@ -54,7 +56,13 @@
5456
'TimerRequest',
5557

5658
# Middlewares
57-
'WsgiMiddleware'
59+
'WsgiMiddleware',
60+
61+
# Extensions
62+
'AppExtensionBase',
63+
'FuncExtensionBase',
64+
'ExtensionMeta',
65+
'FunctionExtensionException'
5866
)
5967

6068
__version__ = '1.6.0'

azure/functions/extension/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from .extension_meta import ExtensionMeta
2+
from .function_extension_exception import FunctionExtensionException
3+
from .app_extension_base import AppExtensionBase
4+
from .func_extension_base import FuncExtensionBase
5+
6+
__all__ = [
7+
'ExtensionMeta',
8+
'FunctionExtensionException',
9+
'AppExtensionBase',
10+
'FuncExtensionBase'
11+
]
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import typing
5+
from logging import Logger
6+
from .extension_meta import ExtensionMeta
7+
from .extension_scope import ExtensionScope
8+
from .._abc import Context
9+
10+
11+
class AppExtensionBase(metaclass=ExtensionMeta):
12+
"""An abstract class defines the global life-cycle hooks to be implemented
13+
by customer's extension, will be applied to all functions.
14+
15+
An AppExtension should be treated as a static class. Must not contain
16+
__init__ method since it is not instantiable.
17+
18+
Please place your initialization code in init() classmethod, consider
19+
accepting extension settings in configure() classmethod from customers.
20+
"""
21+
22+
_scope = ExtensionScope.APPLICATION
23+
24+
@classmethod
25+
def init(cls):
26+
"""The function will be executed when the extension is loaded.
27+
Happens when Azure Functions customers import the extension module.
28+
"""
29+
pass
30+
31+
@classmethod
32+
def configure(cls, *args, **kwargs):
33+
"""This function is intended to be called by Azure Functions
34+
customers. This is a contract between extension developers and
35+
azure functions customers. If multiple .configure() are called,
36+
the extension system cannot guarentee the calling order.
37+
"""
38+
pass
39+
40+
# DO NOT decorate this with @abc.abstractstatismethod
41+
# since implementation by subclass is not mandatory
42+
@classmethod
43+
def post_function_load_app_level(cls,
44+
function_name: str,
45+
function_directory: str,
46+
*args, **kwargs) -> None:
47+
"""This must be implemented as a @classmethod. It will be called right
48+
a customer's function is loaded. In this stage, the customer's logger
49+
is not fully initialized from the Python worker. Please use print()
50+
to emit message if necessary.
51+
52+
Parameters
53+
----------
54+
function_name: str
55+
The name of customer's function (e.g. HttpTrigger)
56+
function_directory: str
57+
The path to customer's function directory
58+
(e.g. /home/site/wwwroot/HttpTrigger)
59+
"""
60+
pass
61+
62+
# DO NOT decorate this with @abc.abstractstatismethod
63+
# since implementation by subclass is not mandatory
64+
@classmethod
65+
def pre_invocation_app_level(cls,
66+
logger: Logger,
67+
context: Context,
68+
func_args: typing.Dict[str, object] = {},
69+
*args,
70+
**kwargs) -> None:
71+
"""This must be implemented as a @staticmethod. It will be called right
72+
before a customer's function is being executed.
73+
74+
Parameters
75+
----------
76+
logger: logging.Logger
77+
A logger provided by Python worker. Extension developer should
78+
use this logger to emit telemetry to Azure Functions customers.
79+
context: azure.functions.Context
80+
This will include the function_name, function_directory and an
81+
invocation_id of this specific invocation.
82+
func_args: typing.Dict[str, object]
83+
Arguments that are passed into the Azure Functions. The name of
84+
each parameter is defined in function.json. Extension developers
85+
may also want to do isinstance() check if you want to apply
86+
operations to specific trigger types or input binding types.
87+
"""
88+
pass
89+
90+
# DO NOT decorate this with @abc.abstractstatismethod
91+
# since implementation by subclass is not mandatory
92+
@classmethod
93+
def post_invocation_app_level(cls,
94+
logger: Logger,
95+
context: Context,
96+
func_args: typing.Dict[str, object] = {},
97+
func_ret: typing.Optional[object] = None,
98+
*args,
99+
**kwargs) -> None:
100+
"""This must be implemented as a @staticmethod. It will be called right
101+
before a customer's function is being executed.
102+
103+
Parameters
104+
----------
105+
logger: logging.Logger
106+
A logger provided by Python worker. Extension developer should
107+
use this logger to emit telemetry to Azure Functions customers.
108+
context: azure.functions.Context
109+
This will include the function_name, function_directory and an
110+
invocation_id of this specific invocation.
111+
func_args: typing.Dict[str, object]
112+
Arguments that are passed into the Azure Functions. The name of
113+
each parameter is defined in function.json. Extension developers
114+
may also want to do isinstance() check if you want to apply
115+
operations to specific trigger types or input binding types.
116+
func_ret: typing.Optional[object]
117+
Return value from Azure Functions. This is usually the value
118+
defined in function.json $return section. Extension developers
119+
may also want to do isinstance() check if you want to apply
120+
operations to specific types or input binding types."
121+
"""
122+
pass
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from typing import NamedTuple, List
5+
from .extension_hook_meta import ExtensionHookMeta
6+
7+
8+
class AppExtensionHooks(NamedTuple):
9+
"""The definition of which type of global hooks are supported in SDK.
10+
ExtensionMeta will lookup the AppExtension life-cycle type from here.
11+
"""
12+
# The default value ([] empty list) is not being set here intentionally
13+
# since it is impacted by a Python bug https://bugs.python.org/issue33077.
14+
post_function_load_app_level: List[ExtensionHookMeta]
15+
pre_invocation_app_level: List[ExtensionHookMeta]
16+
post_invocation_app_level: List[ExtensionHookMeta]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from typing import Callable, NamedTuple
5+
6+
7+
class ExtensionHookMeta(NamedTuple):
8+
"""The metadata of a single life-cycle hook.
9+
The ext_name has the class name of an extension class.
10+
The ext_impl has the callable function that is used by the worker.
11+
"""
12+
ext_name: str
13+
ext_impl: Callable
14+
15+
# When adding more fields, make sure they have default values (e.g.
16+
# ext_new_field: Optional[str] = None
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from typing import Optional, Union, Dict, List
5+
import abc
6+
import json
7+
from .app_extension_hooks import AppExtensionHooks
8+
from .func_extension_hooks import FuncExtensionHooks
9+
from .extension_hook_meta import ExtensionHookMeta
10+
from .extension_scope import ExtensionScope
11+
from .function_extension_exception import FunctionExtensionException
12+
13+
14+
class ExtensionMeta(abc.ABCMeta):
15+
"""The metaclass handles extension registration.
16+
17+
AppExtension is regsistered in __init__, it is applied to all triggers.
18+
FuncExtension is registered in __call__, as users need to instantiate it
19+
inside hook script.
20+
21+
After registration, the extension class will be flatten into the following
22+
structure to speed up worker lookup:
23+
_func_exts[<trigger_name>].<hook_name>.(ext_name, ext_impl)
24+
(e.g. _func_exts['HttpTrigger'].pre_invocation.ext_impl)
25+
26+
_app_exts.<hook_name>.(ext_name, ext_impl)
27+
(e.g. _app_exts.pre_invocation_app_level.ext_impl)
28+
29+
The extension tree information is stored in _info for diagnostic
30+
purpose. The dictionary is serializible to json:
31+
_info['FuncExtension']['<Trigger>'] = list(<Extension>)
32+
_info['AppExtension'] = list(<Extension>)
33+
"""
34+
_func_exts: Dict[str, FuncExtensionHooks] = {}
35+
_app_exts: Optional[AppExtensionHooks] = None
36+
_info: Dict[str, Union[Dict[str, List[str]], List[str]]] = {}
37+
38+
def __init__(cls, *args, **kwargs):
39+
"""Executes on 'import extension', once the AppExtension class is
40+
loaded, call the setup() method and add the life-cycle hooks into
41+
_app_exts.
42+
"""
43+
super(ExtensionMeta, cls).__init__(*args, **kwargs)
44+
scope = ExtensionMeta._get_extension_scope(cls)
45+
46+
# Only register application extension here
47+
if scope is ExtensionScope.APPLICATION:
48+
ExtensionMeta._register_application_extension(cls)
49+
50+
def __call__(cls, *args, **kwargs):
51+
"""Executes on 'inst = extension(__file__)', once the FuncExtension
52+
class is instantiate, overwrite the __init__() method and add the
53+
instance into life-cycle hooks.
54+
"""
55+
scope = ExtensionMeta._get_extension_scope(cls)
56+
57+
# Only register function extension here
58+
if scope is ExtensionScope.FUNCTION:
59+
instance = super(ExtensionMeta, cls).__call__(*args, **kwargs)
60+
ExtensionMeta._register_function_extension(instance)
61+
return instance
62+
elif scope is ExtensionScope.APPLICATION:
63+
raise FunctionExtensionException(
64+
f'Python worker extension with scope:{scope} should not be'
65+
'instantiable. Please access via class method directly.'
66+
)
67+
else:
68+
raise FunctionExtensionException(
69+
f'Python worker extension:{cls.__name__} is not properly '
70+
'implemented from AppExtensionBase or FuncExtensionBase.'
71+
)
72+
73+
@classmethod
74+
def get_function_hooks(cls, name: str) -> Optional[FuncExtensionHooks]:
75+
"""Return all function extension hooks indexed by trigger name.
76+
77+
Returns
78+
-------
79+
Optional[FuncExtensionHooks]:
80+
Example to look up a certain life-cycle name:
81+
get_function_hooks('HttpTrigger').pre_invocation.ext_name
82+
"""
83+
return cls._func_exts.get(name.lower())
84+
85+
@classmethod
86+
def get_application_hooks(cls) -> Optional[AppExtensionHooks]:
87+
"""Return all application hooks
88+
89+
Returns
90+
-------
91+
Optional[AppExtensionHooks]:
92+
Example to look up a certain life-cycle name:
93+
get_application_hooks().pre_invocation_app_level.ext_name
94+
"""
95+
return cls._app_exts
96+
97+
@classmethod
98+
def get_registered_extensions_json(cls) -> str:
99+
"""Return a json string of the registered
100+
101+
Returns
102+
-------
103+
str:
104+
The json string will be constructed in a structure of
105+
{
106+
"FuncExtension": {
107+
"<TriggerA>": [
108+
"ExtensionName"
109+
]
110+
},
111+
"AppExtension": [
112+
"ExtensionName"
113+
]
114+
}
115+
"""
116+
return json.dumps(cls._info)
117+
118+
@classmethod
119+
def _get_extension_scope(cls, extension) -> ExtensionScope:
120+
"""Return the scope of an extension"""
121+
return getattr(extension, '_scope', # type: ignore
122+
ExtensionScope.UNKNOWN)
123+
124+
@classmethod
125+
def _set_hooks_for_function(cls, trigger_name: str, ext):
126+
ext_hooks = cls._func_exts.setdefault(
127+
trigger_name.lower(),
128+
cls._create_default_function_hook()
129+
)
130+
131+
# Flatten extension class to cls._func_exts
132+
for hook_name in ext_hooks._fields:
133+
hook_impl = getattr(ext, hook_name, None)
134+
if hook_impl is not None:
135+
hook_meta = ExtensionHookMeta(
136+
ext_name=ext.__class__.__name__,
137+
ext_impl=hook_impl,
138+
)
139+
getattr(ext_hooks, hook_name).append(hook_meta)
140+
141+
@classmethod
142+
def _set_hooks_for_application(cls, ext):
143+
if cls._app_exts is None:
144+
cls._app_exts = cls._create_default_app_hook()
145+
146+
# Check for definition in AppExtensionHooks NamedTuple
147+
for hook_name in cls._app_exts._fields:
148+
hook_impl = getattr(ext, hook_name, None)
149+
if hook_impl is not None:
150+
getattr(cls._app_exts, hook_name).append(ExtensionHookMeta(
151+
ext_name=ext.__name__,
152+
ext_impl=hook_impl
153+
))
154+
155+
@classmethod
156+
def _register_function_extension(cls, extension):
157+
"""Flatten the function extension structure into function hooks"""
158+
# Should skip registering FuncExtensionBase, cannot use isinstance(),
159+
# referring to func_extension_hooks introduces a dependency cycle
160+
if extension.__class__.__name__ == 'FuncExtensionBase':
161+
return
162+
163+
trigger_name = extension._trigger_name
164+
cls._set_hooks_for_function(trigger_name, extension)
165+
166+
# Record function extension information
167+
hooks_info = cls._info.setdefault( # type: ignore
168+
'FuncExtension', {}).setdefault(trigger_name, [])
169+
hooks_info.append(extension.__class__.__name__)
170+
171+
@classmethod
172+
def _register_application_extension(cls, extension):
173+
"""Flatten the application extension structure into function hooks"""
174+
# Should skip registering AppExtensionBase, cannot use isinstance(),
175+
# referring to app_extension_hooks introduces a dependency cycle
176+
if extension.__name__ == 'AppExtensionBase':
177+
return
178+
179+
if getattr(extension, 'init', None):
180+
extension.init()
181+
182+
cls._set_hooks_for_application(extension)
183+
184+
# Record application extension information
185+
hooks_info = cls._info.setdefault('AppExtension', [])
186+
hooks_info.append(extension.__name__) # type: ignore
187+
188+
@classmethod
189+
def _create_default_function_hook(cls) -> FuncExtensionHooks:
190+
return FuncExtensionHooks(
191+
post_function_load=[],
192+
pre_invocation=[],
193+
post_invocation=[]
194+
)
195+
196+
@classmethod
197+
def _create_default_app_hook(cls) -> AppExtensionHooks:
198+
return AppExtensionHooks(
199+
post_function_load_app_level=[],
200+
pre_invocation_app_level=[],
201+
post_invocation_app_level=[]
202+
)

0 commit comments

Comments
 (0)