improve async-ness, jinja2 async full, aiofiles introduced as codependency with multipart

Commit 9959bf6 · patx · 2025-02-04T02:31:44-05:00

Changeset
9959bf6e8480cc01eca2c3098283d817750a36d3
Parents
7394de5e6d754e84611eabfca357030d41324c28

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/MicroPie.py b/MicroPie.py
index a1e4087..c618989 100644
--- a/MicroPie.py
+++ b/MicroPie.py
@@ -43,7 +43,7 @@ from typing import Any, Awaitable, BinaryIO, Callable, Dict, List, Optional, Tup
 from urllib.parse import parse_qs
 
 try:
-    from jinja2 import Environment, FileSystemLoader
+    from jinja2 import Environment, FileSystemLoader, select_autoescape
     JINJA_INSTALLED = True
 except ImportError:
     JINJA_INSTALLED = False
@@ -51,6 +51,7 @@ except ImportError:
 try:
     from multipart import PushMultipartParser, MultipartSegment
     MULTIPART_INSTALLED = True
+    import aiofiles
 except ImportError:
     MULTIPART_INSTALLED = False
 
@@ -87,7 +88,10 @@ class App:
         If Jinja2 is installed, set up the template environment.
         """
         if JINJA_INSTALLED:
-            self.env: Optional[Environment] = Environment(loader=FileSystemLoader("templates"))
+            self.env: Optional[Environment] = Environment(
+                loader=FileSystemLoader("templates"),
+                autoescape=select_autoescape(["html", "xml"]),
+                enable_async=True)
         else:
             self.env = None
         self.sessions: Dict[str, Any] = {}
@@ -288,7 +292,7 @@ class App:
             boundary: The boundary bytes extracted from the Content-Type header.
         """
         if not MULTIPART_INSTALLED:
-            raise ImportError("Multipart form data not supported. Install `multipart` via pip.")
+            raise ImportError("Multipart form data not supported. Install multipart aiofiles via pip.")
         with PushMultipartParser(boundary) as parser:
             current_field_name: Optional[str] = None
             current_filename: Optional[str] = None
@@ -296,7 +300,7 @@ class App:
             current_file: Optional[BinaryIO] = None
             form_value: str = ""
             upload_directory: str = "uploads"
-            os.makedirs(upload_directory, exist_ok=True)
+            await asyncio.to_thread(os.makedirs, upload_directory, exist_ok=True)
             while not parser.closed:
                 chunk: bytes = await reader.read(65536)
                 for result in parser.parse(chunk):
@@ -311,18 +315,18 @@ class App:
                         if current_filename:
                             safe_filename: str = f"{uuid.uuid4()}_{current_filename}"
                             file_path: str = os.path.join(upload_directory, safe_filename)
-                            current_file = open(file_path, "wb")
+                            current_file = await aiofiles.open(file_path, "wb")
                         else:
                             if current_field_name not in self.request.body_params:
                                 self.request.body_params[current_field_name] = []
                     elif result:
                         if current_file:
-                            current_file.write(result)
+                            await current_file.write(result)
                         else:
                             form_value += result.decode("utf-8", "ignore")
                     else:
                         if current_file:
