patx/micropie
added static file example, shortened readme, use examples to learn
Commit 45de47a · patx · 2025-01-28T04:56:09-05:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index f32aab6..b157eb7 100644
--- a/README.md
+++ b/README.md
@@ -8,14 +8,14 @@
*"Fast, efficient, and deliciously simple."*
- 🚀 **Easy Setup:** Minimal configuration required. Our setup is so simple, you’ll have time for dessert.
-- 🔄 **Routing:** Maps URLs to functions automatically. So easy, even your grandma could do it (probably).
+- 🔄 **Routing:** Class based routing. Maps URLs to functions automatically. So easy, even your grandma could do it (probably).
- 🔐 **Sessions:** Simple session management using cookies.
- 🎨 **Templates:** Jinja2 for dynamic HTML pages.
- ⚡ **Fast & Lightweight:** No unnecessary dependencies. Life’s too short for bloated frameworks.
- 🖥️ **ASGI support:** Deploy with any ASGI server, like **uvicorn** making web development easy as... pie!
-## **Installing MicroPie**
-### **Normal Installation**
+
+## **Install**
To install MicroPie [from the PyPI](https://pypi.org/project/MicroPie/) run the following command:
```bash
pip install micropie
@@ -26,7 +26,8 @@ To run your application you need an ASGI web server, like **uvicorn**. Install i
```bash
pip install uvicorn
```
-MicroPie will also work with any ASGI server of your choice!
+MicroPie will work with any ASGI server of your choice!
+
## **Getting Started**
@@ -36,7 +37,7 @@ Create a basic MicroPie app in `app.py`:
from MicroPie import Server
class MyApp(Server):
- def index(self, name="Guest"):
+ async def index(self, name="Guest"):
return f"Hello, {name}!"
app = MyApp()
@@ -50,212 +51,26 @@ uvicorn app:app
Visit your app at [http://127.0.0.1:8000](http://127.0.0.1:8000). In MicroPie your application code can look just like the WSGI code you are used to writing!
-## **Core Features**
-
-### **1. Routing**
-Define methods to handle URLs:
-```python
-class MyApp(Server):
- def hello(self):
- return "Hello, world!"
-```
-Your methods can also, of course, be `async` whenever needed:
-```python
-class MyApp(Server):
- async def hello(self):
- return "Hello World!"
-```
-
-**Access:**
-- Basic route: [http://127.0.0.1:8080/hello](http://127.0.0.1:8080/hello)
-
-### **2. Handling GET Requests**
-
-MicroPie allows passing data using query strings (`?key=value`) and URL path segments.
-
-#### **Query Parameters**
-
-You can pass query parameters via the URL, which will be automatically mapped to method arguments:
-
-```python
-class MyApp(Server):
- def greet(self, name="Guest"):
- return f"Hello, {name}!"
-```
-
-**Access:**
-- Using query parameters: [http://127.0.0.1:8080/greet?name=Alice](http://127.0.0.1:8080/greet?name=Alice)
- - This will return: `Hello, Alice!`
-- Using URL path segments: [http://127.0.0.1:8080/greet](http://127.0.0.1:8080/greet)
- - This will return: `Hello, Guest!`
-
-#### **Path Parameters (Dynamic Routing)**
-
-You can also pass parameters directly in the URL path instead of query strings:
-
-```python
-class MyApp(Server):
- def greet(self, name="Guest"):
- return f"Hello, {name}!"
-```
-
-**Access:**
-- Using path parameters: [http://127.0.0.1:8080/greet/Alice](http://127.0.0.1:8080/greet/Alice)
- - This will return: `Hello, Alice!`
-- Another example: [http://127.0.0.1:8080/greet/John](http://127.0.0.1:8080/greet/John)
- - This will return: `Hello, John!`
-
-#### **Using Both Query and Path Parameters Together**
-
-```python
-class MyApp(Server):
- def profile(self, user_id):
- age = self.query_params.get('age', ['Unknown'])[0]
- return f"User ID: {user_id}, Age: {age}"
-```
-
-**Access:**
-- [http://127.0.0.1:8080/profile/123?age=25](http://127.0.0.1:8080/profile/123?age=25)
- - Returns: `User ID: 123, Age: 25`
-- [http://127.0.0.1:8080/profile/456](http://127.0.0.1:8080/profile/456)
- - Returns: `User ID: 456, Age: Unknown`
-
-### **3. Handling POST Requests**
-
-MicroPie supports handling form data submitted via HTTP POST requests. Form data is automatically mapped to method arguments.
-
-#### **Handling Form Submission with Default Values**
-
-```python
-class MyApp(Server):
- def submit(self, username="Anonymous"):
- return f"Form submitted by: {username}"
-```
-
-#### **Accessing Raw POST Data**
-
-```python
-class MyApp(Server):
- def submit(self):
- username = self.body_params.get('username', ['Anonymous'])[0]
- return f"Submitted by: {username}"
-```
-
-#### **Handling Multiple POST Parameters**
-```python
-class MyApp(Server):
- def register(self):
- username = self.body_params.get('username', ['Guest'])[0]
- email = self.body_params.get('email', ['No Email'])[0]
- return f"Registered {username} with email {email}"
-```
-
-### **4. Handling Sessions**
-MicroPie has built in session handling:
-```python
-class MyApp(Server):
-
- def index(self):
- # Initialize or increment visit count in session
- if 'visits' not in self.session:
- self.session['visits'] = 1
- else:
- self.session['visits'] += 1
-
- return f"Welcome! You have visited this page {self.session['visits']} times."
-
-app = MyApp() # Run with `uvicorn app:app` assuming this file saved as `app.py`
-```
-
-### **5. Jinja2 Built In**
-MicroPie has Jinja template engine built in. You can use it with the `render_template` method. You can also implement any other template engine you would like.
-
-#### **`app.py`**
-Save the following as `app.py`:
-```python
-class MyApp(Server):
- def index(self):
- # Pass data to the template for rendering
- return self.render_template("index.html", title="Welcome", message="Hello from MicroPie!")
-
-app = MyApp() # Run with `uvicorn app:app`
-```
-
-#### **HTML**
-In order to use the `render_template` method you must put your HTML template files in a directory at the same level as `app.py` titled `templates`. Save the following as `templates/index.html`:
-```html
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>{{ title }}</title>
-</head>
-<body>
- <h1>{{ message }}</h1>
- <p>This page is rendered using Jinja2 templates.</p>
-</body>
-</html>
-```
-
-### **6. Serving Static Files**
-MicroPie can serve static files (such as CSS, JavaScript, and images) from a static directory using the built in `serve_static` method. To do this you must define a route you would like to serve your static files from. For example:
-```python
-class Root(Server):
- def static(self, filename):
- return self.serve_static(filename)
-```
-
-#### **Setup**
-- Create a directory named `static` in the same location as your MicroPie application. For safety the `serve_static` method will only work if `filename` is in the `static` directory.
-- Place your static files (e.g., style.css, script.js, logo.png) inside the static directory.
-
-#### **Accessing Static Files**
-Static files can be accessed via the `/static/` URL path. For example, if you have a file named `style.css` in the `static` directory, you can access it using:
-```
-http://127.0.0.1:8080/static/style.css
-```
-
-### **7. Streaming Responses**
-MicroPie provides support for streaming responses, allowing you to send data to the client in chunks instead of all at once. This is particularly useful for scenarios where data is generated or processed over time, such as live feeds, large file downloads, or incremental data generation.
-
-Check out the `streaming` folder in the `examples` to see MicroPie's streaming responses in action.
+## **Learn by Examples**
+Check out the [examples folder](https://github.com/patx/micropie/tree/development/examples) for more advanced usage, including:
+- Template rendering
+- Custom HTTP request handling
+- File uploads
+- Session usage
+- Websockets with Socket.io
+- Async Streaming
+- Form handling
-### **8. WebSockets**
+## **Notes on WebSockets**
MicroPie does not handle WebSockets out of the box. While the underlying ASGI interface can theoretically handle WebSocket connections, MicroPie’s routing and request-handling logic is designed primarily for HTTP. If you need WebSocket functionality, you’ll need to either:
- Write or integrate your own custom ASGI WebSocket handler, or
- Use a dedicated library such as Socket.IO or channels with your ASGI server alongside MicroPie.
-Check out the `socketio` folder in the `examples` on this repo to see Socket.io integration.
-
-## **API Reference**
-
-### Class: Server
+Check out [examples/socketio](https://github.com/patx/micropie/tree/development/examples/socketio) to see Socket.io integration.
-#### cleanup_sessions()
-Removes expired sessions that have surpassed the timeout period.
-
-#### redirect(location)
-Returns a 302 redirect response to the specified URL.
-
-#### render_template(name, **args)
-Renders a Jinja2 template with provided context variables.
-
-#### serve_static(filename)
-Serve static files from the `static` directory.
-
-## **Examples**
-Check out the [examples folder](https://github.com/patx/micropie/tree/development/examples) for more advanced usage, including:
-- Template rendering
-- Custom HTTP request handling
-- File uploads
-- Session usage
-- Websockets with Socket.io
-- Async Streaming
-- Form handling.
## **Feature Comparison**
diff --git a/docs/index.html b/docs/index.html
index 34380b8..d3ebd21 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -107,7 +107,7 @@
<div class="logo">
<img src="logo.png" alt="MicroPie logo">
</div>
- <p><strong>MicroPie is an ultra-lightweight ASGI Python web framework</strong> that gets out of your way, letting you build fast and dynamic web apps with ease.
+ <p><strong>MicroPie is an ultra-small ASGI Python web framework</strong> that gets out of your way, letting you build fast and dynamic web apps with ease.
Inspired by <a href="https://cherrypy.dev/">CherryPy</a> and licensed under the BSD three-clause license.</p>
<h2>MicroPie is Fun</h2>
diff --git a/examples/static_content/app.py b/examples/static_content/app.py
new file mode 100644
index 0000000..a83fde6
--- /dev/null
+++ b/examples/static_content/app.py
@@ -0,0 +1,9 @@
+from MicroPie import Server
+
+class Root(Server):
+
+ def static(self, filename):
+ return self.serve_static(filename)
+
+
+app = Root()
diff --git a/examples/static_content/static/logo.png b/examples/static_content/static/logo.png
new file mode 100644
index 0000000..20eb33f
Binary files /dev/null and b/examples/static_content/static/logo.png differ
diff --git a/tests.py b/tests.py
index c988a36..04c0537 100644
--- a/tests.py
+++ b/tests.py
@@ -1,230 +1,113 @@
import unittest
-import uuid
-import time
-from io import BytesIO
-from unittest.mock import patch
+from unittest.mock import AsyncMock, MagicMock, patch
from MicroPie import Server
+import os
+import uuid
-
-class TestMicroPie(unittest.TestCase):
+class TestServer(unittest.TestCase):
def setUp(self):
- """
- Create a fresh instance of the Server for each test and define some
- sample endpoints on the fly.
- """
self.server = Server()
- # Define a simple default endpoint
- def index():
- return "Hello from index!"
-
- # Define an endpoint that greets the user by name
- def greet(name="World"):
- return f"Hello, {name}!"
-
- # Define an endpoint that triggers a redirect
- def go_away():
- return self.server.redirect("/gone")
-
- # Define an endpoint for testing template rendering (requires a
- # templates/greet.html file in your project for a real test).
- def greet_template(name="World"):
- return self.server.render_template("greet.html", name=name)
-
- # Attach the functions to the server instance
- self.server.index = index
- self.server.greet = greet
- self.server.go_away = go_away
- self.server.greet_template = greet_template
-
- def _start_response(self, status, headers):
- """
- Helper method to capture the status and headers from wsgi_app calls.
- """
- self._wsgi_status = status
- self._wsgi_headers = headers
-
- def test_index_get(self):
- """
- Ensure that accessing '/' via GET calls the 'index' function and
- returns the correct body.
- """
- environ = {
- "REQUEST_METHOD": "GET",
- "PATH_INFO": "/", # Access root
- "QUERY_STRING": "",
- "wsgi.input": BytesIO(b""),
- "CONTENT_LENGTH": "0",
- }
- response = self.server.wsgi_app(environ, self._start_response)
- body = b"".join(response).decode()
-
- self.assertEqual(self._wsgi_status, "200 OK")
- self.assertIn("Hello from index!", body)
-
- def test_custom_endpoint_get(self):
- """
- Ensure that a custom endpoint (greet) can accept query parameters
- through GET and return the correct response.
- """
- environ = {
- "REQUEST_METHOD": "GET",
- "PATH_INFO": "/greet",
- "QUERY_STRING": "name=Alice",
- "wsgi.input": BytesIO(b""),
- "CONTENT_LENGTH": "0",
- }
- response = self.server.wsgi_app(environ, self._start_response)
- body = b"".join(response).decode()
-
- self.assertEqual(self._wsgi_status, "200 OK")
- self.assertIn("Hello, Alice!", body)
-
- def test_custom_endpoint_path_param(self):
- """
- Test that path parameters are used if present. For example, accessing
- '/greet/Bob' should call greet("Bob").
- """
- environ = {
- "REQUEST_METHOD": "GET",
- "PATH_INFO": "/greet/Bob",
- "QUERY_STRING": "",
- "wsgi.input": BytesIO(b""),
- "CONTENT_LENGTH": "0",
- }
- response = self.server.wsgi_app(environ, self._start_response)
- body = b"".join(response).decode()
-
- self.assertEqual(self._wsgi_status, "200 OK")
- self.assertIn("Hello, Bob!", body)
-
- def test_endpoint_not_found(self):
- """
- Access a path that does not map to a defined method. Expect 404.
- """
- environ = {
- "REQUEST_METHOD": "GET",
- "PATH_INFO": "/does_not_exist",
- "QUERY_STRING": "",
- "wsgi.input": BytesIO(b""),
- "CONTENT_LENGTH": "0",
- }
- response = self.server.wsgi_app(environ, self._start_response)
- body = b"".join(response).decode()
-
- self.assertEqual(self._wsgi_status, "404 Not Found")
- self.assertIn("404 Not Found", body)
-
- def test_post_request(self):
- """
- Test handling of a POST request with form data in the body.
- """
- post_body = "name=Charlie"
- environ = {
- "REQUEST_METHOD": "POST",
- "PATH_INFO": "/greet",
- "QUERY_STRING": "",
- "wsgi.input": BytesIO(post_body.encode("utf-8")),
- "CONTENT_LENGTH": str(len(post_body)),
- }
- response = self.server.wsgi_app(environ, self._start_response)
- body = b"".join(response).decode()
+ def test_parse_cookies(self):
+ cookie_header = "session_id=abc123; theme=dark"
+ cookies = self.server._parse_cookies(cookie_header)
+ self.assertEqual(cookies, {"session_id": "abc123", "theme": "dark"})
- self.assertEqual(self._wsgi_status, "200 OK")
- self.assertIn("Hello, Charlie!", body)
+ def test_parse_cookies_empty(self):
+ cookies = self.server._parse_cookies("")
+ self.assertEqual(cookies, {})
def test_redirect(self):
- """
- Test that an endpoint can return a 302 redirect.
- """
- environ = {
- "REQUEST_METHOD": "GET",
- "PATH_INFO": "/go_away", # Calls the go_away function
- "QUERY_STRING": "",
- "wsgi.input": BytesIO(b""),
- "CONTENT_LENGTH": "0",
- }
- response = self.server.wsgi_app(environ, self._start_response)
- body = b"".join(response).decode()
-
- # MicroPie returns (302, <html>...) so we expect status to be "302 Found"
- self.assertEqual(self._wsgi_status, "302 Found")
- self.assertIn("url=/gone", body) # Basic check that the redirect body is correct
-
- def test_session_creation(self):
- """
- Test that a session is created if no session cookie is present.
- """
- environ = {
- "REQUEST_METHOD": "GET",
- "PATH_INFO": "/",
- "QUERY_STRING": "",
- "wsgi.input": BytesIO(b""),
- "CONTENT_LENGTH": "0",
- # No HTTP_COOKIE -> expect new session
+ location = "/new-path"
+ status, body = self.server.redirect(location)
+ self.assertEqual(status, 302)
+ self.assertIn(location, body)
+
+ @patch("os.path.isfile", return_value=True)
+ @patch("builtins.open", new_callable=MagicMock)
+ def test_serve_static_file(self, mock_open, mock_isfile):
+ mock_open.return_value.__enter__.return_value.read.return_value = b"file content"
+ response = self.server.serve_static("test.txt")
+ self.assertEqual(response[0], 200)
+ self.assertEqual(response[1], b"file content")
+ self.assertEqual(response[2][0][0], "Content-Type")
+
+ @patch("os.path.isfile", return_value=False)
+ def test_serve_static_file_not_found(self, mock_isfile):
+ response = self.server.serve_static("missing.txt")
+ self.assertEqual(response, (404, "404 Not Found"))
+
+ def test_cleanup_sessions(self):
+ self.server.sessions = {
+ "session1": {"last_access": time.time() - 1000},
+ "session2": {"last_access": time.time() - 10000},
}
- response = self.server.wsgi_app(environ, self._start_response)
- _ = b"".join(response).decode()
-
- # Find the Set-Cookie header among the WSGI headers
- set_cookie_headers = [h for h in self._wsgi_headers if h[0] == 'Set-Cookie']
- self.assertTrue(set_cookie_headers, "Expected a Set-Cookie header for new session.")
-
- # The session should be stored in self.server.sessions
- cookie_value = set_cookie_headers[0][1]
- session_id = cookie_value.split("=")[1].split(";")[0]
- self.assertIn(session_id, self.server.sessions)
-
- def test_session_usage(self):
- """
- Test that an existing session is reused if a valid session_id is provided.
- """
- # Create a session manually
- session_id = str(uuid.uuid4())
- self.server.sessions[session_id] = {"last_access": time.time()}
-
- environ = {
- "REQUEST_METHOD": "GET",
- "PATH_INFO": "/",
- "QUERY_STRING": "",
- "wsgi.input": BytesIO(b""),
- "CONTENT_LENGTH": "0",
- "HTTP_COOKIE": f"session_id={session_id}",
+ self.server.SESSION_TIMEOUT = 3600
+ self.server.cleanup_sessions()
+ self.assertEqual(len(self.server.sessions), 1)
+ self.assertIn("session1", self.server.sessions)
+
+ @patch("uuid.uuid4", return_value="test-session-id")
+ @patch("time.time", return_value=1000)
+ async def test_asgi_app_creates_session(self, mock_time, mock_uuid):
+ mock_send = AsyncMock()
+ mock_receive = AsyncMock(return_value={"type": "http.request", "body": b""})
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/",
+ "headers": [],
}
- response = self.server.wsgi_app(environ, self._start_response)
- _ = b"".join(response).decode()
-
- # We should not receive a new Set-Cookie; we should reuse existing one.
- set_cookie_headers = [h for h in self._wsgi_headers if h[0] == 'Set-Cookie']
- self.assertFalse(set_cookie_headers, "Did not expect a new Set-Cookie header.")
-
- # Ensure we didn't lose the session
- self.assertIn(session_id, self.server.sessions, "Existing session should still be present.")
-
- @unittest.skip("Requires a valid 'greet.html' in the 'templates' folder to work.")
- def test_template_rendering(self):
- """
- Optional test for verifying a Jinja2 template render. This test will
- require a 'templates/greet.html' file that references a variable 'name'.
-
- Example 'greet.html' content:
-
- <h1>Hello {{ name }}!</h1>
- """
- environ = {
- "REQUEST_METHOD": "GET",
- "PATH_INFO": "/greet_template",
- "QUERY_STRING": "name=Tester",
- "wsgi.input": BytesIO(b""),
- "CONTENT_LENGTH": "0",
+
+ async def mock_index():
+ return "Hello, world!"
+
+ self.server.index = mock_index
+
+ await self.server.asgi_app(scope, mock_receive, mock_send)
+
+ self.assertIn("test-session-id", self.server.sessions)
+ self.assertEqual(self.server.sessions["test-session-id"].get("last_access"), 1000)
+
+ @patch("time.time", return_value=1000)
+ async def test_asgi_app_handles_request(self, mock_time):
+ mock_send = AsyncMock()
+ mock_receive = AsyncMock(return_value={"type": "http.request", "body": b""})
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/",
+ "headers": [(b"cookie", b"session_id=test-session-id")],
}
- response = self.server.wsgi_app(environ, self._start_response)
- body = b"".join(response).decode()
- self.assertEqual(self._wsgi_status, "200 OK")
- self.assertIn("<h1>Hello Tester!</h1>", body)
+ self.server.sessions["test-session-id"] = {"last_access": 500}
+
+ async def mock_index():
+ return "Hello, test!"
+
+ self.server.index = mock_index
+
+ await self.server.asgi_app(scope, mock_receive, mock_send)
+
+ self.assertEqual(self.server.sessions["test-session-id"].get("last_access"), 1000)
+
+ @patch("jinja2.Environment.get_template")
+ def test_render_template(self, mock_get_template):
+ mock_template = MagicMock()
+ mock_template.render.return_value = "Rendered content"
+ mock_get_template.return_value = mock_template
+
+ result = self.server.render_template("test.html", var="value")
+ self.assertEqual(result, "Rendered content")
+ mock_template.render.assert_called_with({"var": "value"})
+ def test_render_template_no_jinja(self):
+ self.server.env = None
+ with self.assertRaises(ImportError):
+ self.server.render_template("test.html")
-if __name__ == '__main__':
+if __name__ == "__main__":
unittest.main()