patx/micropie
Fix bug that made the index method not properly recieve path params. Added URL shortener example to demonstrate this.
Commit 7882338 · patx · 2025-08-19T01:19:55-04:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index 395e0c4..4d28fa4 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,6 @@
### **Useful Links**
- **Homepage**: [patx.github.io/micropie](https://patx.github.io/micropie)
- **Official Documentation**: [micropie.readthedocs.io](https://micropie.readthedocs.io/)
-- **Quick API Reference**: [README.md#api-documentation](https://github.com/patx/micropie/blob/main/README.md#api-documentation)
- **PyPI Page**: [pypi.org/project/MicroPie](https://pypi.org/project/MicroPie/)
- **GitHub Project**: [github.com/patx/micropie](https://github.com/patx/micropie)
- **File Issue/Request**: [github.com/patx/micropie/issues](https://github.com/patx/micropie/issues)
@@ -335,172 +334,4 @@ We welcome suggestions, bug reports, and pull requests!
- File issues or feature requests [here](https://github.com/patx/micropie/issues).
- Security issues that should not be public, email `harrisonerd [at] gmail.com`.
-# **API Documentation**
-
-## Session Backend Abstraction
-
-MicroPie provides an abstraction for session backends, allowing you to define custom session storage mechanisms.
-
-### `SessionBackend` Class
-
-#### Methods
-- `load(session_id: str) -> Dict[str, Any]`
- - Abstract method to load session data given a session ID.
-- `save(session_id: str, data: Dict[str, Any], timeout: int) -> None`
- - Abstract method to save session data.
-
-### `InMemorySessionBackend` Class
-
-An in-memory implementation of the `SessionBackend`.
-
-#### Methods
-- `__init__()`
- - Initializes the in-memory session backend.
-- `load(session_id: str) -> Dict[str, Any]`
- - Loads session data for the given session ID.
-- `save(session_id: str, data: Dict[str, Any], timeout: int) -> None`
- - Saves session data for the given session ID.
-
-## Middleware Abstraction
-
-MicroPie allows you to create pluggable middleware to hook into the request lifecycle for both HTTP and WebSocket requests.
-
-### `HttpMiddleware` Class
-
-#### Methods
-- `before_request(request: Request) -> Optional[Dict]`
- - Abstract method called before the HTTP request is processed. Returns an optional dictionary with response details (status_code, body, headers) to short-circuit the request, or None to continue processing.
-- `after_request(request: Request, status_code: int, response_body: Any, extra_headers: List[Tuple[str, str]]) -> Optional[Dict]`
- - Abstract method called after the HTTP request is processed but before the final response is sent. Returns an optional dictionary with updated response details (status_code, body, headers), or None to use defaults.
-
-### `WebSocketMiddleware` Class
-
-#### Methods
-- `before_websocket(request: WebSocketRequest) -> Optional[Dict]`
- - Abstract method called before the WebSocket handler is invoked. Returns an optional dictionary with close details (code, reason) to reject the connection, or None to continue processing.
-- `after_websocket(request: WebSocketRequest) -> None`
- - Abstract method called after the WebSocket handler completes.
-
-## Request Objects
-
-### `Request` Class
-
-Represents an HTTP request in the MicroPie framework.
-
-#### Attributes
-- `scope`: The ASGI scope dictionary for the request.
-- `method`: The HTTP method of the request.
-- `path_params`: List of path parameters.
-- `query_params`: Dictionary of query parameters.
-- `body_params`: Dictionary of body parameters.
-- `get_json`: JSON request body object.
-- `session`: Dictionary of session data.
-- `files`: Dictionary of multipart data/streamed content.
-- `headers`: Dictionary of headers.
-
-### `WebSocketRequest` Class
-
-Represents a WebSocket request in the MicroPie framework, inheriting from `Request`.
-
-#### Attributes
-- Inherits all attributes from `Request`.
-
-## WebSocket Management
-
-### `WebSocket` Class
-
-Manages WebSocket communication in the MicroPie framework.
-
-#### Methods
-- `__init__(receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None`
- - Initializes a WebSocket instance with ASGI receive and send callables.
-- `accept(subprotocol: Optional[str] = None, session_id: Optional[str] = None) -> None`
- - Accepts the WebSocket connection, optionally specifying a subprotocol and session ID for setting a cookie during the handshake.
-- `receive_text() -> str`
- - Receives a text message from the WebSocket. Raises `ConnectionClosed` if the connection is closed or `ValueError` for unexpected message types.
-- `receive_bytes() -> bytes`
- - Receives a binary message from the WebSocket. Raises `ConnectionClosed` if the connection is closed or `ValueError` for unexpected message types.
-- `send_text(data: str) -> None`
- - Sends a text message over the WebSocket. Raises `RuntimeError` if the connection is not accepted.
-- `send_bytes(data: bytes) -> None`
- - Sends a binary message over the WebSocket. Raises `RuntimeError` if the connection is not accepted.
-- `close(code: int = 1000, reason: Optional[str] = None) -> None`
- - Closes the WebSocket connection with the specified code and optional reason.
-
-#### Attributes
-- `accepted`: Boolean indicating if the WebSocket connection is accepted.
-- `session_id`: Optional string storing the session ID set during the handshake.
-
-### `ConnectionClosed` Class
-
-An exception raised when a WebSocket connection is closed.
-
-## Application Base
-
-### `App` Class
-
-The main ASGI application class for handling HTTP, WebSocket, and lifespan events in MicroPie.
-
-#### Attributes
-- `middlewares`: List of `HttpMiddleware` instances for HTTP request processing.
-- `ws_middlewares`: List of `WebSocketMiddleware` instances for WebSocket request processing.
-- `startup_handlers`: List of async callables to run during `lifespan.startup`.
-- `shutdown_handlers`: List of async callables to run during `lifespan.shutdown`.
-- `_started`: Boolean indicating whether the application has completed startup.
-
-#### Methods
-- `__init__(session_backend: Optional[SessionBackend] = None) -> None`
- - Initializes the application with an optional session backend, empty middleware lists, and empty lifecycle handler lists.
-- `request(self) -> Request`
- - Accessor for the current request object. Returns the current request from the context variable.
-- `__call__(scope: Dict[str, Any], receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None`
- - ASGI callable interface for the server. Dispatches to HTTP, WebSocket, or lifespan event handling based on `scope` type.
-- `_asgi_app_http(scope: Dict[str, Any], receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None`
- - ASGI application entry point for handling HTTP requests.
-- `_asgi_app_websocket(scope: Dict[str, Any], receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None`
- - ASGI application entry point for handling WebSocket requests. Routes to methods prefixed with `ws_` (e.g., `ws_chat` for `/chat`).
-- `_asgi_app_lifespan(receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None`
- - ASGI application entry point for handling lifespan events. Executes registered startup and shutdown handlers.
-- `_parse_cookies(cookie_header: str) -> Dict[str, str]`
- - Parses the Cookie header and returns a dictionary of cookie names and values.
-- `_parse_multipart(reader: asyncio.StreamReader, boundary: bytes) -> Tuple[Dict[str, List[str]], Dict[str, Dict[str, Any]]]`
- - Asynchronously parses multipart/form-data from the given reader using the specified boundary. Returns a tuple of two dictionaries: `form_data` (text fields as key-value pairs) and `files` (file fields with metadata). Each file entry in `files` contains:
- - `filename`: The original filename of the uploaded file.
- - `content_type`: The MIME type of the file (defaults to `application/octet-stream`).
- - `content`: An `asyncio.Queue` containing chunks of file data as bytes, with a `None` sentinel signaling the end of the stream.
- - Handlers can consume the file data by iterating over the queue (e.g., using `await queue.get()`).
- - *Requires:* `multipart`
-- `_send_response(send: Callable[[Dict[str, Any]], Awaitable[None]], status_code: int, body: Any, extra_headers: Optional[List[Tuple[str, str]]] = None) -> None`
- - Sends an HTTP response using the ASGI send callable.
-- `_send_websocket_close(send: Callable[[Dict[str, Any]], Awaitable[None]], code: int, reason: str) -> None`
- - Sends a WebSocket close message with the specified code and reason.
-- `_redirect(location: str) -> Tuple[int, str]`
- - Generates an HTTP redirect response.
-- `_render_template(name: str, **kwargs: Any) -> str`
- - Renders a template asynchronously using Jinja2.
- - *Requires*: `jinja2`
-
-The `App` class is the main entry point for creating MicroPie applications. It implements the ASGI interface and handles HTTP, WebSocket, and lifespan events.
-
-## Response Formats
-
-Handlers can return responses in the following formats:
-
-1. String, bytes, or JSON-serializable object
-2. Tuple of (status_code, body)
-3. Tuple of (status_code, body, headers)
-4. Async or sync generator for streaming responses
-
-## Error Handling
-
-MicroPie provides built-in error handling for common HTTP status codes:
-
-- `404 Not Found`: Automatically returned for non-existent routes
-- `400 Bad Request`: Returned for missing required parameters
-- `500 Internal Server Error`: Returned for unhandled exceptions
-
-Custom error handling can be implemented through middleware.
-
----
-
© 2025 Harrison Erd
diff --git a/examples/blog/app.py b/examples/blog/app.py
new file mode 100644
index 0000000..50de51f
--- /dev/null
+++ b/examples/blog/app.py
@@ -0,0 +1,97 @@
+from micropie import App
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+from sqlalchemy.orm import sessionmaker, DeclarativeBase
+from sqlalchemy import Column, Integer, String, Text, DateTime
+from sqlalchemy.sql import select
+from datetime import datetime
+import markdown
+from jinja2 import Environment, FileSystemLoader
+import asyncio
+import os
+
+# Initialize Jinja2 environment for templates
+env = Environment(loader=FileSystemLoader('templates'))
+
+# Convert Markdown to HTML with GFM support
+def markdown_to_html(text):
+ return markdown.markdown(text, extensions=['extra', 'tables', 'fenced_code'])
+
+# SQLAlchemy setup
+class Base(DeclarativeBase):
+ pass
+
+class Post(Base):
+ __tablename__ = 'posts'
+ id = Column(Integer, primary_key=True)
+ title = Column(String(100), nullable=False)
+ content = Column(Text, nullable=False)
+ created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+
+# SQLite async engine and session (will be initialized in startup handler)
+engine = None
+AsyncSessionLocal = None
+
+# Lifecycle event handlers
+async def setup_db():
+ global engine, AsyncSessionLocal
+ print("Setting up SQLite database...")
+ engine = create_async_engine('sqlite+aiosqlite:///blog.db', echo=False)
+ AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ print("SQLite database setup complete.")
+
+async def close_db():
+ global engine
+ print("Closing SQLite database...")
+ if engine:
+ await engine.dispose()
+ print("SQLite database closed.")
+
+class BlogApp(App):
+ def __init__(self):
+ super().__init__()
+ # Register lifecycle handlers
+ self.startup_handlers.append(setup_db)
+ self.shutdown_handlers.append(close_db)
+
+ async def index(self):
+ # Fetch all posts, sorted by creation date (descending)
+ async with AsyncSessionLocal() as session:
+ posts = await session.execute(
+ select(Post).order_by(Post.created_at.desc())
+ )
+ posts = posts.scalars().all()
+ for post in posts:
+ post.html_content = markdown_to_html(post.content)
+ return await self._render_template('index.html', posts=posts)
+
+ async def new(self, title=None, content=None):
+ if self.request.method == 'POST':
+ # Handle form submission
+ title = self.request.body_params.get('title', [None])[0]
+ content = self.request.body_params.get('content', [None])[0]
+ if title and content:
+ # Insert new post into SQLite
+ async with AsyncSessionLocal() as session:
+ new_post = Post(title=title, content=content)
+ session.add(new_post)
+ await session.commit()
+ return self._redirect('/')
+ return await self._render_template('new_post.html')
+
+ async def post(self, id):
+ # Fetch a single post by ID
+ async with AsyncSessionLocal() as session:
+ post = await session.get(Post, int(id))
+ if not post:
+ return 404, "Post not found"
+ post.html_content = markdown_to_html(post.content)
+ return await self._render_template('post.html', post=post)
+
+# Create the app instance
+app = BlogApp()
+
+if __name__ == '__main__':
+ import uvicorn
+ uvicorn.run(app, host='127.0.0.1', port=8080)
diff --git a/examples/blog/blog.db b/examples/blog/blog.db
new file mode 100644
index 0000000..fd53bcf
Binary files /dev/null and b/examples/blog/blog.db differ
diff --git a/examples/blog/templates/index.html b/examples/blog/templates/index.html
new file mode 100644
index 0000000..3be686e
--- /dev/null
+++ b/examples/blog/templates/index.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>MicroPie Blog</title>
+ <script src="https://cdn.tailwindcss.com"></script>
+</head>
+<body class="bg-gray-100 font-sans">
+ <header class="bg-blue-600 text-white p-4">
+ <div class="container mx-auto flex justify-between items-center">
+ <h1 class="text-2xl font-bold">MicroPie Blog</h1>
+ <nav>
+ <a href="/" class="px-4 hover:underline" aria-label="Home">Home</a>
+ <a href="/new" class="px-4 hover:underline" aria-label="Create New Post">New Post</a>
+ </nav>
+ </div>
+ </header>
+ <main class="container mx-auto p-6">
+ <div class="grid gap-6">
+ {% for post in posts %}
+ <article class="bg-white p-6 rounded-lg shadow-md" role="article">
+ <h2 class="text-xl font-semibold">
+ <a href="/post/{{ post.id }}" class="text-blue-600 hover:underline">{{ post.title }}</a>
+ </h2>
+ <p class="text-gray-500 text-sm">Posted on {{ post.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</p>
+ <div class="prose max-w-none mt-4">{{ post.html_content | safe }}</div>
+ </article>
+ {% else %}
+ <p class="text-gray-600">No posts yet. <a href="/new" class="text-blue-600 hover:underline">Create one!</a></p>
+ {% endfor %}
+ </div>
+ </main>
+</body>
+</html>
diff --git a/examples/blog/templates/new_post.html b/examples/blog/templates/new_post.html
new file mode 100644
index 0000000..fcb3f57
--- /dev/null
+++ b/examples/blog/templates/new_post.html
@@ -0,0 +1,56 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Create New Post - MicroPie Blog</title>
+ <script src="https://cdn.tailwindcss.com"></script>
+</head>
+<body class="bg-gray-100 font-sans">
+ <header class="bg-blue-600 text-white p-4">
+ <div class="container mx-auto flex justify-between items-center">
+ <h1 class="text-2xl font-bold">MicroPie Blog</h1>
+ <nav>
+ <a href="/" class="px-4 hover:underline" aria-label="Home">Home</a>
+ <a href="/new" class="px-4 hover:underline" aria-label="Create New Post">New Post</a>
+ </nav>
+ </div>
+ </header>
+ <main class="container mx-auto p-6">
+ <div class="bg-white p-6 rounded-lg shadow-md max-w-2xl mx-auto">
+ <h2 class="text-2xl font-semibold mb-4">Create New Post</h2>
+ <div>
+ <input type="text" id="title" name="title" placeholder="Post Title" required
+ class="w-full p-2 mb-4 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ aria-label="Post Title">
+ <textarea id="content" name="content" placeholder="Write your Markdown content here..." required
+ class="w-full p-2 mb-4 border rounded-md h-64 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ aria-label="Post Content"></textarea>
+ <button onclick="submitForm()"
+ class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
+ aria-label="Submit Post">Submit</button>
+ </div>
+ <p class="mt-4">
+ <a href="/" class="text-blue-600 hover:underline" aria-label="Back to Home">Back to Home</a>
+ </p>
+ </div>
+ <script>
+ function submitForm() {
+ const title = document.getElementById('title').value;
+ const content = document.getElementById('content').value;
+ if (!title || !content) {
+ alert('Please fill in both title and content.');
+ return;
+ }
+ const formData = new FormData();
+ formData.append('title', title);
+ formData.append('content', content);
+ fetch('/new', {
+ method: 'POST',
+ body: formData
+ }).then(() => window.location.href = '/');
+ }
+ </script>
+ </main>
+</body>
+</html>
diff --git a/examples/blog/templates/post.html b/examples/blog/templates/post.html
new file mode 100644
index 0000000..d3fb6b9
--- /dev/null
+++ b/examples/blog/templates/post.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{ post.title }} - MicroPie Blog</title>
+ <script src="https://cdn.tailwindcss.com"></script>
+</head>
+<body class="bg-gray-100 font-sans">
+ <header class="bg-blue-600 text-white p-4">
+ <div class="container mx-auto flex justify-between items-center">
+ <h1 class="text-2xl font-bold">MicroPie Blog</h1>
+ <nav>
+ <a href="/" class="px-4 hover:underline" aria-label="Home">Home</a>
+ <a href="/new" class="px-4 hover:underline" aria-label="Create New Post">New Post</a>
+ </nav>
+ </div>
+ </header>
+ <main class="container mx-auto p-6">
+ <article class="bg-white p-6 rounded-lg shadow-md max-w-2xl mx-auto" role="article">
+ <h1 class="text-2xl font-semibold mb-2">{{ post.title }}</h1>
+ <p class="text-gray-500 text-sm mb-4">Posted on {{ post.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</p>
+ <div class="prose max-w-none">{{ post.html_content | safe }}</div>
+ <p class="mt-4">
+ <a href="/" class="text-blue-600 hover:underline" aria-label="Back to Home">Back to Home</a>
+ </p>
+ </article>
+ </main>
+</body>
+</html>
diff --git a/examples/url_shortener/app.py b/examples/url_shortener/app.py
new file mode 100644
index 0000000..e5cd501
--- /dev/null
+++ b/examples/url_shortener/app.py
@@ -0,0 +1,69 @@
+# shorty_index_only.py
+# URL shortener on MicroPie using ONLY the `index` method for both create & redirect.
+# No middleware. Path params are mapped to `index(id=...)` automatically by MicroPie.
+# Run: uvicorn shorty_index_only:app --reload
+
+import re
+import secrets
+import string
+from typing import Optional
+from urllib.parse import urlparse
+
+from micropie import App
+from pickledb import PickleDB
+
+# ---- storage ----
+db = PickleDB("shorty.json") # persisted JSON file
+
+# ---- config ----
+ALPHABET = string.ascii_letters + string.digits
+SLUG_LEN = 6
+
+# ---- helpers ----
+def _rand_slug(n: int = SLUG_LEN) -> str:
+ return "".join(secrets.choice(ALPHABET) for _ in range(n))
+
+def _is_valid_url(url: str) -> bool:
+ """Basic URL validation."""
+ if not url:
+ return False
+ # Simple regex for URL validation (http(s) scheme, domain, optional path)
+ pattern = r'^https?://[a-zA-Z0-9-._~:/?#[\]@!$&\'()*+,;=]+[^/]$'
+ return bool(re.match(pattern, url))
+
+class Root(App):
+
+ async def index(self, id: Optional[str] = None):
+ if self.request.method == "POST":
+ # Use body_params for form data instead of query_params
+ url = self.request.body_params.get('url', [''])[0]
+ if not _is_valid_url(url):
+ return 400, "Invalid URL provided"
+ slug = _rand_slug()
+ while db.get(slug):
+ slug = _rand_slug()
+ db.set(slug, url)
+ db.save()
+ short = f"http://127.0.0.1:8000/{slug}"
+ return f"Your short URL is <a href='{short}'>{short}</a>"
+ elif self.request.method == "GET":
+ if id:
+ dest = db.get(id)
+ if not dest:
+ return 404, "Not Found"
+ # Ensure destination URL is valid before redirecting
+ if not _is_valid_url(dest):
+ return 400, "Invalid destination URL"
+ return self._redirect(dest)
+ else:
+ return (
+ """
+ <h1>Shorty</h1>
+ <form method="post" enctype="application/x-www-form-urlencoded">
+ <input type="url" name="url" placeholder="https://example.com" required style="width:28rem">
+ <button type="submit">Shorten</button>
+ </form>
+ """
+ )
+
+app = Root()
diff --git a/micropie.py b/micropie.py
index dded65d..e6ed820 100644
--- a/micropie.py
+++ b/micropie.py
@@ -532,9 +532,23 @@ class App:
await self._send_response(send, 404, "404 Not Found")
return
+ # Initialize func_args early to avoid UnboundLocalError
+ func_args: List[Any] = []
+
+ # Check if index handler accepts parameters (for non-root paths)
+ if handler == getattr(self, "index", None) and path and path != "index":
+ sig = inspect.signature(handler)
+ accepts_params = any(
+ param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.VAR_POSITIONAL)
+ for param in sig.parameters.values() if param.name != "self"
+ )
+ if not accepts_params:
+ await self._send_response(send, 404, "404 Not Found")
+ return
+ request.path_params = parts # Pass all path parts to index handler
+
# Build handler args (query/body/files/session)
sig = inspect.signature(handler)
- func_args: List[Any] = []
path_params_copy = request.path_params[:]
for param in sig.parameters.values():
@@ -577,10 +591,6 @@ class App:
func_args.append(param_value)
- if handler == getattr(self, "index", None) and not func_args and path and path != "index":
- await self._send_response(send, 404, "404 Not Found")
- return
-
# Execute handler
try:
result = await handler(*func_args) if inspect.iscoroutinefunction(handler) else handler(*func_args)