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 ADDED
@@ -0,0 +1,5 @@
1
+ from .app import AzureFunctionApp
2
+ from .service_handler import ServiceHandler
3
+ from .openai import stream_chat
4
+
5
+ __all__ = ["AzureFunctionApp", "ServiceHandler", "stream_chat"]
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
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ azure_func