Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Part of #3928 [Web Examples] Add First Class Python Support #4107

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/modules/ROOT/nav.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
*** xref:pythonlib/module-config.adoc[]
*** xref:pythonlib/dependencies.adoc[]
*** xref:pythonlib/publishing.adoc[]
*** xref:pythonlib/web-examples.adoc[]
* xref:comparisons/why-mill.adoc[]
** xref:comparisons/maven.adoc[]
** xref:comparisons/gradle.adoc[]
Expand Down
25 changes: 25 additions & 0 deletions docs/modules/ROOT/pages/pythonlib/web-examples.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
= Python Web Project Examples
:page-aliases: Python_Web_Examples.adoc

include::partial$gtag-config.adoc[]

This page provides examples of using Mill as a build tool for Python web applications.
It includes setting up a basic "Hello, World!" application and developing a fully
functional Todo-MVC app with Flask and Django, showcasing best practices
for project organization, scalability, and maintainability.

== Flask Hello World App

include::partial$example/pythonlib/web/1-hello-flask.adoc[]

== Flask TodoMvc App

include::partial$example/pythonlib/web/2-todo-flask.adoc[]

== Django Hello World App

include::partial$example/pythonlib/web/3-hello-django.adoc[]

== Django TodoMvc App

include::partial$example/pythonlib/web/4-todo-django.adoc[]
1 change: 1 addition & 0 deletions example/package.mill
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ object `package` extends RootModule with Module {
object dependencies extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "dependencies"))
object publishing extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "publishing"))
object module extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "module"))
object web extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "web"))
}