-                            current_file.close()
+                            await current_file.close()
                             current_file = None
                             if current_field_name:
                                 self.request.files[current_field_name] = {
@@ -458,11 +462,9 @@ class App:
             The rendered template as a string.
         """
         if not JINJA_INSTALLED:
-            raise ImportError("`_render_template` not available. Install `jinja2` via pip.")
+            raise ImportError("_render_template not available. Install `jinja2` via pip.")
 
-        def render_sync() -> str:
-            assert self.env is not None
-            return self.env.get_template(name).render(kwargs)
-
-        return await asyncio.get_event_loop().run_in_executor(None, render_sync)
+        assert self.env is not None
+        template = self.env.get_template(name)
+        return await template.render_async(**kwargs)
 
diff --git a/README.md b/README.md
index 6236678..86a5b0d 100644
--- a/README.md
+++ b/README.md
@@ -19,16 +19,16 @@ Install MicroPie via pip:
 ```bash
 pip install micropie
 ```
-This will install MicroPie along with `jinja2` for template rendering and `multipart` for parsing multipart form data.
+This will install MicroPie along with `jinja2` for template rendering and `multipart`/`aiofiles` for parsing multipart form data.
 
 ### **Minimal Setup**
 For an ultra-minimalistic approach, download the standalone script:
 
 [MicroPie.py](https://raw.githubusercontent.com/patx/micropie/refs/heads/main/MicroPie.py)
 
-Place it in your project directory, and you are good to go. Note that `jinja2` must be installed separately to use templates and/or `multipart` for handling file uploads, but this *is* optional:
+Place it in your project directory, and you are good to go. Note that `jinja2` must be installed separately to use templates and/or `multipart` & `aiofiles` for handling file uploads, but this *is* optional:
 ```bash
-pip install jinja2 multipart
+pip install jinja2 multipart aiofiles
 ```
 
 ### **Install an ASGI Web Server**
diff --git a/docs/api.html b/docs/api.html
index 9aad121..ffaa744 100644
--- a/docs/api.html
+++ b/docs/api.html
@@ -1,78 +1,198 @@
 <!DOCTYPE html>
-<html>
+<html lang="en">
 <head>
-    <title>MicroPie API Documentation</title>
-    <meta charset="UTF-8">
-    <style>
-        body { font-family: Arial, sans-serif; line-height: 1.6; margin: 40px; }
-        h1, h2, h3 { color: #333; }
-        code { background: #f4f4f4; padding: 2px 5px; }
-        pre { background: #f4f4f4; padding: 10px; border-left: 5px solid #333; overflow-x: auto; }
-    </style>
+  <meta charset="UTF-8">
+  <title>MicroPie API Documentation</title>
+  <style>
+    body {
+      font-family: Arial, sans-serif;
+      margin: 2em;
+      line-height: 1.6;
+      background-color: #f9f9f9;
+    }
+    h1, h2, h3, h4 {
+      color: #333;
+    }
+    h1 {
+      border-bottom: 2px solid #ccc;
+      padding-bottom: 0.5em;
+    }
+    .class-section {
+      background: #fff;
+      border: 1px solid #ddd;
+      padding: 1em;
+      margin-bottom: 2em;
+      border-radius: 4px;
+    }
+    .method, .attribute {
+      margin-bottom: 1em;
+    }
+    code {
+      background-color: #eee;
+      padding: 0.1em 0.3em;
+      border-radius: 3px;
+    }
+    pre {
+      background: #f4f4f4;
+      border: 1px solid #ddd;
+      padding: 1em;
+      overflow-x: auto;
+    }
+    .param-list {
+      margin: 0.5em 0 0.5em 1em;
+    }
+  </style>
 </head>
 <body>
-    <h1>MicroPie API Documentation</h1>
-    <p>A simple Python ultra-micro web framework with ASGI support.</p>
+  <h1>MicroPie API Documentation</h1>
+  <p><em>MicroPie: A simple Python ultra‐micro web framework with ASGI support.</em></p>
+  <p>Documentation for all public and private (prefixed with <code>_</code>) methods.</p>
 
-    <h2>Request Class</h2>
-    <p>Represents an HTTP request in the MicroPie framework.</p>
-    <pre><code>class Request(scope: Dict[str, Any])</code></pre>
+  <!-- ======================================================== -->
+  <!-- Class: Request -->
+  <!-- ======================================================== -->
+  <div class="class-section" id="class-Request">
+    <h2>Class: Request</h2>
+    <p><strong>Description:</strong> Represents an HTTP request in the MicroPie framework.</p>
+
+    <h3>Attributes</h3>
     <ul>
-        <li><strong>scope</strong>: The ASGI scope dictionary for the request.</li>
-        <li><strong>method</strong>: The HTTP method of the request.</li>
-        <li><strong>path_params</strong>: URL path parameters.</li>
-        <li><strong>query_params</strong>: URL query parameters.</li>
-        <li><strong>body_params</strong>: Request body parameters.</li>
-        <li><strong>session</strong>: Dictionary for session data.</li>
-        <li><strong>files</strong>: Uploaded files in the request.</li>
+      <li><code>scope</code> (Dict[str, Any]): The ASGI scope dictionary for the request.</li>
+      <li><code>method</code> (str): The HTTP method derived from the scope.</li>
+      <li><code>path_params</code> (List[str]): List of URL path parameters.</li>
+      <li><code>query_params</code> (Dict[str, List[str]]): Dictionary of query string parameters.</li>
+      <li><code>body_params</code> (Dict[str, List[str]]): Dictionary of POST/PUT/PATCH body parameters.</li>
+      <li><code>session</code> (Dict[str, Any]): Dictionary for session data.</li>
+      <li><code>files</code> (Dict[str, Any]): Dictionary for uploaded files.</li>
     </ul>
 
-    <h2>App Class</h2>
-    <p>ASGI application framework for handling HTTP requests.</p>
-    <pre><code>class App()</code></pre>
+    <h3>Methods</h3>
+    <div class="method" id="Request.__init__">
+      <h4><code>__init__(self, scope: Dict[str, Any]) -> None</code></h4>
+      <p><strong>Description:</strong> Initialize a new Request instance.</p>
+      <p><strong>Parameters:</strong></p>
+      <ul class="param-list">
+        <li><code>scope</code> (Dict[str, Any]): The ASGI scope dictionary for the request.</li>
+      </ul>
+    </div>
+  </div>
+
+  <!-- ======================================================== -->
+  <!-- Class: App -->
+  <!-- ======================================================== -->
+  <div class="class-section" id="class-App">
+    <h2>Class: App</h2>
+    <p><strong>Description:</strong> ASGI application for handling HTTP requests and WebSocket connections in MicroPie.</p>
 
-    <h3>Properties</h3>
+    <h3>Class Attributes</h3>
     <ul>
-        <li><code>request</code> - Retrieve the current request instance.</li>
-        <li><code>SESSION_TIMEOUT</code> - Session timeout duration (8 hours by default).</li>
-        <li><code>sessions</code> - Dictionary storing active session data.</li>
-        <li><code>env</code> - Jinja2 environment for template rendering (if installed).</li>
+      <li><code>SESSION_TIMEOUT</code> (int): Session timeout value (8 hours, expressed in seconds).</li>
     </ul>
 
     <h3>Methods</h3>
-    <pre><code>async def __call__(self, scope, receive, send)</code></pre>
-    <p>ASGI callable interface.</p>
 
-    <pre><code>async def _asgi_app(self, scope, receive, send)</code></pre>
-    <p>Handles incoming HTTP requests, processes path parameters, query parameters, request body, and calls the appropriate handler function.</p>
+    <div class="method" id="App.__init__">
+      <h4><code>__init__(self) -> None</code></h4>
+      <p><strong>Description:</strong> Initialize a new App instance. If Jinja2 is installed, sets up the template environment and initializes session storage.</p>
+    </div>
 
-    <pre><code>def _parse_cookies(self, cookie_header: str) -> Dict[str, str]</code></pre>
-    <p>Parses cookies from the request header and returns them as a dictionary.</p>
+    <div class="method" id="App.request">
+      <h4><code>request(self) -> Request</code></h4>
+      <p><strong>Description:</strong> Retrieve the current request from the context variable.</p>
+      <p><strong>Returns:</strong> The current <code>Request</code> instance.</p>
+    </div>
 
-    <pre><code>async def _parse_multipart(self, reader, boundary)</code></pre>
-    <p>Parses multipart form-data including file uploads.</p>
+    <div class="method" id="App.__call__">
+      <h4><code>__call__(self, scope: Dict[str, Any], receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None</code></h4>
+      <p><strong>Description:</strong> ASGI callable interface for the server. This method simply delegates to <code>_asgi_app</code>.</p>
+      <p><strong>Parameters:</strong></p>
+      <ul class="param-list">
+        <li><code>scope</code>: The ASGI scope dictionary.</li>
+        <li><code>receive</code>: The callable to receive ASGI events.</li>
+        <li><code>send</code>: The callable to send ASGI events.</li>
+      </ul>
+    </div>
 
-    <pre><code>async def _send_response(self, send, status_code, body, extra_headers=None)</code></pre>
-    <p>Sends an HTTP response with the specified status code, response body, and optional extra headers.</p>
+    <div class="method" id="App._asgi_app">
+      <h4><code>_asgi_app(self, scope: Dict[str, Any], receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None</code></h4>
+      <p><strong>Description:</strong> ASGI application entry point for handling HTTP requests.</p>
+      <p><strong>Parameters:</strong></p>
+      <ul class="param-list">
+        <li><code>scope</code>: The ASGI scope dictionary.</li>
+        <li><code>receive</code>: The callable to receive ASGI events.</li>
+        <li><code>send</code>: The callable to send ASGI events.</li>
+      </ul>
+    </div>
 
-    <pre><code>def _cleanup_sessions(self)</code></pre>
-    <p>Cleans up expired sessions based on the session timeout value.</p>
+    <div class="method" id="App._parse_cookies">
+      <h4><code>_parse_cookies(self, cookie_header: str) -> Dict[str, str]</code></h4>
+      <p><strong>Description:</strong> Parse the Cookie header and return a dictionary of cookie names and values.</p>
+      <p><strong>Parameters:</strong></p>
+      <ul class="param-list">
+        <li><code>cookie_header</code> (str): The raw Cookie header string.</li>
+      </ul>
+      <p><strong>Returns:</strong> A dictionary mapping cookie names to their corresponding values.</p>
+    </div>
 
-    <pre><code>def _redirect(self, location: str) -> Tuple[int, str]</code></pre>
-    <p>Generates an HTTP redirect response to the specified location.</p>
+    <div class="method" id="App._parse_multipart">
+      <h4><code>_parse_multipart(self, reader: asyncio.StreamReader, boundary: bytes) -> None</code></h4>
+      <p><strong>Description:</strong> Parse <code>multipart/form-data</code> from the given reader using the specified boundary.</p>
+      <p><strong>Parameters:</strong></p>
+      <ul class="param-list">
+        <li><code>reader</code> (asyncio.StreamReader): Contains the multipart data.</li>
+        <li><code>boundary</code> (bytes): The boundary bytes extracted from the <code>Content-Type</code> header.</li>
+      </ul>
+      <p><strong>Notes:</strong> Requires the <code>multipart</code> and <code>aiofiles</code> packages to be installed.</p>
+    </div>
 
-    <pre><code>async def _render_template(self, name: str, **kwargs)</code></pre>
-    <p>Renders a Jinja2 template asynchronously with the provided context variables.</p>
+    <div class="method" id="App._send_response">
+      <h4><code>_send_response(self, send: Callable[[Dict[str, Any]], Awaitable[None]], status_code: int, body: Any, extra_headers: Optional[List[Tuple[str, str]]] = None) -> None</code></h4>
+      <p><strong>Description:</strong> Send an HTTP response using the ASGI <code>send</code> callable.</p>
+      <p><strong>Parameters:</strong></p>
+      <ul class="param-list">
+        <li><code>send</code>: The ASGI send callable.</li>
+        <li><code>status_code</code> (int): The HTTP status code for the response.</li>
+        <li><code>body</code>: The response body (string, bytes, or generator).</li>
+        <li><code>extra_headers</code> (Optional[List[Tuple[str, str]]]): Optional additional header tuples.</li>
+      </ul>
+    </div>
 
-    <h3>ASGI Request Handling</h3>
-    <p>The server class processes incoming ASGI requests in the following manner:</p>
-    <ul>
-        <li>Extracts the request path and determines the appropriate handler function.</li>
-        <li>Parses query parameters and request body.</li>
-        <li>Handles cookies and session data.</li>
-        <li>Processes multipart form data if necessary.</li>
-        <li>Executes the handler function and sends the response back.</li>
-    </ul>
+    <div class="method" id="App._cleanup_sessions">
+      <h4><code>_cleanup_sessions(self) -> None</code></h4>
+      <p><strong>Description:</strong> Clean up expired sessions based on the <code>SESSION_TIMEOUT</code> value.</p>
+    </div>
+
+    <div class="method" id="App._redirect">
+      <h4><code>_redirect(self, location: str) -> Tuple[int, str]</code></h4>
+      <p><strong>Description:</strong> Generate an HTTP redirect response.</p>
+      <p><strong>Parameters:</strong></p>
+      <ul class="param-list">
+        <li><code>location</code> (str): The URL to redirect to.</li>
+      </ul>
+      <p><strong>Returns:</strong> A tuple containing the HTTP status code (302) and an HTML body for redirection.</p>
+    </div>
+
+    <div class="method" id="App._render_template">
+      <h4><code>_render_template(self, name: str, **kwargs: Any) -> str</code></h4>
+      <p><strong>Description:</strong> Render a template asynchronously using Jinja2.</p>
+      <p><strong>Parameters:</strong></p>
+      <ul class="param-list">
+        <li><code>name</code> (str): The name of the template file.</li>
+        <li><code>**kwargs</code>: Additional keyword arguments passed to the template.</li>
+      </ul>
+      <p><strong>Returns:</strong> The rendered template as a string.</p>
+      <p><strong>Raises:</strong> <code>ImportError</code> if Jinja2 is not installed.</p>
+    </div>
+
+  </div>
+
+  <!-- ======================================================== -->
+  <!-- Footer -->
+  <!-- ======================================================== -->
+  <footer>
+    <p>&copy; Harrison Erd</p>
+    <p>For more details, visit the <a href="https://patx.github.io/micropie" target="_blank">MicroPie website</a>.</p>
+  </footer>
 </body>
 </html>
 
diff --git a/setup.py b/setup.py
index 80de66c..ae3adfe 100644
--- a/setup.py
+++ b/setup.py
@@ -25,7 +25,7 @@ Links
 from distutils.core import setup
 
 setup(name="MicroPie",
-    version="0.9.5.1",
+    version="0.9.6",
     description="A ultra micro web framework w/ Jinja2.",
     long_description=__doc__,
     author="Harrison Erd",
@@ -46,6 +46,6 @@ setup(name="MicroPie",
     "Topic :: Software Development :: Libraries :: Application Frameworks",
     "Typing :: Typed"],
     py_modules=['MicroPie'],
-    install_requires=['jinja2'],
+    install_requires=['jinja2', 'multipart', 'aiofiles'],
 )