stupidhuman-func 0.2.0__tar.gz

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.
@@ -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,584 @@
1
+ # azure-func-decorator
2
+
3
+ A decorator library that turns plain Python functions into Azure HTTP-triggered
4
+ Functions or MCP tool triggers automatically. It handles parameter extraction
5
+ (query string, route params, and JSON body), type coercion, OAuth2/JWT scope
6
+ validation, and error responses.
7
+
8
+ ## Installation
9
+
10
+ **From GitHub via HTTPS:**
11
+
12
+ ```bash
13
+ pip install git+https://github.com/stupidhumanAI/python-func.git
14
+ ```
15
+
16
+ **From GitHub via SSH** (requires an SSH key configured for your GitHub account):
17
+
18
+ ```bash
19
+ pip install git+ssh://git@github.com/stupidhumanAI/python-func.git
20
+ ```
21
+
22
+ Pin to a specific tag or commit when you need reproducible installs:
23
+
24
+ ```bash
25
+ # HTTPS
26
+ pip install git+https://github.com/stupidhumanAI/python-func.git@v0.1.0
27
+ pip install git+https://github.com/stupidhumanAI/python-func.git@<commit-sha>
28
+
29
+ # SSH
30
+ pip install git+ssh://git@github.com/stupidhumanAI/python-func.git@v0.1.0
31
+ pip install git+ssh://git@github.com/stupidhumanAI/python-func.git@<commit-sha>
32
+ ```
33
+
34
+ Inside a `requirements.txt` or `pyproject.toml`:
35
+
36
+ ```
37
+ # requirements.txt (HTTPS)
38
+ git+https://github.com/stupidhumanAI/python-func.git@v0.1.0
39
+
40
+ # requirements.txt (SSH)
41
+ git+ssh://git@github.com/stupidhumanAI/python-func.git@v0.1.0
42
+ ```
43
+
44
+ ```toml
45
+ # pyproject.toml (HTTPS)
46
+ [project]
47
+ dependencies = [
48
+ "azure-func-decorator @ git+https://github.com/stupidhumanAI/python-func.git@v0.1.0",
49
+ ]
50
+
51
+ # pyproject.toml (SSH)
52
+ [project]
53
+ dependencies = [
54
+ "azure-func-decorator @ git+ssh://git@github.com/stupidhumanAI/python-func.git@v0.1.0",
55
+ ]
56
+ ```
57
+
58
+ ## Public API
59
+
60
+ Two top-level classes are exported from `azure_func`:
61
+
62
+ ```python
63
+ from azure_func import AzureFunctionApp # simple HTTP routes
64
+ from azure_func import ServiceHandler # HTTP routes + OAuth2 scope guards
65
+ ```
66
+
67
+ ---
68
+
69
+ ### `AzureFunctionApp`
70
+
71
+ A thin wrapper around `azure.functions.FunctionApp`. Use it when you do not
72
+ need scope-based access control.
73
+
74
+ ```python
75
+ app = AzureFunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS, **kwargs)
76
+ ```
77
+
78
+ | Parameter | Type | Default | Description |
79
+ |---|---|---|---|
80
+ | `http_auth_level` | `func.AuthLevel` | `ANONYMOUS` | Passed straight to the underlying `FunctionApp` |
81
+ | `**kwargs` | — | — | Forwarded to `FunctionApp.__init__` |
82
+
83
+ **`app.route(route=None, methods=None)`** — decorator
84
+
85
+ | Parameter | Type | Default | Description |
86
+ |---|---|---|---|
87
+ | `route` | `str \| None` | function name | URL path for the HTTP trigger |
88
+ | `methods` | `list[str] \| None` | `["GET", "POST"]` | Accepted HTTP methods |
89
+
90
+ Returns the **original function unchanged**, so it can be called directly in
91
+ tests and other code without going through the HTTP layer.
92
+
93
+ **`app.func_app`** — the underlying `azure.functions.FunctionApp` instance.
94
+
95
+ #### Example
96
+
97
+ ```python
98
+ import azure.functions as func
99
+ from azure_func import AzureFunctionApp
100
+
101
+ app = AzureFunctionApp()
102
+
103
+ @app.route()
104
+ def full_name(fname, lname):
105
+ return fname + " " + lname
106
+
107
+ # GET /full_name?fname=John&lname=Doe → {"result": "John Doe"}
108
+
109
+ @app.route(route="api/greet", methods=["GET"])
110
+ def greet(name, greeting="Hello"):
111
+ return f"{greeting}, {name}!"
112
+
113
+ # GET /api/greet?name=World → {"result": "Hello, World!"}
114
+
115
+ # The original function is still directly callable:
116
+ assert full_name("John", "Doe") == "John Doe"
117
+ ```
118
+
119
+ ---
120
+
121
+ ### `ServiceHandler`
122
+
123
+ Like `AzureFunctionApp` but adds OAuth2/JWT scope validation via
124
+ `@sh.scope()`. Use it when endpoints must be protected by bearer tokens.
125
+
126
+ ```python
127
+ sh = ServiceHandler(http_auth_level=func.AuthLevel.ANONYMOUS, **kwargs)
128
+ ```
129
+
130
+ Constructor parameters are identical to `AzureFunctionApp`.
131
+
132
+ **`sh.service(route=None, methods=None)`** — decorator
133
+
134
+ Same parameters as `app.route()`. Reads any scope metadata placed on the
135
+ function by `@sh.scope()` and enforces it on every request.
136
+
137
+ **`sh.anonymous(route=None, methods=None)`** — decorator
138
+
139
+ Same parameters as `sh.service()`. Registers the function as a public HTTP
140
+ trigger with no authentication or scope requirements. Use this instead of
141
+ `@sh.service()` to make the intent explicit that the endpoint is publicly
142
+ accessible, even when other endpoints on the same handler use `@sh.scope()`.
143
+
144
+ **`sh.scope(*scopes)`** — decorator
145
+
146
+ Attaches required OAuth2 scope strings to a function. The check is an **AND**:
147
+ all listed scopes must be present in the token.
148
+
149
+ | Parameter | Type | Description |
150
+ |---|---|---|
151
+ | `*scopes` | `str` | One or more scope strings to require |
152
+
153
+ `@sh.scope()` is **cumulative**: stacking multiple calls or listing multiple
154
+ scopes in one call both accumulate onto the same set of required scopes.
155
+ Scope metadata is stored on the function object under the attribute
156
+ `_required_scopes` and is read by `@sh.service()` at decoration time.
157
+
158
+ `@sh.scope()` must be applied **below** `@sh.service()` in the source (i.e.
159
+ closer to the function definition).
160
+
161
+ #### Example
162
+
163
+ ```python
164
+ from azure_func import ServiceHandler
165
+
166
+ sh = ServiceHandler()
167
+
168
+ @sh.anonymous()
169
+ def health():
170
+ return "ok"
171
+
172
+ # GET /health → {"result": "ok"} (no token required)
173
+
174
+ @sh.service()
175
+ @sh.scope("read:items")
176
+ def get_item(item_id: int):
177
+ return {"id": item_id}
178
+
179
+ @sh.service(methods=["POST"])
180
+ @sh.scope("read:items")
181
+ @sh.scope("write:items") # cumulative — both scopes required
182
+ def create_item(name, price: float):
183
+ return {"name": name, "price": price}
184
+
185
+ # Equivalent single call:
186
+ # @sh.scope("read:items", "write:items")
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Parameter handling
192
+
193
+ Parameters are resolved from the HTTP request using this priority order:
194
+
195
+ 1. **Query string** — `?key=value`
196
+ 2. **Route params** — path segments defined in the route template (e.g. `route="items/{item_id}"`)
197
+ 3. **JSON body** — `Content-Type: application/json` with `{"key": "value"}`
198
+
199
+ Query string always wins when the same key appears in multiple sources.
200
+
201
+ All incoming values arrive as strings. If a function parameter has a type
202
+ annotation the value is cast before the function is called.
203
+
204
+ ### Type coercion
205
+
206
+ | Annotation | Behaviour |
207
+ |---|---|
208
+ | `int` | `int(value)` |
209
+ | `float` | `float(value)` |
210
+ | `bool` | `False` for `"false"`, `"0"`, `"no"`, `""`; `True` for everything else (case-insensitive) |
211
+ | `str` | no-op |
212
+ | any other callable | called with the string value |
213
+
214
+ Failed coercion returns **HTTP 400** before the function is invoked.
215
+
216
+ ### Default values
217
+
218
+ Parameters with Python default values are optional in the request. Parameters
219
+ without defaults are required; omitting them returns **HTTP 400**.
220
+
221
+ ```python
222
+ @sh.service()
223
+ def calc(n: int, op: str = "double"):
224
+ return n * 2 if op == "double" else n
225
+
226
+ # GET /calc?n=5 → {"result": 10}
227
+ # GET /calc?n=5&op=id → {"result": 5}
228
+ # GET /calc → 400 {"error": "Missing required parameter(s): n"}
229
+ ```
230
+
231
+ Route parameters are declared in the `route` template using `{param}` syntax and
232
+ resolved automatically like any other parameter:
233
+
234
+ ```python
235
+ @sh.service(route="items/{item_id}", methods=["GET"])
236
+ def get_item(item_id: int):
237
+ return {"id": item_id}
238
+
239
+ # GET /items/42 → {"result": {"id": 42}}
240
+ ```
241
+
242
+ ---
243
+
244
+ ## Response format
245
+
246
+ Every response body is JSON.
247
+
248
+ ### Success
249
+
250
+ ```json
251
+ {"result": <return value of the function>}
252
+ ```
253
+
254
+ HTTP status: **200**
255
+
256
+ ### Error responses
257
+
258
+ | Situation | Status | Body |
259
+ |---|---|---|
260
+ | Missing required parameter(s) | 400 | `{"error": "Missing required parameter(s): x, y"}` |
261
+ | Type coercion failure | 400 | `{"error": "Invalid value for 'n': expected int, got 'abc'", "detail": "<exception message>"}` |
262
+ | Missing or insufficient scope | 403 | `{"error": "Insufficient scope", "required": ["scope1", ...]}` |
263
+ | Unhandled exception in function | 500 | `{"error": "Internal server error", "detail": "<exception message>"}` |
264
+
265
+ ---
266
+
267
+ ## OAuth2 / JWT scope validation
268
+
269
+ When `@sh.scope()` is used, every request must include an `Authorization`
270
+ header with a Bearer JWT:
271
+
272
+ ```
273
+ Authorization: Bearer <jwt>
274
+ ```
275
+
276
+ Scopes are read from the JWT payload (no signature verification is performed —
277
+ the Azure Functions host or an API gateway is expected to validate the token):
278
+
279
+ - `scp` claim — Azure AD format, space-separated string
280
+ - `scope` claim — standard OAuth2, space-separated string or list
281
+
282
+ If the header is absent, malformed, or the token does not contain all required
283
+ scopes, the handler returns **HTTP 403** and the function is never called.
284
+
285
+ ---
286
+
287
+ ## Project layout for Azure Functions
288
+
289
+ A minimal Azure Functions v2 (Python) project using this library:
290
+
291
+ ```
292
+ my_project/
293
+ ├── function_app.py ← entry point; register all routes here
294
+ ├── host.json
295
+ ├── local.settings.json
296
+ └── requirements.txt
297
+ ```
298
+
299
+ **`function_app.py`**:
300
+
301
+ ```python
302
+ import azure.functions as func
303
+ from azure_func import AzureFunctionApp
304
+
305
+ app = AzureFunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
306
+
307
+ @app.route()
308
+ def hello(name: str):
309
+ return f"Hello, {name}!"
310
+ ```
311
+
312
+ The `app` (or `sh.func_app` when using `ServiceHandler`) must be named `app`
313
+ in `function_app.py` — that is what the Azure Functions runtime discovers.
314
+
315
+ When using `ServiceHandler`, expose `sh.func_app` as `app`:
316
+
317
+ ```python
318
+ from azure_func import ServiceHandler
319
+ import azure.functions as func
320
+
321
+ sh = ServiceHandler(http_auth_level=func.AuthLevel.ANONYMOUS)
322
+ app = sh.func_app # runtime entry point
323
+
324
+ @sh.service()
325
+ @sh.scope("api:access")
326
+ def protected(value: str):
327
+ return value.upper()
328
+ ```
329
+
330
+ ---
331
+
332
+ ## Queue triggers
333
+
334
+ `@sh.queue(queue_name)` registers a function as an Azure Storage Queue trigger.
335
+ The function is called automatically whenever a message arrives on the specified
336
+ queue, receiving a `func.QueueMessage` object.
337
+
338
+ ### `sh.queue(queue_name, connection="AzureWebJobsStorage")` — decorator
339
+
340
+ | Parameter | Type | Default | Description |
341
+ |---|---|---|---|
342
+ | `queue_name` | `str` | — | Name of the storage queue to listen on |
343
+ | `connection` | `str` | `"AzureWebJobsStorage"` | App setting name for the storage connection string |
344
+
345
+ ```python
346
+ import azure.functions as func
347
+ from azure_func import ServiceHandler
348
+
349
+ sh = ServiceHandler()
350
+ app = sh.func_app
351
+
352
+ @sh.queue("my-queue")
353
+ def process_message(msg: func.QueueMessage):
354
+ data = msg.get_json() # parse JSON body
355
+ raw = msg.get_body().decode() # raw string body
356
+ print(f"Received: {data}")
357
+
358
+ @sh.queue("orders", connection="OrdersStorageConnection")
359
+ def process_order(msg: func.QueueMessage):
360
+ order = msg.get_json()
361
+ ...
362
+ ```
363
+
364
+ Unhandled exceptions are logged and re-raised, which causes the Azure Functions
365
+ runtime to retry the message according to the queue's poison-message policy.
366
+
367
+ ---
368
+
369
+ ## Streaming responses
370
+
371
+ `@sh.stream()` registers an async generator function as an HTTP trigger that
372
+ streams its output using the
373
+ [`azurefunctions-extensions-http-fastapi`](https://pypi.org/project/azurefunctions-extensions-http-fastapi/)
374
+ extension. This is the correct approach for SSE, LLM token streaming, and any
375
+ response too large to buffer.
376
+
377
+ All handlers (`@sh.service()`, `@sh.anonymous()`, `@sh.stream()`) now use
378
+ the FastAPI-compatible `Request` type from this extension, so the request model
379
+ is consistent across the whole app.
380
+
381
+ ### `sh.stream(route=None, methods=None, media_type="text/event-stream")` — decorator
382
+
383
+ | Parameter | Type | Default | Description |
384
+ |---|---|---|---|
385
+ | `route` | `str \| None` | function name | URL path for the HTTP trigger |
386
+ | `methods` | `list[str] \| None` | `["GET", "POST"]` | Accepted HTTP methods |
387
+ | `media_type` | `str` | `"text/event-stream"` | `Content-Type` of the streamed response |
388
+
389
+ The decorated function must be an **async generator** (`async def` with `yield`).
390
+ Parameters are extracted and type-coerced exactly like `@sh.service()`.
391
+ `@sh.scope()` is supported and enforced before streaming begins.
392
+
393
+ ```python
394
+ from azure_func import ServiceHandler
395
+
396
+ sh = ServiceHandler()
397
+ app = sh.func_app
398
+
399
+ @sh.stream()
400
+ async def count(n: int):
401
+ """Stream n numbers as Server-Sent Events."""
402
+ for i in range(n):
403
+ yield f"data: {i}\n\n"
404
+
405
+ # GET /count?n=3 → streams:
406
+ # data: 0
407
+ #
408
+ # data: 1
409
+ #
410
+ # data: 2
411
+ #
412
+
413
+ @sh.stream(media_type="application/x-ndjson")
414
+ @sh.scope("read:data")
415
+ async def search_results(query: str):
416
+ """Stream search results as newline-delimited JSON."""
417
+ import json
418
+ for result in run_search(query):
419
+ yield json.dumps(result) + "\n"
420
+ ```
421
+
422
+ ### Required environment variables
423
+
424
+ Add these to `local.settings.json` for local development and to Azure App
425
+ Settings for deployed apps:
426
+
427
+ ```json
428
+ {
429
+ "IsEncrypted": false,
430
+ "Values": {
431
+ "AzureWebJobsStorage": "UseDevelopmentStorage=true",
432
+ "FUNCTIONS_WORKER_RUNTIME": "python",
433
+ "PYTHON_ENABLE_INIT_INDEXING": "1",
434
+ "PYTHON_ISOLATE_WORKER_DEPENDENCIES": "1"
435
+ }
436
+ }
437
+ ```
438
+
439
+ `PYTHON_ENABLE_INIT_INDEXING=1` is required for all deployments.
440
+ `PYTHON_ISOLATE_WORKER_DEPENDENCIES=1` is required on the Linux Consumption
441
+ plan and recommended elsewhere to avoid protobuf/grpcio conflicts.
442
+
443
+ ### Limitations
444
+
445
+ - Does not work on **Premium** or **Dedicated** hosting plans (known Azure issue)
446
+ - Works on **Consumption** and **Flex Consumption** plans
447
+
448
+ ---
449
+
450
+ ## Static files
451
+
452
+ `@sh.static(folder)` registers a catch-all GET route that serves files from a
453
+ local folder at the root URL path.
454
+
455
+ ```python
456
+ @sh.static('public')
457
+ def serve_static():
458
+ pass
459
+
460
+ # GET /style.css → ./public/style.css
461
+ # GET /js/app.js → ./public/js/app.js
462
+ # GET /images/logo.png → ./public/images/logo.png
463
+ ```
464
+
465
+ | Situation | Status |
466
+ |---|---|
467
+ | File found | 200 with correct `Content-Type` |
468
+ | File not found | 404 |
469
+ | Path traversal attempt | 404 |
470
+
471
+ The `folder` argument is relative to the function app's working directory.
472
+ MIME types are detected automatically from the file extension.
473
+
474
+ Because the catch-all route `{*filepath}` has lower specificity than named
475
+ routes, all your `@sh.service()` and `@sh.tool()` endpoints continue to take
476
+ precedence.
477
+
478
+ ---
479
+
480
+ ## MCP tools
481
+
482
+ `ServiceHandler` supports registering functions as [Azure Functions MCP tool
483
+ triggers](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-mcp-tool-trigger)
484
+ via `@sh.tool()`. This turns your function app into a remote MCP server that
485
+ any MCP client (e.g. VS Code Agent Mode, Claude Desktop) can connect to.
486
+
487
+ Requires `azure-functions >= 1.25.0b2` (still in pre-release — install explicitly
488
+ with `pip install "azure-functions>=1.25.0b2"`) and the **preview extension bundle** in
489
+ `host.json` (included in this repo).
490
+
491
+ ### `sh.tool()` — decorator
492
+
493
+ Registers the function as an MCP tool trigger. The **function name** becomes
494
+ the tool name and the **docstring** becomes the tool description. Each
495
+ parameter is automatically registered as an MCP tool property.
496
+
497
+ ```python
498
+ from azure_func import ServiceHandler
499
+
500
+ sh = ServiceHandler()
501
+ app = sh.func_app # runtime entry point
502
+
503
+ @sh.tool()
504
+ def hello() -> str:
505
+ """Say hello."""
506
+ return "Hello from MCP!"
507
+
508
+ @sh.tool()
509
+ @sh.tool_property("item_id", "The ID of the item to retrieve.")
510
+ def get_item(item_id: int) -> str:
511
+ """Get an item by ID."""
512
+ return str(item_id)
513
+
514
+ @sh.tool()
515
+ @sh.tool_property("name", "The name of the item.")
516
+ @sh.tool_property("price", "The price of the item in USD.")
517
+ def create_item(name: str, price: float) -> str:
518
+ """Create a new item with a name and price."""
519
+ return f"Created {name} at ${price}"
520
+ ```
521
+
522
+ Property descriptions are optional — parameters without a `@sh.tool_property()`
523
+ are still registered as MCP tool properties, just without a description.
524
+
525
+ ### Authentication
526
+
527
+ MCP tools are **not** protected by `@sh.scope()`. Instead, authentication is
528
+ handled at the Azure host level via the `mcp_extension` system key. When
529
+ deployed, clients must include this key either as a `?code=` query parameter
530
+ or as the `x-functions-key` header.
531
+
532
+ Retrieve the key from the portal (**Functions → App keys → System keys →
533
+ `mcp_extension`**) or via the CLI:
534
+
535
+ ```bash
536
+ az functionapp keys list --name <app> --resource-group <rg> --query systemKeys
537
+ ```
538
+
539
+ ### Connecting an MCP client
540
+
541
+ **VS Code (Agent Mode)** — add to `.vscode/mcp.json`:
542
+
543
+ ```json
544
+ {
545
+ "servers": {
546
+ "my-func-mcp": {
547
+ "type": "http",
548
+ "url": "https://<funcappname>.azurewebsites.net/runtime/webhooks/mcp",
549
+ "headers": {
550
+ "x-functions-key": "<mcp_extension key>"
551
+ }
552
+ }
553
+ }
554
+ }
555
+ ```
556
+
557
+ **MCP Inspector** — use the URL with `?code=<mcp_extension key>` appended.
558
+
559
+ ### host.json
560
+
561
+ The `mcpToolTrigger` binding is a preview feature and requires the preview
562
+ extension bundle. The `host.json` in this repo is already configured:
563
+
564
+ ```json
565
+ {
566
+ "version": "2.0",
567
+ "extensionBundle": {
568
+ "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview",
569
+ "version": "[4.*, 5.0.0)"
570
+ }
571
+ }
572
+ ```
573
+
574
+ ---
575
+
576
+ ## Running tests
577
+
578
+ ```bash
579
+ pip install -e '.[dev]'
580
+ pytest
581
+ ```
582
+
583
+ Tests mock the Azure SDK (`azure.functions`) entirely, so no Azure account or
584
+ Functions runtime is needed.
@@ -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"]
@@ -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