object cli extends Module{
Expand Down
37 changes: 37 additions & 0 deletions example/pythonlib/web/1-hello-flask/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// This example uses Mill to manage a Flask app that serves "Hello, Mill!"
// at the root URL (`/`), with Flask installed as a dependency
// and tests enabled using `unittest`.
package build
import mill._, pythonlib._

object foo extends PythonModule {

def mainScript = Task.Source { millSourcePath / "src" / "foo.py" }

def pythonDeps = Seq("flask==3.1.0")

object test extends PythonTests with TestModule.Unittest

}

// Running these commands will test and run the Flask server with desired outputs.

/** Usage

> ./mill foo.test
...
test_hello_flask (test.TestScript...)
Test the '/' endpoint. ... ok
...
Ran 1 test...
OK
...

> ./mill foo.runBackground

> curl http://localhost:5000
...<h1>Hello, Mill!</h1>...

> ./mill clean foo.runBackground

*/
12 changes: 12 additions & 0 deletions example/pythonlib/web/1-hello-flask/foo/src/foo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from flask import Flask

app = Flask(__name__)


@app.route("/")
def hello_world():
return "<h1>Hello, Mill!</h1>"


if __name__ == "__main__":
app.run(debug=True)
21 changes: 21 additions & 0 deletions example/pythonlib/web/1-hello-flask/foo/test/src/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import unittest
from foo import app # type: ignore


class TestScript(unittest.TestCase):
def setUp(self):
himanshumahajan138 marked this conversation as resolved.
Show resolved Hide resolved
"""Set up the test client before each test."""
self.app = app.test_client() # Initialize the test client
self.app.testing = True # Enable testing mode for better error handling

def test_hello_flask(self):
"""Test the '/' endpoint."""
response = self.app.get("/") # Simulate a GET request to the root endpoint
self.assertEqual(response.status_code, 200) # Check the HTTP status code
self.assertIn(
b"Hello, Mill!", response.data
) # Check if the response contains the expected text


if __name__ == "__main__":
unittest.main()
63 changes: 63 additions & 0 deletions example/pythonlib/web/2-todo-flask/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// This is a `Flask`-based `Todo` application managed and build using `Mill`.
// It allows users to `add`, `edit`, `delete`, and `view` tasks stored in a `SQLite` database.
// `Flask-SQLAlchemy` handles database operations, while `Flask-WTF` manages forms
// for task creation and updates.
package build
import mill._, pythonlib._

object todo extends PythonModule {

def mainScript = Task.Source { millSourcePath / "src" / "app.py" }

def pythonDeps = Seq("flask==3.1.0", "Flask-SQLAlchemy==3.1.1", "Flask-WTF==1.2.2")

object unitTest extends PythonTests with TestModule.Unittest
object integrationTest extends PythonTests with TestModule.Unittest
Comment on lines +14 to +15
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is super nitpicky, but I personally always find it a bit weird to use mixed case names in folders. If you'd like to follow what's done in some other areas of mill, I would suggest to rename unitTest => test and integrationTest => itest

But I'm only expressing a very subjective point of view, so feel free to ignore this if you prefer.


}

// Apart from running a web server, this example demonstrates:

// - **Serving HTML templates** using **Jinja2** (Flask's default templating engine).
// - **Managing static files** such as JavaScript, CSS, and images.
// - **Querying a SQL database** using **Flask-SQLAlchemy** with an **SQLite** backend.
// - **Form handling and validation** with **Flask-WTF**.
// - **Unit testing** using **unittest** with SQLite in-memory database.
// - **Integration testing** using **unittest**.

// This example also utilizes **Mill** for managing `dependencies`, `builds`, and `tests`,
// offering an efficient development workflow.

/** Usage

> ./mill todo.unitTest
...
test_task_creation (test_unit.UnitTest...) ... ok
test_task_status_update (test_unit.UnitTest...) ... ok
...
Ran 2 tests...
OK
...

> ./mill todo.integrationTest
...
test_add_task (test_integration.IntegrationTest...)
Test adding a task through the /add route. ... ok
test_delete_task (test_integration.IntegrationTest...) ... ok
test_edit_task (test_integration.IntegrationTest...)
Test editing a task through the /edit/<int:task_id> route. ... ok
test_index_empty (test_integration.IntegrationTest...)
Test the index route with no tasks. ... ok
...
Ran 4 tests...
OK
...

> ./mill todo.runBackground

> curl http://localhost:5001
...To-Do Flask App Using Mill Build Tool...

> ./mill clean todo.runBackground

*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import unittest
from app import app, db, Task
from datetime import date

class IntegrationTest(unittest.TestCase):
def setUp(self):
"""Set up the test client and initialize the in-memory database."""
app.config['TESTING'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:///:memory:"
# Disable CSRF for testing
app.config['WTF_CSRF_ENABLED'] = False
self.client = app.test_client()
with app.app_context():
db.create_all()

def tearDown(self):
"""Clean up the database after each test."""
with app.app_context():
db.session.remove()
db.drop_all()

def test_index_empty(self):
"""Test the index route with no tasks."""
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertIn(b"No tasks found", response.data)

def test_add_task(self):
"""Test adding a task through the /add route."""

# Simulate a POST request with valid form data
response = self.client.post("/add", data={
"title": "Test Task",
"description": "This is a test task description.",
"status": "Pending",
"deadline": "2024-12-31"
}, follow_redirects=True)

# Check if the response is successful
self.assertEqual(response.status_code, 200)
self.assertIn(b"Task added successfully!", response.data)

# Verify the task was added to the database
with app.app_context():
task = Task.query.first() # Retrieve the first task in the database
self.assertIsNotNone(task, "Task was not added to the database.")
self.assertEqual(task.title, "Test Task")
self.assertEqual(task.description, "This is a test task description.")
self.assertEqual(task.status, "Pending")
self.assertEqual(str(task.deadline), "2024-12-31")

def test_edit_task(self):
"""Test editing a task through the /edit/<int:task_id> route."""

# Prepopulate the database with a task
with app.app_context():
new_task = Task(
title="Original Task",
description="Original description.",
status="Pending",
deadline=date(2024, 12, 31)
)
db.session.add(new_task)
db.session.commit()
task_id = new_task.id # Get the ID of the newly added task

# Simulate a POST request to edit the task
response = self.client.post(f"/edit/{task_id}", data={
"title": "Updated Task",
"description": "Updated description.",
"status": "Completed",
"deadline": "2025-01-15"
}, follow_redirects=True)

# Check if the response is successful
self.assertEqual(response.status_code, 200)
self.assertIn(b"Task updated successfully!", response.data)

# Verify the task was updated in the database
with app.app_context():
task = db.session.get(Task, task_id)
self.assertIsNotNone(task, "Task not found in the database after edit.")
self.assertEqual(task.title, "Updated Task")
self.assertEqual(task.description, "Updated description.")
self.assertEqual(task.status, "Completed")
self.assertEqual(str(task.deadline), "2025-01-15")

# Test editing a non-existent task
non_existent_id = 9999
response = self.client.get(f"/edit/{non_existent_id}", follow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertIn(b"Task not found.", response.data)

def test_delete_task(self):
with app.app_context(): # Ensure application context is active
# Add a task to delete
task = Task(title="Test Task")
db.session.add(task)
db.session.commit()
task_id = task.id

# Test deleting an existing task
response = self.client.get(f"/delete/{task_id}", follow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertIn(b"Task deleted successfully!", response.data)
self.assertIsNone(db.session.get(Task, task_id))

# Test deleting a non-existent task
non_existent_id = 9999
response = self.client.get(f"/delete/{non_existent_id}", follow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertIn(b"Task not found.", response.data)


if __name__ == "__main__":
unittest.main()
82 changes: 82 additions & 0 deletions example/pythonlib/web/2-todo-flask/todo/src/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from flask import Flask, render_template, redirect, url_for, flash, request
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SelectField, DateField, SubmitField
from wtforms.validators import DataRequired, Length

# Initialize Flask App and Database
app = Flask(__name__, static_folder="../static", template_folder="../templates")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///todo.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SECRET_KEY"] = (
"8f41b7124eec1c73f2fbe77e6e76c54602a40c44c842da93b09f48b79c023c88"
)

# Import models
from models import Task, db

# Import forms
from forms import TaskForm

db.init_app(app)


# Routes
@app.route("/")
def index():
tasks = Task.query.all()
return render_template("index.html", tasks=tasks)


@app.route("/add", methods=["GET", "POST"])
def add_task():
form = TaskForm()
if form.validate_on_submit():
new_task = Task(
title=form.title.data,
description=form.description.data,
status=form.status.data,
deadline=form.deadline.data,
)
db.session.add(new_task)
db.session.commit()
flash("Task added successfully!", "success")
return redirect(url_for("index"))
return render_template("task.html", form=form, title="Add Task")


@app.route("/edit/<int:task_id>", methods=["GET", "POST"])
def edit_task(task_id):
task = db.session.get(Task, task_id)
if not task: # Handle case where task doesn't exist
flash("Task not found.", "error")
return redirect(url_for("index"))
form = TaskForm(obj=task)
if form.validate_on_submit():
task.title = form.title.data
task.description = form.description.data
task.status = form.status.data
task.deadline = form.deadline.data
db.session.commit()
flash("Task updated successfully!", "success")
return redirect(url_for("index"))
return render_template("task.html", form=form, title="Edit Task")


@app.route("/delete/<int:task_id>")
def delete_task(task_id):
task = db.session.get(Task, task_id)
if not task: # Handle case where task doesn't exist
flash("Task not found.", "error")
return redirect(url_for("index"))
db.session.delete(task)
db.session.commit()
flash("Task deleted successfully!", "success")
return redirect(url_for("index"))


# Create database tables and run the app
if __name__ == "__main__":
with app.app_context():
db.create_all()
app.run(debug=True, port=5001)
13 changes: 13 additions & 0 deletions example/pythonlib/web/2-todo-flask/todo/src/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SelectField, DateField, SubmitField
from wtforms.validators import DataRequired, Length


class TaskForm(FlaskForm):
title = StringField("Title", validators=[DataRequired(), Length(max=100)])
description = TextAreaField("Description")
status = SelectField(
"Status", choices=[("Pending", "Pending"), ("Completed", "Completed")]
)
deadline = DateField("Deadline", format="%Y-%m-%d", validators=[DataRequired()])
submit = SubmitField("Save")
Loading
Loading