stupidhuman-func 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- azure_func/__init__.py +5 -0
- azure_func/app.py +48 -0
- azure_func/decorator.py +234 -0
- azure_func/mcp_server.py +183 -0
- azure_func/openai.py +117 -0
- azure_func/scope.py +66 -0
- azure_func/service_handler.py +251 -0
- stupidhuman_func-0.2.0.dist-info/METADATA +11 -0
- stupidhuman_func-0.2.0.dist-info/RECORD +11 -0
- stupidhuman_func-0.2.0.dist-info/WHEEL +5 -0
- stupidhuman_func-0.2.0.dist-info/top_level.txt +1 -0
azure_func/__init__.py
ADDED
azure_func/app.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import azure.functions as func
|
|
2
|
+
|
|
3
|
+
from .decorator import make_http_handler
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AzureFunctionApp:
|
|
7
|
+
"""
|
|
8
|
+
Thin wrapper around ``azure.functions.FunctionApp`` that adds a
|
|
9
|
+
``@app.route()`` decorator for turning plain Python functions into
|
|
10
|
+
Azure HTTP-triggered functions.
|
|
11
|
+
|
|
12
|
+
Usage::
|
|
13
|
+
|
|
14
|
+
app = AzureFunctionApp()
|
|
15
|
+
|
|
16
|
+
@app.route()
|
|
17
|
+
def full_name(fname, lname):
|
|
18
|
+
return fname + " " + lname
|
|
19
|
+
|
|
20
|
+
# The underlying FunctionApp is available as app.func_app
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, http_auth_level=func.AuthLevel.ANONYMOUS, **kwargs):
|
|
24
|
+
self.func_app = func.FunctionApp(http_auth_level=http_auth_level, **kwargs)
|
|
25
|
+
|
|
26
|
+
# Proxy attribute access so callers can do ``app.func_app`` things directly
|
|
27
|
+
def __getattr__(self, name):
|
|
28
|
+
return getattr(self.func_app, name)
|
|
29
|
+
|
|
30
|
+
def route(self, route: str = None, methods: list = None):
|
|
31
|
+
"""
|
|
32
|
+
Decorator that registers the wrapped function as an Azure HTTP trigger.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
route: URL path segment. Defaults to the function's own name.
|
|
36
|
+
methods: HTTP methods to accept. Defaults to ``["GET", "POST"]``.
|
|
37
|
+
|
|
38
|
+
The decorated function is returned unchanged so it can still be
|
|
39
|
+
called directly in tests or other Python code.
|
|
40
|
+
"""
|
|
41
|
+
if methods is None:
|
|
42
|
+
methods = ["GET", "POST"]
|
|
43
|
+
|
|
44
|
+
def decorator(fn):
|
|
45
|
+
route_name = route or fn.__name__
|
|
46
|
+
return make_http_handler(fn, self.func_app, route_name, methods)
|
|
47
|
+
|
|
48
|
+
return decorator
|
azure_func/decorator.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import mimetypes
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from .scope import extract_scopes, TOOL_PROP_ATTR
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def _extract_params_async(req, params):
|
|
11
|
+
"""
|
|
12
|
+
Extract and coerce parameters from a FastAPI-compatible Request.
|
|
13
|
+
|
|
14
|
+
Resolution order: query string → route params → JSON body.
|
|
15
|
+
Parameters annotated with ``bytes`` receive the raw request body directly.
|
|
16
|
+
Returns (kwargs dict, error response dict or None).
|
|
17
|
+
"""
|
|
18
|
+
# Read raw body once if any parameter wants bytes.
|
|
19
|
+
needs_raw = any(
|
|
20
|
+
p.annotation is bytes
|
|
21
|
+
for p in params.values()
|
|
22
|
+
if p.annotation is not inspect.Parameter.empty
|
|
23
|
+
)
|
|
24
|
+
raw_body = (await req.body()) if needs_raw else None
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
if "application/json" in req.headers.get("content-type", ""):
|
|
28
|
+
body = await req.json()
|
|
29
|
+
if not isinstance(body, dict):
|
|
30
|
+
body = {}
|
|
31
|
+
else:
|
|
32
|
+
body = {}
|
|
33
|
+
except Exception:
|
|
34
|
+
body = {}
|
|
35
|
+
|
|
36
|
+
kwargs = {}
|
|
37
|
+
errors = []
|
|
38
|
+
|
|
39
|
+
for name, param in params.items():
|
|
40
|
+
annotation = param.annotation
|
|
41
|
+
|
|
42
|
+
if annotation is bytes:
|
|
43
|
+
kwargs[name] = raw_body if raw_body is not None else b""
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
value = req.query_params.get(name)
|
|
47
|
+
if value is None:
|
|
48
|
+
value = req.path_params.get(name)
|
|
49
|
+
if value is None:
|
|
50
|
+
value = body.get(name)
|
|
51
|
+
|
|
52
|
+
if value is None:
|
|
53
|
+
if param.default is not inspect.Parameter.empty:
|
|
54
|
+
kwargs[name] = param.default
|
|
55
|
+
continue
|
|
56
|
+
else:
|
|
57
|
+
errors.append(name)
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
# Type coercion driven by annotation
|
|
61
|
+
if annotation is not inspect.Parameter.empty and annotation is not str:
|
|
62
|
+
try:
|
|
63
|
+
if annotation is bool:
|
|
64
|
+
value = value.lower() not in ("0", "false", "no", "")
|
|
65
|
+
else:
|
|
66
|
+
value = annotation(value)
|
|
67
|
+
except (ValueError, TypeError) as exc:
|
|
68
|
+
return None, {
|
|
69
|
+
"error": f"Invalid value for '{name}': expected {annotation.__name__}, got {value!r}",
|
|
70
|
+
"detail": str(exc),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
kwargs[name] = value
|
|
74
|
+
|
|
75
|
+
if errors:
|
|
76
|
+
return None, {"error": f"Missing required parameter(s): {', '.join(errors)}"}
|
|
77
|
+
|
|
78
|
+
return kwargs, None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def make_queue_handler(fn, app, queue_name, connection):
|
|
82
|
+
"""Register fn as an Azure Queue trigger and return the original fn."""
|
|
83
|
+
import azure.functions as func
|
|
84
|
+
|
|
85
|
+
def queue_trigger(msg: func.QueueMessage) -> None:
|
|
86
|
+
try:
|
|
87
|
+
fn(msg)
|
|
88
|
+
except Exception:
|
|
89
|
+
logging.exception("Unhandled error in queue handler %s", fn.__name__)
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
queue_trigger.__name__ = fn.__name__
|
|
93
|
+
app.queue_trigger(arg_name="msg", queue_name=queue_name, connection=connection)(queue_trigger)
|
|
94
|
+
return fn
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def make_static_handler(handler_name, folder, app):
|
|
98
|
+
"""Register a catch-all GET route that serves files from folder."""
|
|
99
|
+
from azurefunctions.extensions.http.fastapi import Request, Response
|
|
100
|
+
|
|
101
|
+
base = os.path.abspath(folder)
|
|
102
|
+
|
|
103
|
+
async def static_handler(req: Request):
|
|
104
|
+
filepath = req.path_params.get("filepath", "")
|
|
105
|
+
|
|
106
|
+
# Prevent path traversal
|
|
107
|
+
target = os.path.abspath(os.path.join(base, filepath))
|
|
108
|
+
if not target.startswith(base + os.sep) and target != base:
|
|
109
|
+
return Response(status_code=404)
|
|
110
|
+
|
|
111
|
+
if not os.path.isfile(target):
|
|
112
|
+
return Response(status_code=404)
|
|
113
|
+
|
|
114
|
+
mime_type, _ = mimetypes.guess_type(target)
|
|
115
|
+
mime_type = mime_type or "application/octet-stream"
|
|
116
|
+
|
|
117
|
+
with open(target, "rb") as f:
|
|
118
|
+
return Response(content=f.read(), media_type=mime_type, status_code=200)
|
|
119
|
+
|
|
120
|
+
static_handler.__name__ = handler_name
|
|
121
|
+
app.route(route="{*filepath}", methods=["GET"])(static_handler)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def make_mcp_handler(fn, app):
|
|
125
|
+
"""Register fn as an Azure MCP tool trigger and return the original fn."""
|
|
126
|
+
sig = inspect.signature(fn)
|
|
127
|
+
params = sig.parameters
|
|
128
|
+
param_names = list(params.keys())
|
|
129
|
+
|
|
130
|
+
# The Azure Functions runtime inspects the handler's parameter names to map
|
|
131
|
+
# MCP arguments. Using **kwargs or a single 'kwargs' parameter causes the
|
|
132
|
+
# runtime to pass all args under a single key named 'kwargs'. We must
|
|
133
|
+
# generate a handler whose signature exactly matches fn's parameters.
|
|
134
|
+
param_str = ", ".join(param_names)
|
|
135
|
+
call_str = ", ".join(f"{name}={name}" for name in param_names)
|
|
136
|
+
globs = {"fn": fn}
|
|
137
|
+
exec(
|
|
138
|
+
f"def mcp_handler({param_str}):\n return fn({call_str})",
|
|
139
|
+
globs,
|
|
140
|
+
)
|
|
141
|
+
mcp_handler = globs["mcp_handler"]
|
|
142
|
+
|
|
143
|
+
mcp_handler.__name__ = fn.__name__
|
|
144
|
+
mcp_handler.__doc__ = fn.__doc__
|
|
145
|
+
|
|
146
|
+
# Copy type annotations so the SDK builds the correct JSON Schema types.
|
|
147
|
+
mcp_handler.__annotations__ = {
|
|
148
|
+
name: params[name].annotation
|
|
149
|
+
for name in param_names
|
|
150
|
+
if params[name].annotation is not inspect.Parameter.empty
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Attach property metadata for each parameter, then register the tool.
|
|
154
|
+
# mcp_tool_property adds metadata in-place; mcp_tool() does the registration.
|
|
155
|
+
descriptions = getattr(fn, TOOL_PROP_ATTR, {})
|
|
156
|
+
for name in params:
|
|
157
|
+
kw = {"arg_name": name}
|
|
158
|
+
if name in descriptions:
|
|
159
|
+
kw["description"] = descriptions[name]
|
|
160
|
+
app.mcp_tool_property(**kw)(mcp_handler)
|
|
161
|
+
app.mcp_tool()(mcp_handler)
|
|
162
|
+
|
|
163
|
+
return fn
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def make_stream_handler(fn, app, route_name, methods, media_type, required_scopes=None):
|
|
167
|
+
"""Register fn as a streaming HTTP-triggered function and return the original fn."""
|
|
168
|
+
from azurefunctions.extensions.http.fastapi import Request, StreamingResponse
|
|
169
|
+
|
|
170
|
+
sig = inspect.signature(fn)
|
|
171
|
+
params = sig.parameters
|
|
172
|
+
|
|
173
|
+
async def stream_trigger(req: Request) -> StreamingResponse:
|
|
174
|
+
if required_scopes:
|
|
175
|
+
token_scopes = extract_scopes(req.headers.get("Authorization", ""))
|
|
176
|
+
missing = [s for s in required_scopes if s not in token_scopes]
|
|
177
|
+
if missing:
|
|
178
|
+
async def _forbidden():
|
|
179
|
+
yield json.dumps(
|
|
180
|
+
{"error": "Insufficient scope", "required": missing},
|
|
181
|
+
ensure_ascii=False,
|
|
182
|
+
)
|
|
183
|
+
return StreamingResponse(_forbidden(), status_code=403, media_type="application/json")
|
|
184
|
+
|
|
185
|
+
kwargs, err = await _extract_params_async(req, params)
|
|
186
|
+
if err is not None:
|
|
187
|
+
async def _bad_request():
|
|
188
|
+
yield json.dumps(err, ensure_ascii=False)
|
|
189
|
+
return StreamingResponse(_bad_request(), status_code=400, media_type="application/json")
|
|
190
|
+
|
|
191
|
+
return StreamingResponse(fn(**kwargs), media_type=media_type)
|
|
192
|
+
|
|
193
|
+
stream_trigger.__name__ = fn.__name__
|
|
194
|
+
app.route(route=route_name, methods=methods)(stream_trigger)
|
|
195
|
+
return fn
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def make_http_handler(fn, app, route_name, methods, required_scopes=None):
|
|
199
|
+
"""Register fn as an Azure HTTP-triggered function and return the original fn."""
|
|
200
|
+
from azurefunctions.extensions.http.fastapi import Request, Response
|
|
201
|
+
|
|
202
|
+
sig = inspect.signature(fn)
|
|
203
|
+
params = sig.parameters
|
|
204
|
+
|
|
205
|
+
def _json(data, status_code):
|
|
206
|
+
return Response(
|
|
207
|
+
content=json.dumps(data, ensure_ascii=False),
|
|
208
|
+
status_code=status_code,
|
|
209
|
+
media_type="application/json",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def http_trigger(req: Request):
|
|
213
|
+
if required_scopes:
|
|
214
|
+
token_scopes = extract_scopes(req.headers.get("Authorization", ""))
|
|
215
|
+
missing = [s for s in required_scopes if s not in token_scopes]
|
|
216
|
+
if missing:
|
|
217
|
+
return _json({"error": "Insufficient scope", "required": missing}, 403)
|
|
218
|
+
|
|
219
|
+
kwargs, err = await _extract_params_async(req, params)
|
|
220
|
+
if err is not None:
|
|
221
|
+
return _json(err, 400)
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
result = fn(**kwargs)
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
logging.exception("Unhandled error in %s", fn.__name__)
|
|
227
|
+
return _json({"error": "Internal server error", "detail": str(exc)}, 500)
|
|
228
|
+
|
|
229
|
+
return _json({"result": result}, 200)
|
|
230
|
+
|
|
231
|
+
http_trigger.__name__ = fn.__name__
|
|
232
|
+
app.route(route=route_name, methods=methods)(http_trigger)
|
|
233
|
+
|
|
234
|
+
return fn
|
azure_func/mcp_server.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from .scope import attach_tool_property_description, TOOL_PROP_ATTR
|
|
7
|
+
|
|
8
|
+
_JSON_SCHEMA_TYPE = {
|
|
9
|
+
str: "string",
|
|
10
|
+
int: "integer",
|
|
11
|
+
float: "number",
|
|
12
|
+
bool: "boolean",
|
|
13
|
+
list: "array",
|
|
14
|
+
dict: "object",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _build_input_schema(params, descriptions=None):
|
|
19
|
+
properties = {}
|
|
20
|
+
required = []
|
|
21
|
+
for name, param in params.items():
|
|
22
|
+
prop = {}
|
|
23
|
+
annotation = param.annotation
|
|
24
|
+
if annotation is not inspect.Parameter.empty and annotation in _JSON_SCHEMA_TYPE:
|
|
25
|
+
prop["type"] = _JSON_SCHEMA_TYPE[annotation]
|
|
26
|
+
if descriptions and name in descriptions:
|
|
27
|
+
prop["description"] = descriptions[name]
|
|
28
|
+
if param.default is inspect.Parameter.empty:
|
|
29
|
+
required.append(name)
|
|
30
|
+
properties[name] = prop
|
|
31
|
+
return {"type": "object", "properties": properties, "required": required}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _coerce_args(arguments, params):
|
|
35
|
+
result = {}
|
|
36
|
+
missing = []
|
|
37
|
+
for name, param in params.items():
|
|
38
|
+
if name not in arguments:
|
|
39
|
+
if param.default is not inspect.Parameter.empty:
|
|
40
|
+
result[name] = param.default
|
|
41
|
+
else:
|
|
42
|
+
missing.append(name)
|
|
43
|
+
continue
|
|
44
|
+
value = arguments[name]
|
|
45
|
+
annotation = param.annotation
|
|
46
|
+
if annotation is not inspect.Parameter.empty and not isinstance(value, annotation):
|
|
47
|
+
try:
|
|
48
|
+
value = annotation(value)
|
|
49
|
+
except (ValueError, TypeError):
|
|
50
|
+
pass
|
|
51
|
+
result[name] = value
|
|
52
|
+
if missing:
|
|
53
|
+
raise ValueError(f"Missing required argument(s): {', '.join(missing)}")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class McpToolServer:
|
|
58
|
+
"""
|
|
59
|
+
A self-contained MCP tool server exposed as a single POST route.
|
|
60
|
+
|
|
61
|
+
Create via ``sh.mcp_server(name, route)`` and register tools with
|
|
62
|
+
the ``@server.tool()`` decorator::
|
|
63
|
+
|
|
64
|
+
search = sh.mcp_server("search", route="mcp/search")
|
|
65
|
+
|
|
66
|
+
@search.tool()
|
|
67
|
+
def find_products(query: str, customer_id: str) -> list:
|
|
68
|
+
\"\"\"Search products for a customer.\"\"\"
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
Handles ``initialize``, ``tools/list``, and ``tools/call`` JSON-RPC 2.0.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, name: str, route: str, func_app):
|
|
75
|
+
self._name = name
|
|
76
|
+
self._tools = {}
|
|
77
|
+
self._register_endpoint(route, func_app)
|
|
78
|
+
|
|
79
|
+
def tool(self):
|
|
80
|
+
"""Register the decorated function as a tool on this MCP server."""
|
|
81
|
+
def decorator(fn):
|
|
82
|
+
sig = inspect.signature(fn)
|
|
83
|
+
descriptions = getattr(fn, TOOL_PROP_ATTR, {})
|
|
84
|
+
self._tools[fn.__name__] = {
|
|
85
|
+
"fn": fn,
|
|
86
|
+
"description": (fn.__doc__ or "").strip(),
|
|
87
|
+
"params": sig.parameters,
|
|
88
|
+
"schema": _build_input_schema(sig.parameters, descriptions),
|
|
89
|
+
}
|
|
90
|
+
return fn
|
|
91
|
+
return decorator
|
|
92
|
+
|
|
93
|
+
def tool_property(self, arg_name: str, description: str):
|
|
94
|
+
"""
|
|
95
|
+
Attach a description to a tool parameter.
|
|
96
|
+
|
|
97
|
+
Must be applied below ``@server.tool()`` (closer to the function).
|
|
98
|
+
Cumulative — stack multiple calls for different parameters::
|
|
99
|
+
|
|
100
|
+
@invoices.tool()
|
|
101
|
+
@invoices.tool_property("invoice_id", "The invoice's unique identifier")
|
|
102
|
+
@invoices.tool_property("include_lines", "Include line items in the response")
|
|
103
|
+
def get_invoice(invoice_id: str, include_lines: bool = True) -> dict:
|
|
104
|
+
\"\"\"Retrieve an invoice by ID.\"\"\"
|
|
105
|
+
...
|
|
106
|
+
"""
|
|
107
|
+
return attach_tool_property_description(arg_name, description)
|
|
108
|
+
|
|
109
|
+
def _register_endpoint(self, route, func_app):
|
|
110
|
+
from azurefunctions.extensions.http.fastapi import Request, Response
|
|
111
|
+
|
|
112
|
+
tools = self._tools
|
|
113
|
+
name = self._name
|
|
114
|
+
|
|
115
|
+
def _ok(rpc_id, result):
|
|
116
|
+
return Response(
|
|
117
|
+
content=json.dumps({"jsonrpc": "2.0", "id": rpc_id, "result": result}, ensure_ascii=False),
|
|
118
|
+
status_code=200,
|
|
119
|
+
media_type="application/json",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _err(rpc_id, code, message):
|
|
123
|
+
return Response(
|
|
124
|
+
content=json.dumps({"jsonrpc": "2.0", "id": rpc_id, "error": {"code": code, "message": message}}, ensure_ascii=False),
|
|
125
|
+
status_code=200,
|
|
126
|
+
media_type="application/json",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def mcp_endpoint(req: Request):
|
|
130
|
+
try:
|
|
131
|
+
body = await req.json()
|
|
132
|
+
except Exception:
|
|
133
|
+
return _err(None, -32700, "Parse error")
|
|
134
|
+
|
|
135
|
+
rpc_id = body.get("id")
|
|
136
|
+
method = body.get("method", "")
|
|
137
|
+
params = body.get("params") or {}
|
|
138
|
+
|
|
139
|
+
if method == "initialize":
|
|
140
|
+
return _ok(rpc_id, {
|
|
141
|
+
"protocolVersion": "2024-11-05",
|
|
142
|
+
"capabilities": {"tools": {}},
|
|
143
|
+
"serverInfo": {"name": name, "version": "1.0.0"},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
if method == "notifications/initialized":
|
|
147
|
+
return Response(content="", status_code=204, media_type="application/json")
|
|
148
|
+
|
|
149
|
+
if method == "tools/list":
|
|
150
|
+
return _ok(rpc_id, {
|
|
151
|
+
"tools": [
|
|
152
|
+
{"name": n, "description": t["description"], "inputSchema": t["schema"]}
|
|
153
|
+
for n, t in tools.items()
|
|
154
|
+
]
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
if method == "tools/call":
|
|
158
|
+
tool_name = params.get("name")
|
|
159
|
+
arguments = params.get("arguments") or {}
|
|
160
|
+
|
|
161
|
+
if tool_name not in tools:
|
|
162
|
+
return _err(rpc_id, -32601, f"Unknown tool: {tool_name!r}")
|
|
163
|
+
|
|
164
|
+
t = tools[tool_name]
|
|
165
|
+
try:
|
|
166
|
+
kwargs = _coerce_args(arguments, t["params"])
|
|
167
|
+
result = t["fn"](**kwargs)
|
|
168
|
+
if asyncio.iscoroutine(result):
|
|
169
|
+
result = await result
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
logging.exception("Error in MCP tool %s", tool_name)
|
|
172
|
+
return _ok(rpc_id, {
|
|
173
|
+
"content": [{"type": "text", "text": str(exc)}],
|
|
174
|
+
"isError": True,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
text = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
|
|
178
|
+
return _ok(rpc_id, {"content": [{"type": "text", "text": text}]})
|
|
179
|
+
|
|
180
|
+
return _err(rpc_id, -32601, f"Method not found: {method!r}")
|
|
181
|
+
|
|
182
|
+
mcp_endpoint.__name__ = f"mcp_{name}"
|
|
183
|
+
func_app.route(route=route, methods=["POST"])(mcp_endpoint)
|
azure_func/openai.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
OPENAI_API_URL = "https://api.openai.com/v1"
|
|
6
|
+
|
|
7
|
+
_TIMEOUT = httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def _stream_request(client: httpx.AsyncClient, payload: dict):
|
|
11
|
+
"""Yield parsed SSE event dicts from a single /responses request."""
|
|
12
|
+
async with client.stream(
|
|
13
|
+
"POST",
|
|
14
|
+
f"{OPENAI_API_URL}/responses",
|
|
15
|
+
headers={
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
"Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}",
|
|
18
|
+
},
|
|
19
|
+
json=payload,
|
|
20
|
+
) as response:
|
|
21
|
+
if response.status_code != 200:
|
|
22
|
+
yield {"type": "error", "_status": response.status_code}
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
buffer = ""
|
|
26
|
+
async for chunk in response.aiter_text():
|
|
27
|
+
buffer += chunk
|
|
28
|
+
while "\n\n" in buffer:
|
|
29
|
+
block, buffer = buffer.split("\n\n", 1)
|
|
30
|
+
for line in block.splitlines():
|
|
31
|
+
if not line.startswith("data: "):
|
|
32
|
+
continue
|
|
33
|
+
raw = line[6:]
|
|
34
|
+
if raw == "[DONE]":
|
|
35
|
+
continue
|
|
36
|
+
try:
|
|
37
|
+
yield json.loads(raw)
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def stream_chat(messages: list, model: str, tools: list | None = None, system_prompt: str = ""):
|
|
43
|
+
"""
|
|
44
|
+
Stream an OpenAI Responses API call as SSE chunks.
|
|
45
|
+
|
|
46
|
+
Yields SSE-formatted strings for the caller to forward to the HTTP client.
|
|
47
|
+
Yields a final data: {"type": "done", "content": "<full text>"} event that
|
|
48
|
+
the caller can use to persist the assistant turn (e.g. to a database).
|
|
49
|
+
That event should NOT be forwarded to the browser.
|
|
50
|
+
"""
|
|
51
|
+
assistant_content = ""
|
|
52
|
+
if tools is None:
|
|
53
|
+
tools = []
|
|
54
|
+
|
|
55
|
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
56
|
+
previous_response_id = None
|
|
57
|
+
had_mcp_call = False
|
|
58
|
+
|
|
59
|
+
while True:
|
|
60
|
+
if previous_response_id:
|
|
61
|
+
# Continuation: model ended after a tool call without presenting results
|
|
62
|
+
payload = {
|
|
63
|
+
"model": model,
|
|
64
|
+
"instructions": system_prompt,
|
|
65
|
+
"previous_response_id": previous_response_id,
|
|
66
|
+
"stream": True,
|
|
67
|
+
"tools": tools,
|
|
68
|
+
}
|
|
69
|
+
else:
|
|
70
|
+
payload = {
|
|
71
|
+
"model": model,
|
|
72
|
+
"instructions": system_prompt,
|
|
73
|
+
"input": [{"role": m["role"], "content": m["content"]} for m in messages],
|
|
74
|
+
"stream": True,
|
|
75
|
+
"tools": tools,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
got_text = False
|
|
79
|
+
had_mcp_call = False
|
|
80
|
+
current_response_id = None
|
|
81
|
+
|
|
82
|
+
async for data in _stream_request(client, payload):
|
|
83
|
+
event_type = data.get("type")
|
|
84
|
+
|
|
85
|
+
if event_type == "error":
|
|
86
|
+
status = data.get("_status")
|
|
87
|
+
yield f"data: {json.dumps({'type': 'error', 'content': f'OpenAI error {status}'})}\n\n"
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
elif event_type == "response.created":
|
|
91
|
+
current_response_id = data.get("response", {}).get("id")
|
|
92
|
+
|
|
93
|
+
elif event_type == "response.output_text.delta":
|
|
94
|
+
delta = data.get("delta", "")
|
|
95
|
+
assistant_content += delta
|
|
96
|
+
got_text = True
|
|
97
|
+
yield f"data: {json.dumps({'type': 'content', 'content': delta})}\n\n"
|
|
98
|
+
|
|
99
|
+
elif event_type == "response.output_item.added":
|
|
100
|
+
item = data.get("item", {})
|
|
101
|
+
if item.get("type") == "mcp_call":
|
|
102
|
+
had_mcp_call = True
|
|
103
|
+
yield f"data: {json.dumps({'type': 'tool_call', 'name': item.get('name', item.get('server_label', 'tool'))})}\n\n"
|
|
104
|
+
|
|
105
|
+
elif event_type == "response.mcp_call.completed":
|
|
106
|
+
yield f"data: {json.dumps({'type': 'tool_done'})}\n\n"
|
|
107
|
+
|
|
108
|
+
# If the response ended after an MCP call but produced no text,
|
|
109
|
+
# continue with previous_response_id so the model presents the results.
|
|
110
|
+
if had_mcp_call and not got_text and current_response_id:
|
|
111
|
+
previous_response_id = current_response_id
|
|
112
|
+
else:
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
# Terminal event — not forwarded to the client, used by the caller to
|
|
116
|
+
# persist the assistant turn without re-parsing the SSE stream.
|
|
117
|
+
yield f"data: {json.dumps({'type': 'done', 'content': assistant_content})}\n\n"
|
azure_func/scope.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""JWT scope extraction and scope-metadata decorator."""
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
# Attribute name used to carry required-scope metadata on a function
|
|
7
|
+
SCOPE_ATTR = "_required_scopes"
|
|
8
|
+
TOOL_PROP_ATTR = "_tool_property_descriptions"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _decode_jwt_payload(token: str) -> dict:
|
|
12
|
+
"""Base64-decode the payload part of a JWT (no signature verification)."""
|
|
13
|
+
try:
|
|
14
|
+
parts = token.split(".")
|
|
15
|
+
if len(parts) != 3:
|
|
16
|
+
return {}
|
|
17
|
+
payload = parts[1]
|
|
18
|
+
# Restore stripped padding
|
|
19
|
+
payload += "=" * (-len(payload) % 4)
|
|
20
|
+
return json.loads(base64.urlsafe_b64decode(payload))
|
|
21
|
+
except Exception:
|
|
22
|
+
return {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract_scopes(authorization_header: str) -> set:
|
|
26
|
+
"""
|
|
27
|
+
Return the set of scopes found in a Bearer JWT Authorization header.
|
|
28
|
+
|
|
29
|
+
Handles:
|
|
30
|
+
- Azure AD ``scp`` claim (space-separated string)
|
|
31
|
+
- Standard OAuth2 ``scope`` claim (space-separated string or list)
|
|
32
|
+
"""
|
|
33
|
+
if not authorization_header:
|
|
34
|
+
return set()
|
|
35
|
+
match = re.match(r"^Bearer\s+(.+)$", authorization_header, re.IGNORECASE)
|
|
36
|
+
if not match:
|
|
37
|
+
return set()
|
|
38
|
+
payload = _decode_jwt_payload(match.group(1))
|
|
39
|
+
raw = payload.get("scp") or payload.get("scope") or ""
|
|
40
|
+
if isinstance(raw, list):
|
|
41
|
+
return set(raw)
|
|
42
|
+
return set(raw.split())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def attach_tool_property_description(arg_name: str, description: str):
|
|
46
|
+
"""Attach a parameter description for an MCP tool property."""
|
|
47
|
+
def decorator(fn):
|
|
48
|
+
existing = getattr(fn, TOOL_PROP_ATTR, {})
|
|
49
|
+
existing[arg_name] = description
|
|
50
|
+
fn._tool_property_descriptions = existing
|
|
51
|
+
return fn
|
|
52
|
+
return decorator
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def require_scopes(*scopes: str):
|
|
56
|
+
"""
|
|
57
|
+
Attach required-scope metadata to a function.
|
|
58
|
+
|
|
59
|
+
Decorators are cumulative — stacking ``@sh.scope()`` calls accumulates all
|
|
60
|
+
listed scopes.
|
|
61
|
+
"""
|
|
62
|
+
def decorator(fn):
|
|
63
|
+
existing = getattr(fn, SCOPE_ATTR, [])
|
|
64
|
+
fn._required_scopes = list(existing) + list(scopes)
|
|
65
|
+
return fn
|
|
66
|
+
return decorator
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import azure.functions as func
|
|
2
|
+
|
|
3
|
+
from .decorator import make_http_handler, make_mcp_handler, make_queue_handler, make_static_handler, make_stream_handler
|
|
4
|
+
from .mcp_server import McpToolServer
|
|
5
|
+
from .scope import require_scopes, SCOPE_ATTR, attach_tool_property_description
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ServiceHandler:
|
|
9
|
+
"""
|
|
10
|
+
Service registry that turns plain Python functions into Azure HTTP triggers.
|
|
11
|
+
|
|
12
|
+
Usage::
|
|
13
|
+
|
|
14
|
+
sh = ServiceHandler()
|
|
15
|
+
|
|
16
|
+
@sh.service()
|
|
17
|
+
@sh.scope('read:data')
|
|
18
|
+
def my_func(fname, lname):
|
|
19
|
+
return fname + " " + lname
|
|
20
|
+
|
|
21
|
+
# Pass sh.func_app to Azure as the FunctionApp entry point
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, http_auth_level=func.AuthLevel.ANONYMOUS, **kwargs):
|
|
25
|
+
self.func_app = func.FunctionApp(http_auth_level=http_auth_level, **kwargs)
|
|
26
|
+
|
|
27
|
+
def __getattr__(self, name):
|
|
28
|
+
return getattr(self.func_app, name)
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# Decorators
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def service(self, route: str = None, methods: list = None):
|
|
35
|
+
"""
|
|
36
|
+
Register the decorated function as an Azure HTTP trigger.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
route: URL path segment. Defaults to the function's own name.
|
|
40
|
+
methods: HTTP methods to accept. Defaults to ``["GET", "POST"]``.
|
|
41
|
+
|
|
42
|
+
Any ``@sh.scope()`` metadata already attached to the function is
|
|
43
|
+
read here and incorporated into the generated handler.
|
|
44
|
+
"""
|
|
45
|
+
if methods is None:
|
|
46
|
+
methods = ["GET", "POST"]
|
|
47
|
+
|
|
48
|
+
def decorator(fn):
|
|
49
|
+
route_name = route or fn.__name__
|
|
50
|
+
required_scopes = getattr(fn, SCOPE_ATTR, [])
|
|
51
|
+
return make_http_handler(
|
|
52
|
+
fn, self.func_app, route_name, methods,
|
|
53
|
+
required_scopes=required_scopes,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return decorator
|
|
57
|
+
|
|
58
|
+
def anonymous(self, route: str = None, methods: list = None):
|
|
59
|
+
"""
|
|
60
|
+
Register the decorated function as an Azure HTTP trigger with no
|
|
61
|
+
authentication or scope requirements.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
route: URL path segment. Defaults to the function's own name.
|
|
65
|
+
methods: HTTP methods to accept. Defaults to ``["GET", "POST"]``.
|
|
66
|
+
|
|
67
|
+
Use this instead of ``@sh.service()`` to make the intent explicit that
|
|
68
|
+
the endpoint is publicly accessible, even when other endpoints on the
|
|
69
|
+
same handler use ``@sh.scope()``.
|
|
70
|
+
"""
|
|
71
|
+
if methods is None:
|
|
72
|
+
methods = ["GET", "POST"]
|
|
73
|
+
|
|
74
|
+
def decorator(fn):
|
|
75
|
+
route_name = route or fn.__name__
|
|
76
|
+
return make_http_handler(fn, self.func_app, route_name, methods)
|
|
77
|
+
|
|
78
|
+
return decorator
|
|
79
|
+
|
|
80
|
+
def queue(self, queue_name: str, connection: str = "AzureWebJobsStorage"):
|
|
81
|
+
"""
|
|
82
|
+
Register the decorated function as an Azure Queue trigger.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
queue_name: Name of the storage queue to listen on.
|
|
86
|
+
connection: App setting name for the storage connection string.
|
|
87
|
+
Defaults to ``"AzureWebJobsStorage"``.
|
|
88
|
+
|
|
89
|
+
The decorated function receives a single ``func.QueueMessage`` argument::
|
|
90
|
+
|
|
91
|
+
@sh.queue("my-queue")
|
|
92
|
+
def process_message(msg: func.QueueMessage):
|
|
93
|
+
data = msg.get_json()
|
|
94
|
+
...
|
|
95
|
+
"""
|
|
96
|
+
def decorator(fn):
|
|
97
|
+
return make_queue_handler(fn, self.func_app, queue_name, connection)
|
|
98
|
+
return decorator
|
|
99
|
+
|
|
100
|
+
def stream(self, route: str = None, methods: list = None, media_type: str = "text/event-stream"):
|
|
101
|
+
"""
|
|
102
|
+
Register the decorated async generator function as a streaming HTTP trigger.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
route: URL path segment. Defaults to the function's own name.
|
|
106
|
+
methods: HTTP methods to accept. Defaults to ``["GET", "POST"]``.
|
|
107
|
+
media_type: Content-Type for the streamed response.
|
|
108
|
+
Defaults to ``"text/event-stream"`` (Server-Sent Events).
|
|
109
|
+
|
|
110
|
+
Any ``@sh.scope()`` metadata is enforced identically to ``@sh.service()``.
|
|
111
|
+
|
|
112
|
+
The decorated function must be an ``async`` generator (uses ``yield``)::
|
|
113
|
+
|
|
114
|
+
@sh.stream()
|
|
115
|
+
async def token_stream(prompt: str):
|
|
116
|
+
\"\"\"Stream tokens for a prompt.\"\"\"
|
|
117
|
+
for token in generate(prompt):
|
|
118
|
+
yield token
|
|
119
|
+
"""
|
|
120
|
+
if methods is None:
|
|
121
|
+
methods = ["GET", "POST"]
|
|
122
|
+
|
|
123
|
+
def decorator(fn):
|
|
124
|
+
route_name = route or fn.__name__
|
|
125
|
+
required_scopes = getattr(fn, SCOPE_ATTR, [])
|
|
126
|
+
return make_stream_handler(
|
|
127
|
+
fn, self.func_app, route_name, methods, media_type,
|
|
128
|
+
required_scopes=required_scopes,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return decorator
|
|
132
|
+
|
|
133
|
+
def static(self, folder: str):
|
|
134
|
+
"""
|
|
135
|
+
Serve static files from ``folder`` at the root URL path.
|
|
136
|
+
|
|
137
|
+
Registers a catch-all GET route ``{*filepath}`` that maps request paths
|
|
138
|
+
directly to files inside ``folder``. Path traversal is blocked.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
folder: Path to the directory to serve (relative to the working
|
|
142
|
+
directory of the function app, or absolute).
|
|
143
|
+
|
|
144
|
+
Usage::
|
|
145
|
+
|
|
146
|
+
@sh.static('public')
|
|
147
|
+
def serve_static():
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
# GET /style.css → ./public/style.css
|
|
151
|
+
# GET /js/app.js → ./public/js/app.js
|
|
152
|
+
"""
|
|
153
|
+
def decorator(fn):
|
|
154
|
+
make_static_handler(fn.__name__, folder, self.func_app)
|
|
155
|
+
return fn
|
|
156
|
+
|
|
157
|
+
return decorator
|
|
158
|
+
|
|
159
|
+
def tool(self):
|
|
160
|
+
"""
|
|
161
|
+
Register the decorated function as an Azure MCP tool trigger.
|
|
162
|
+
|
|
163
|
+
The function name becomes the tool name and the docstring becomes the
|
|
164
|
+
tool description. Each parameter is registered as an MCP tool property.
|
|
165
|
+
|
|
166
|
+
Note: MCP tools are authenticated via the Azure ``mcp_extension`` system
|
|
167
|
+
key at the host level. ``@sh.scope()`` is not supported for tools.
|
|
168
|
+
|
|
169
|
+
Usage::
|
|
170
|
+
|
|
171
|
+
@sh.tool()
|
|
172
|
+
def get_item(item_id: int) -> str:
|
|
173
|
+
\"\"\"Get an item by ID.\"\"\"
|
|
174
|
+
return str(item_id)
|
|
175
|
+
"""
|
|
176
|
+
def decorator(fn):
|
|
177
|
+
return make_mcp_handler(fn, self.func_app)
|
|
178
|
+
|
|
179
|
+
return decorator
|
|
180
|
+
|
|
181
|
+
def tool_property(self, arg_name: str, description: str):
|
|
182
|
+
"""
|
|
183
|
+
Attach a description to an MCP tool parameter.
|
|
184
|
+
|
|
185
|
+
Must be applied below ``@sh.tool()`` (closer to the function definition).
|
|
186
|
+
Cumulative — multiple calls attach descriptions to different parameters.
|
|
187
|
+
|
|
188
|
+
Usage::
|
|
189
|
+
|
|
190
|
+
@sh.tool()
|
|
191
|
+
@sh.tool_property("item_id", "The ID of the item to retrieve.")
|
|
192
|
+
def get_item(item_id: int) -> str:
|
|
193
|
+
\"\"\"Get an item by ID.\"\"\"
|
|
194
|
+
return str(item_id)
|
|
195
|
+
"""
|
|
196
|
+
return attach_tool_property_description(arg_name, description)
|
|
197
|
+
|
|
198
|
+
def mcp_server(self, name: str, route: str = None) -> McpToolServer:
|
|
199
|
+
"""
|
|
200
|
+
Create a named MCP tool server exposed as a single POST route.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
name: Server name, used in the ``serverInfo`` response.
|
|
204
|
+
route: URL path. Defaults to ``mcp/<name>``.
|
|
205
|
+
|
|
206
|
+
Returns an :class:`McpToolServer` whose ``@server.tool()`` decorator
|
|
207
|
+
registers tools on that server only. Multiple servers can coexist in
|
|
208
|
+
the same Function App at different routes::
|
|
209
|
+
|
|
210
|
+
sales = sh.mcp_server("sales", route="mcp/sales")
|
|
211
|
+
invoices = sh.mcp_server("invoices", route="mcp/invoices")
|
|
212
|
+
|
|
213
|
+
@sales.tool()
|
|
214
|
+
def lookup_customer(customer_id: str) -> dict:
|
|
215
|
+
\"\"\"Look up a customer account.\"\"\"
|
|
216
|
+
...
|
|
217
|
+
|
|
218
|
+
@invoices.tool()
|
|
219
|
+
def get_invoice(invoice_id: str) -> dict:
|
|
220
|
+
\"\"\"Retrieve an invoice by ID.\"\"\"
|
|
221
|
+
...
|
|
222
|
+
|
|
223
|
+
Each server handles ``initialize``, ``tools/list``, and
|
|
224
|
+
``tools/call`` over JSON-RPC 2.0 POST.
|
|
225
|
+
"""
|
|
226
|
+
route_name = route or f"mcp/{name}"
|
|
227
|
+
return McpToolServer(name, route_name, self.func_app)
|
|
228
|
+
|
|
229
|
+
def scope(self, *scopes: str):
|
|
230
|
+
"""
|
|
231
|
+
Attach required OAuth2/JWT scope(s) to the function.
|
|
232
|
+
|
|
233
|
+
The scope is checked against the ``Authorization: Bearer <jwt>``
|
|
234
|
+
header on every request. Missing or insufficient scope returns 403.
|
|
235
|
+
|
|
236
|
+
Decorators are cumulative::
|
|
237
|
+
|
|
238
|
+
@sh.service()
|
|
239
|
+
@sh.scope('read:items')
|
|
240
|
+
@sh.scope('write:items') # both required
|
|
241
|
+
def update_item(id, value):
|
|
242
|
+
...
|
|
243
|
+
|
|
244
|
+
Multiple scopes may also be listed in a single call::
|
|
245
|
+
|
|
246
|
+
@sh.service()
|
|
247
|
+
@sh.scope('read:items', 'write:items')
|
|
248
|
+
def update_item(id, value):
|
|
249
|
+
...
|
|
250
|
+
"""
|
|
251
|
+
return require_scopes(*scopes)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stupidhuman-func
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Decorator library for turning plain Python functions into Azure HTTP-triggered Functions
|
|
5
|
+
Requires-Python: >=3.8
|
|
6
|
+
Requires-Dist: azure-functions>=1.24.0
|
|
7
|
+
Requires-Dist: azurefunctions-extensions-http-fastapi
|
|
8
|
+
Requires-Dist: httpx>=0.24.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
11
|
+
Dynamic: requires-python
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
azure_func/__init__.py,sha256=3MXoG61qo97dZwCMVdSkP7icDcpFVbQ8bUrBfrI0VrM,175
|
|
2
|
+
azure_func/app.py,sha256=uLQuvcYsGk7j_f1VzIVEFcy6QVZYfNJKVY4pPDePNww,1528
|
|
3
|
+
azure_func/decorator.py,sha256=88zTIBNjh3PhkOROy47_hK7ODXVsZkcjjvQMe85ksu8,8208
|
|
4
|
+
azure_func/mcp_server.py,sha256=6a_jv1BN9WM_J8IegN38q4qnZLsEWHW8OM1ITgsmuzk,6569
|
|
5
|
+
azure_func/openai.py,sha256=RoXR3A1Iu1zd5U4QdNrrAXeAkdm5FvxwjSozlFLcB00,4600
|
|
6
|
+
azure_func/scope.py,sha256=mp7DLG5vWV-QQ2r6zCQdQYv_aZ37JUYRnuGFxo8souw,2030
|
|
7
|
+
azure_func/service_handler.py,sha256=ACxBMYyX7app1mSBourf0l5xsVdwMXvkGM4-SNNQibo,8814
|
|
8
|
+
stupidhuman_func-0.2.0.dist-info/METADATA,sha256=9tKcWlGN4xtjcuctjT8mHs_b7wx2j4j91vCOsahfTVg,391
|
|
9
|
+
stupidhuman_func-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
stupidhuman_func-0.2.0.dist-info/top_level.txt,sha256=qGfL6OWfhL5OlS1EbdHRsWebaebdf9wkWNV59txHuRc,11
|
|
11
|
+
stupidhuman_func-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
azure_func
|