fix: Correct vararg path parameter handling in _asgi_app_http --> Ensure path_params are passed individually to handlers with *args by using func_args.extend(path_params_copy) and skipping self parameter. Fixes 404 errors for routes like /api/users/{user}/records/{record} and removes extraneous None in path_params.

Commit 3f63476 · patx · 2025-06-03T19:23:27-04:00

Changeset
3f634764efaea31d7710dff749341e634c7036b9
Parents
4f8f1964c6407e9e8375beb80b27bbe16b26c5d7

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/MicroPie.py b/MicroPie.py
index 7f76b78..dfc2c31 100644
--- a/MicroPie.py
+++ b/MicroPie.py
@@ -298,10 +298,18 @@ class App:
             # Build function arguments from path, query, body, files, and session values.
             sig = inspect.signature(handler)
             func_args: List[Any] = []
+            path_params_copy = request.path_params[:]  # Create a copy to avoid modifying original
             for param in sig.parameters.values():
+                if param.name == "self":  # Skip self parameter
+                    continue
+                if param.kind == inspect.Parameter.VAR_POSITIONAL:
+                    # Pass all remaining path_params for *args
+                    func_args.extend(path_params_copy)
+                    path_params_copy = []  # Clear to prevent reuse
+                    continue  # Skip appending anything else
                 param_value = None
-                if request.path_params:
-                    param_value = request.path_params.pop(0)
+                if path_params_copy:
+                    param_value = path_params_copy.pop(0)
                 elif param.name in request.query_params:
                     param_value = request.query_params[param.name][0]
                 elif param.name in request.body_params:
diff --git a/examples/chatroom/kenobi.py b/examples/chatroom/kenobi.py
deleted file mode 100644
index 9548a50..0000000
--- a/examples/chatroom/kenobi.py
+++ /dev/null
@@ -1,378 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""
-KenobiDB is a small document-based DB, supporting simple usage including
-insertion, removal, and basic search.
-Written by Harrison Erd (https://patx.github.io/)
-https://patx.github.io/kenobi/
-"""
-# Copyright Harrison Erd
-#
-# Redistribution and use in source and binary forms, with or without
-# modification, are permitted provided that the following conditions are met:
-#
-# 1. Redistributions of source code must retain the above copyright notice,
-#    this list of conditions and the following disclaimer.
-# 2. Redistributions in binary form must reproduce the above copyright notice,
-#    this list of conditions and the following disclaimer in the documentation
-#    and/or other materials provided with the distribution.
-# 3. Neither the name of the copyright holder nor the names of its contributors
-#    may be used to endorse or promote products derived from this software
-#    without specific prior written permission.
-#
-# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-# POSSIBILITY OF SUCH DAMAGE.
-
-import os
-import json
-import sqlite3
-from threading import RLock
-from concurrent.futures import ThreadPoolExecutor
-import re
-
-class KenobiDB:
-    """
-    A lightweight document-based database built on SQLite. Supports basic
-    operations such as insert, remove, search, update, and asynchronous
-    execution.
-    """
-
-    def __init__(self, file):
-        """
-        Initialize the KenobiDB instance.
-
-        Args:
-            file (str): Path to the SQLite file. If it does not exist,
-                it will be created.
-        """
-        self.file = os.path.expanduser(file)
-        self._lock = RLock()
-        self.executor = ThreadPoolExecutor(max_workers=5)
-        self._regexp_connections = set()  # Track connections with REGEXP added
-        self._connection = sqlite3.connect(self.file, check_same_thread=False)
-        self._add_regexp_support(self._connection)  # Add REGEXP support lazily
-        self._initialize_db()
-
-    def _initialize_db(self):
-        """
-        Create the table and index if they do not exist, and set
-        journal mode to WAL.
-        """
-        with self._lock:
-            self._connection.execute("""
-                CREATE TABLE IF NOT EXISTS documents (
-                    id INTEGER PRIMARY KEY AUTOINCREMENT,
-                    data TEXT NOT NULL
-                )
-            """)
-            self._connection.execute("""
-                CREATE INDEX IF NOT EXISTS idx_key
-                ON documents (
-                    json_extract(data, '$.key')
-                )
-            """)
-            self._connection.execute("PRAGMA journal_mode=WAL;")
-
-    @staticmethod
-    def _add_regexp_support(conn):
-        """
-        Add REGEXP function support to the SQLite connection.
-        """
-        def regexp(pattern, value):
-            return re.search(pattern, value) is not None
-        conn.create_function("REGEXP", 2, regexp)
-
-    def _get_connection(self):
-        """
-        Return the active SQLite connection.
-        """
-        return self._connection
-
-    def insert(self, document):
-        """
-        Insert a single document (dict) into the database.
-
-        Args:
-            document (dict): The document to insert.
-
-        Returns:
-            bool: True upon successful insertion.
-
-        Raises:
-            TypeError: If the provided document is not a dictionary.
-        """
-        if not isinstance(document, dict):
-            raise TypeError("Must insert a dict")
-        with self._lock:
-            self._connection.execute(
-                "INSERT INTO documents (data) VALUES (?)",
-                (json.dumps(document),)
-            )
-            self._connection.commit()
-            return True
-
-    def insert_many(self, document_list):
-        """
-        Insert multiple documents (list of dicts) into the database.
-
-        Args:
-            document_list (list): The list of documents to insert.
-
-        Returns:
-            bool: True upon successful insertion.
-
-        Raises:
-            TypeError: If the provided object is not a list of dicts.
-        """
-        if (
-            not isinstance(document_list, list)
-            or not all(isinstance(doc, dict) for doc in document_list)
-        ):
-            raise TypeError("Must insert a list of dicts")
-        with self._lock:
-            self._connection.executemany(
-                "INSERT INTO documents (data) VALUES (?)",
-                [(json.dumps(doc),) for doc in document_list]
-            )
-            self._connection.commit()
-            return True
-
-    def remove(self, key, value):
-        """
-        Remove all documents where the given key matches the specified value.
-
-        Args:
-            key (str): The field name to match.
-            value (Any): The value to match.
-
-        Returns:
-            int: Number of documents removed.
-
-        Raises:
-            ValueError: If 'key' is empty or 'value' is None.
-        """
-        if not key or not isinstance(key, str):
-            raise ValueError("key must be a non-empty string")
-        if value is None:
-            raise ValueError("value cannot be None")
-        query = (
-            "DELETE FROM documents "
-            "WHERE json_extract(data, '$.' || ?) = ?"
-        )
-        with self._lock:
-            result = self._connection.execute(query, (key, value))
-            self._connection.commit()
-            return result.rowcount
-
-    def update(self, id_key, id_value, new_dict):
-        """
-        Update documents that match (id_key == id_value) by merging new_dict.
-
-        Args:
-            id_key (str): The field name to match.
-            id_value (Any): The value to match.
-            new_dict (dict): A dictionary of changes to apply.
-
-        Returns:
-            bool: True if at least one document was updated, False otherwise.
-
-        Raises:
-            TypeError: If new_dict is not a dict.
-            ValueError: If id_key is invalid or id_value is None.
-        """
-        if not isinstance(new_dict, dict):
-            raise TypeError("new_dict must be a dictionary")
-        if not id_key or not isinstance(id_key, str):
-            raise ValueError("id_key must be a non-empty string")
-        if id_value is None:
-            raise ValueError("id_value cannot be None")
-
-        select_query = (
-            "SELECT data FROM documents "
-            "WHERE json_extract(data, '$.' || ?) = ?"
-        )
-        update_query = (
-            "UPDATE documents "
-            "SET data = ? "
-            "WHERE json_extract(data, '$.' || ?) = ?"
-        )
-        with self._lock:
-            cursor = self._connection.execute(select_query, (id_key, id_value))
-            documents = cursor.fetchall()
-            if not documents:
-                return False
-            for row in documents:
-                document = json.loads(row[0])
-                if not isinstance(document, dict):
-                    continue
-                document.update(new_dict)
-                self._connection.execute(
-                    update_query,
-                    (json.dumps(document), id_key, id_value)
-                )
-            self._connection.commit()
-            return True
-
-    def purge(self):
-        """
-        Remove all documents from the database.
-
-        Returns:
-            bool: True upon successful purge.
-        """
-        with self._lock:
-            self._connection.execute("DELETE FROM documents")
-            self._connection.commit()
-            return True
-
-    def all(self, limit=100, offset=0):
-        """
-        Return a paginated list of all documents.
-
-        Args:
-            limit (int): The maximum number of documents to return.
-            offset (int): The starting point for retrieval.
-
-        Returns:
-            list: A list of all documents (dicts).
-        """
-        query = "SELECT data FROM documents LIMIT ? OFFSET ?"
-        with self._lock:
-            cursor = self._connection.execute(query, (limit, offset))
-            return [json.loads(row[0]) for row in cursor.fetchall()]
-
-    def search(self, key, value, limit=100, offset=0):
-        """
-        Return a list of documents matching (key == value).
-
-        Args:
-            key (str): The document field to match on.
-            value (Any): The value for which to search.
-            limit (int): The maximum number of documents to return.
-            offset (int): The starting point for retrieval.
-
-        Returns:
-            list: A list of matching documents (dicts).
-        """
-        if not key or not isinstance(key, str):
-            raise ValueError("Key must be a non-empty string")
-
-        query = (
-            "SELECT data FROM documents "
-            "WHERE json_extract(data, '$.' || ?) = ? "
-            "LIMIT ? OFFSET ?"
-        )
-        with self._lock:
-            cursor = self._connection.execute(query, (key, value, limit, offset))
-            return [json.loads(row[0]) for row in cursor.fetchall()]
-
-    def search_pattern(self, key, pattern, limit=100, offset=0):
-        """
-        Search documents matching a regex pattern.
-
-        Args:
-            key (str): The document field to match on.
-            pattern (str): The regex pattern to match.
-            limit (int): The maximum number of documents to return.
-            offset (int): The starting point for retrieval.
-
-        Returns:
-            list: A list of matching documents (dicts).
-
-        Raises:
-            ValueError: If the key or pattern is invalid.
-        """
-        if not key or not isinstance(key, str):
-            raise ValueError("key must be a non-empty string")
-        if not pattern or not isinstance(pattern, str):
-            raise ValueError("pattern must be a non-empty string")
-
-        query = """
-            SELECT data FROM documents
-            WHERE json_extract(data, '$.' || ?) REGEXP ?
-            LIMIT ? OFFSET ?
-        """
-        with self._lock:
-            cursor = self._connection.execute(query, (key, pattern, limit, offset))
-            return [json.loads(row[0]) for row in cursor.fetchall()]
-
-    def find_any(self, key, value_list):
-        """
-        Return documents where key matches any value in value_list.
-
-        Args:
-            key (str): The document field to match on.
-            value_list (list): A list of possible values.
-
-        Returns:
-            list: A list of matching documents.
-        """
-        placeholders = ", ".join(["?"] * len(value_list))
-        query = f"""
-            SELECT DISTINCT documents.data
-            FROM documents, json_each(documents.data, '$.' || ?)
-            WHERE json_each.value IN ({placeholders})
-        """
-        with self._lock:
-            cursor = self._connection.execute(query, [key] + value_list)
-            return [json.loads(row[0]) for row in cursor.fetchall()]
-
-    def find_all(self, key, value_list):
-        """
-        Return documents where the key contains all values in value_list.
-
-        Args:
-            key (str): The field to match.
-            value_list (list): The required values to match.
-
-        Returns:
-            list: A list of matching documents.
-        """
-        placeholders = ", ".join(["?"] * len(value_list))
-        query = f"""
-            SELECT documents.data
-            FROM documents
-            WHERE (
-                SELECT COUNT(DISTINCT value)
-                FROM json_each(documents.data, '$.' || ?)
-                WHERE value IN ({placeholders})
-            ) = ?
-        """
-        with self._lock:
-            cursor = self._connection.execute(
-                query, [key] + value_list + [len(value_list)]
-            )
-            return [json.loads(row[0]) for row in cursor.fetchall()]
-
-    def execute_async(self, func, *args, **kwargs):
-        """
-        Execute a function asynchronously using a thread pool.
-
-        Args:
-            func (callable): The function to execute.
-            *args: Arguments for the function.
-            **kwargs: Keyword arguments for the function.
-
-        Returns:
-            concurrent.futures.Future: A Future object representing
-            the execution.
-        """
-        return self.executor.submit(func, *args, **kwargs)
-
-    def close(self):
-        """
-        Shutdown the thread pool executor and close the database connection.
-        """
-        self.executor.shutdown()
-        with self._lock:
-            self._connection.close()
-
diff --git a/examples/socketio/chatroom.py b/examples/socketio/basic/chatroom.py
similarity index 100%
rename from examples/socketio/chatroom.py
rename to examples/socketio/basic/chatroom.py
diff --git a/examples/socketio/templates/chat.html b/examples/socketio/basic/templates/chat.html
similarity index 100%
rename from examples/socketio/templates/chat.html
rename to examples/socketio/basic/templates/chat.html
diff --git a/examples/socketio/templates/index.html b/examples/socketio/basic/templates/index.html
similarity index 100%
rename from examples/socketio/templates/index.html
rename to examples/socketio/basic/templates/index.html
diff --git a/examples/socketio/templates/stream.html b/examples/socketio/basic/templates/stream.html
similarity index 100%
rename from examples/socketio/templates/stream.html
rename to examples/socketio/basic/templates/stream.html
diff --git a/examples/socketio/templates/watch.html b/examples/socketio/basic/templates/watch.html
similarity index 100%
rename from examples/socketio/templates/watch.html
rename to examples/socketio/basic/templates/watch.html
diff --git a/examples/socketio/webcam.py b/examples/socketio/basic/webcam.py
similarity index 100%
rename from examples/socketio/webcam.py
rename to examples/socketio/basic/webcam.py
diff --git a/examples/chatroom/app.py b/examples/socketio/chatroom/app.py
similarity index 100%
rename from examples/chatroom/app.py
rename to examples/socketio/chatroom/app.py
diff --git a/examples/chatroom/templates/chat.html b/examples/socketio/chatroom/templates/chat.html
similarity index 100%
rename from examples/chatroom/templates/chat.html
rename to examples/socketio/chatroom/templates/chat.html
diff --git a/examples/streaming/sse/app.py b/examples/streaming/sse/app.py
new file mode 100644
index 0000000..bc2eec1
--- /dev/null
+++ b/examples/streaming/sse/app.py
@@ -0,0 +1,55 @@
+import asyncio
+import random
+from MicroPie import App
+
+
+class MyApp(App):
+
+    async def index(self):
+        return """
+        <!DOCTYPE html>
+        <html>
+        <head>
+            <title>Server-Sent Events Demo with MicroPie</title>
+        </head>
+        <body>
+            <h1>Server-Sent Events with MicroPie</h1>
+            <div id="output"></div>
+            <script>
+                const source = new EventSource('/events');
+                source.onmessage = function(event) {
+                    const newElement = document.createElement("p");
+                    newElement.textContent = `Received: ${event.data}`;
+                    document.getElementById("output").appendChild(newElement);
+                };
+                source.onerror = function() {
+                    console.log("SSE error occurred");
+                };
+            </script>
+        </body>
+        </html>
+        """
+
+    # SSE endpoint to stream random numbers
+    async def events(self):
+        async def event_generator():
+            try:
+                while True:
+                    # Generate random number
+                    data = f"Random number: {random.randint(1, 100)}"
+                    # Yield SSE formatted data
+                    yield f"data: {data}\n\n".encode("utf-8")
+                    # Wait for 1 second before sending next event
+                    await asyncio.sleep(1)
+            except asyncio.CancelledError:
+                print("Client disconnected")
+                raise
+
+        # Return status code, async generator, and headers for SSE
+        return 200, event_generator(), [("Content-Type", "text/event-stream")]
+
+app = MyApp()
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/unstable/MicroPie.py b/unstable/MicroPie.py
index 4a0dff0..9780353 100644
--- a/unstable/MicroPie.py
+++ b/unstable/MicroPie.py
@@ -136,14 +136,6 @@ class Request:
 class WebSocket:
     """Represents a WebSocket connection in the MicroPie framework."""
     def __init__(self, scope: Dict[str, Any], receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None:
-        """
-        Initialize a new WebSocket instance.
-
-        Args:
-            scope: The ASGI scope dictionary for the WebSocket connection.
-            receive: The callable to receive ASGI events.
-            send: The callable to send ASGI events.
-        """
         self.scope = scope
         self.receive = receive
         self.send = send
@@ -152,46 +144,56 @@ class WebSocket:
             k.decode("utf-8", errors="replace").lower(): v.decode("utf-8", errors="replace")
             for k, v in scope.get("headers", [])
         }
+        self._closed = False
 
     async def accept(self) -> None:
-        """Accept the WebSocket connection."""
-        await self.send({
-            "type": "websocket.accept"
-        })
+        if not self._closed:
+            await self.send({
+                "type": "websocket.accept"
+            })
 
     async def send_text(self, data: str) -> None:
-        """Send text data over the WebSocket."""
-        await self.send({
-            "type": "websocket.send",
-            "text": data
-        })
+        if not self._closed:
+            await self.send({
+                "type": "websocket.send",
+                "text": data
+            })
 
     async def send_json(self, data: Any) -> None:
-        """Send JSON data over the WebSocket."""
-        await self.send({
-            "type": "websocket.send",
-            "text": json.dumps(data)
-        })
+        if not self._closed:
+            await self.send({
+                "type": "websocket.send",
+                "text": json.dumps(data)
+            })
 
     async def receive_text(self) -> str:
-        """Receive text data from the WebSocket."""
+        if self._closed:
+            raise ConnectionError("WebSocket already closed")
         message = await self.receive()
+        print(f"Raw WebSocket message: {message}")  # Debug raw message
         if message["type"] == "websocket.disconnect":
+            self._closed = True
             raise ConnectionError("WebSocket disconnected")
         return message.get("text", "")
 
     async def receive_json(self) -> Any:
-        """Receive JSON data from the WebSocket."""
         text = await self.receive_text()
-        return json.loads(text)
+        if not text.strip():
+            print("Received empty WebSocket message")
+            return None
+        try:
+            return json.loads(text)
+        except json.JSONDecodeError as e:
+            print(f"Invalid JSON received: {text}, error: {e}")
+            raise
 
     async def close(self, code: int = 1000) -> None:
-        """Close the WebSocket connection."""
-        await self.send({
-            "type": "websocket.close",
-            "code": code
-        })
-
+        if not self._closed:
+            await self.send({
+                "type": "websocket.close",
+                "code": code
+            })
+            self._closed = True
 
 # -----------------------------
 # Middleware Abstraction