Skip to content

Commit

Permalink
Switch from Flask to Quart (asyncio)
Browse files Browse the repository at this point in the history
  • Loading branch information
jantman committed Nov 14, 2024
1 parent 7d1d312 commit 528a45e
Show file tree
Hide file tree
Showing 16 changed files with 1,492 additions and 1,342 deletions.
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ control use of various power tools and equipment in the `Decatur
Makers <https://decaturmakers.org/>`__ makerspace. It is made up of
custom ESP32-based hardware (machine control units) controlling power to
each enabled machine and running ESPHome, and a central access
control/management/logging server application written in Python/Flask.
control/management/logging server application written in Python/Quart.
Like our `“glue” server <https://github.com/decaturmakers/glue>`__ that
powers the RFID-based door access control to the makerspace, dm-mac uses
the `Neon CRM <https://www.neoncrm.com/>`__ as its source for user data,
Expand Down
6 changes: 3 additions & 3 deletions docs/source/introduction.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ control use of various power tools and equipment in the `Decatur
Makers <https://decaturmakers.org/>`__ makerspace. It is made up of
custom ESP32-based hardware (machine control units) controlling power to
each enabled machine and running ESPHome, and a central access
control/management/logging server application written in Python/Flask.
control/management/logging server application written in Python/Quart (an asyncio clone of Flask).
Like our `“glue” server <https://github.com/decaturmakers/glue>`__ that
powers the RFID-based door access control to the makerspace, dm-mac uses
the `Neon CRM <https://www.neoncrm.com/>`__ as its source for user data,
Expand All @@ -27,9 +27,9 @@ the ESPHome configuration for the ESP32’s.
Control Server
~~~~~~~~~~~~~~

This is a Python/Flask application that provides authentication and
This is a Python/Quart application that provides authentication and
authorization for users via RFID credentials, control of the ESP32-based
machine control units;, and logging and monitoring as well as basic
machine control units, and logging and monitoring as well as basic
management capabilities.

**Why not use the Glue server?** First, because the glue server is
Expand Down
2 changes: 1 addition & 1 deletion hardware/v1_mcu/CCR10S_enclosure-lid.gcode
Original file line number Diff line number Diff line change
Expand Up @@ -124678,7 +124678,7 @@ M84 X Y E ;Disable all steppers but Z
M82 ;absolute extrusion mode
M104 S0
;End of Gcode
;SETTING_3 {"global_quality": "[general]\\nversion = 4\\nname = Dynamic Quality
;SETTING_3 {"global_quality": "[general]\\nversion = 4\\nname = Dynamic Quality
;SETTING_3 #2\\ndefinition = creality_cr10s\\n\\n[metadata]\\ntype = quality_cha
;SETTING_3 nges\\nquality_type = adaptive\\nsetting_version = 23\\n\\n[values]\\
;SETTING_3 nadhesion_type = brim\\n\\n", "extruder_quality": ["[general]\\nversi
Expand Down
4 changes: 2 additions & 2 deletions hardware/v1_mcu/CCR10S_enclosure-lid.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
</style>
</head>
<body lang=EN>

<h1>Print settings</h1>
<button id='enabled'>Show/Hide Parameter Enable</button><P>
<button id='visible'>Show/Hide Parameter Standard</button><P>
Expand Down Expand Up @@ -740,4 +740,4 @@ <h1>Print settings</h1>
<tr class='normal'><td class='w-70 pl-0'>Nozzle X Offset</td><td class='val'>0</td><td class='w-10'>mm</td></tr>
<tr class='normal'><td class='w-70 pl-0'>Nozzle Y Offset</td><td class='val'>0</td><td class='w-10'>mm</td></tr>
<tr class='normal'><td class='w-70 pl-0'>Nozzle Diameter</td><td class='val'>0.4</td><td class='w-10'>mm</td></tr>
</table></body></html>
</table></body></html>
2 changes: 2 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ def tests(session: Session) -> None:
"responses",
"pytest-html",
"freezegun",
"pytest-asyncio",
)
try:
session.run(
Expand All @@ -203,6 +204,7 @@ def tests(session: Session) -> None:
"-m",
"pytest",
"--blockage",
"--asyncio-mode=auto",
"--capture=tee-sys",
"--junitxml=pytest.xml",
"--html=pytest.html",
Expand Down
2,191 changes: 1,170 additions & 1,021 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ Changelog = "https://github.com/jantman/machine_access_control/releases"

[tool.poetry.dependencies]
python = "^3.12"
flask = "^3.0.3"
jsonschema = "^4.23.0"
requests = "^2.32.3"
filelock = "^3.15.4"
prometheus-client = "^0.20.0"
quart = "^0.19.8"
asyncio = "^3.4.3"

[tool.poetry.group.dev.dependencies]
Pygments = ">=2.10.0"
Expand Down
12 changes: 6 additions & 6 deletions src/dm_mac/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import logging
from time import time

from flask import Flask
from flask import has_request_context
from flask import request
from flask.logging import default_handler
from quart import Quart
from quart import has_request_context
from quart import request
from quart.logging import default_handler

from dm_mac.models.machine import MachinesConfig
from dm_mac.models.users import UsersConfig
Expand Down Expand Up @@ -52,9 +52,9 @@ def format(self, record: logging.LogRecord) -> str:
api.register_blueprint(machineapi)


def create_app() -> Flask:
def create_app() -> Quart:
"""Factory to create the app."""
app: Flask = Flask("dm_mac")
app: Quart = Quart("dm_mac")
app.config.update({"MACHINES": MachinesConfig()})
app.config.update({"USERS": UsersConfig()})
app.config.update({"START_TIME": time()})
Expand Down
4 changes: 2 additions & 2 deletions src/dm_mac/models/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ def oops(self, do_locking: bool = True) -> None:
"""Oops the machine."""
logging.getLogger("OOPS").warning("Machine %s was Oopsed.", self.machine.name)
locker = self._lock if do_locking else nullcontext()
with locker: # type: ignore
with locker:
self.is_oopsed = True
self.relay_desired_state = False
self.current_user = None
Expand All @@ -308,7 +308,7 @@ def unoops(self, do_locking: bool = True) -> None:
"Machine %s was un-Oopsed.", self.machine.name
)
locker = self._lock if do_locking else nullcontext()
with locker: # type: ignore
with locker:
self.is_oopsed = False
self.relay_desired_state = False
self.current_user = None
Expand Down
12 changes: 6 additions & 6 deletions src/dm_mac/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
from logging import getLogger
from typing import Tuple

from flask import Blueprint
from flask import Response
from flask import current_app
from flask import jsonify
from quart import Blueprint
from quart import Response
from quart import current_app
from quart import jsonify

from dm_mac.models.users import UsersConfig

Expand All @@ -18,13 +18,13 @@


@api.route("/")
def index() -> str:
async def index() -> str:
"""Main API index route - placeholder."""
return "Nothing to see here..."


@api.route("/reload-users", methods=["POST"])
def reload_users() -> Tuple[Response, int]:
async def reload_users() -> Tuple[Response, int]:
"""Reload users config."""
added: int
updated: int
Expand Down
18 changes: 9 additions & 9 deletions src/dm_mac/views/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
from typing import Tuple
from typing import cast

from flask import Blueprint
from flask import Response
from flask import current_app
from flask import jsonify
from flask import request
from quart import Blueprint
from quart import Response
from quart import current_app
from quart import jsonify
from quart import request

from dm_mac.models.machine import Machine
from dm_mac.models.machine import MachinesConfig
Expand All @@ -25,7 +25,7 @@


@machineapi.route("/update", methods=["POST"])
def update() -> Tuple[Response, int]:
async def update() -> Tuple[Response, int]:
"""API method to update machine state.
Accepts POSTed JSON containing the following key/value pairs:
Expand Down Expand Up @@ -107,7 +107,7 @@ def update() -> Tuple[Response, int]:
'internal_temperature_c': 53.88888931
}
"""
data: Dict[str, Any] = cast(Dict[str, Any], request.json) # noqa
data: Dict[str, Any] = cast(Dict[str, Any], await request.json) # noqa
logger.warning("UPDATE request: %s", data)
machine_name: str = data.pop("machine_name")
mconf: MachinesConfig = current_app.config["MACHINES"] # noqa
Expand All @@ -126,7 +126,7 @@ def update() -> Tuple[Response, int]:


@machineapi.route("/oops/<machine_name>", methods=["POST", "DELETE"])
def oops(machine_name: str) -> Tuple[Response, int]:
async def oops(machine_name: str) -> Tuple[Response, int]:
"""API method to set or un-set machine Oops state."""
method: str = request.method
logger.warning("%s oops on machine %s", method, machine_name)
Expand All @@ -153,7 +153,7 @@ def oops(machine_name: str) -> Tuple[Response, int]:


@machineapi.route("/locked_out/<machine_name>", methods=["POST", "DELETE"])
def locked_out(machine_name: str) -> Tuple[Response, int]:
async def locked_out(machine_name: str) -> Tuple[Response, int]:
"""API method to set or un-set machine locked out state."""
method: str = request.method
logger.warning("%s lock-out on machine %s", method, machine_name)
Expand Down
6 changes: 3 additions & 3 deletions src/dm_mac/views/prometheus.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
from typing import Generator
from typing import Optional

from flask import Response
from flask import current_app
from prometheus_client import CollectorRegistry
from prometheus_client import generate_latest
from prometheus_client.core import Metric
from prometheus_client.samples import Sample
from quart import Response
from quart import current_app

from dm_mac.models.machine import Machine
from dm_mac.models.machine import MachinesConfig
Expand Down Expand Up @@ -204,7 +204,7 @@ def collect(self) -> Generator[LabeledGaugeMetricFamily, None, None]:
yield led


def prometheus_route() -> Response:
async def prometheus_route() -> Response:
"""API method to return Prometheus-compatible metrics."""
registry: CollectorRegistry = CollectorRegistry()
registry.register(PromCustomCollector()) # type: ignore
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
"""Helpers for testing the Flask app."""
"""Helpers for testing the Quart app."""

import os
from pathlib import Path
from typing import Tuple
from unittest.mock import patch

from flask import Flask
from flask.testing import FlaskClient
from quart import Quart
from quart.typing import TestClientProtocol

from dm_mac import create_app


def app_and_client(
tmp_path: Path, user_conf: str = "users.json", machine_conf: str = "machines.json"
) -> Tuple[Flask, FlaskClient]:
) -> Tuple[Quart, TestClientProtocol]:
"""Test App - app instance configured for testing.
Doing this as a pytest fixture is a complete pain in the ass because I want
Expand Down
50 changes: 26 additions & 24 deletions tests/views/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,49 @@
from shutil import copy
from unittest.mock import patch

from flask import Flask
from flask.testing import FlaskClient
from freezegun import freeze_time
from werkzeug.test import TestResponse
from quart import Quart
from quart import Response
from quart.typing import TestClientProtocol

from dm_mac.models.users import UsersConfig

from .flask_test_helpers import app_and_client
from .quart_test_helpers import app_and_client


class TestIndex:
"""Tests for API Index view."""

def test_index_response(self, tmp_path: Path) -> None:
async def test_index_response(self, tmp_path: Path) -> None:
"""Test for API index response."""
app: Flask
client: FlaskClient
app: Quart
client: TestClientProtocol
app, client = app_and_client(tmp_path)
response: TestResponse = client.get("/api/")
response: Response = await client.get("/api/")
assert response.status_code == 200
assert response.text == "Nothing to see here..."
assert await response.get_data(True) == "Nothing to see here..."
assert response.headers["Content-Type"] == "text/html; charset=utf-8"


@freeze_time("2023-07-16 03:14:08", tz_offset=0)
class TestReloadUsers:
"""Tests for reloading users."""

def test_no_change(self, tmp_path: Path, fixtures_path: str) -> None:
async def test_no_change(self, tmp_path: Path, fixtures_path: str) -> None:
"""Test /reload-users with no change."""
# set things up
uconf: str = str(os.path.join(tmp_path, "users.json"))
copy(os.path.join(fixtures_path, "users.json"), uconf)
with patch.dict("os.environ", {"USERS_CONFIG": uconf}):
app: Flask
client: FlaskClient
app: Quart
client: TestClientProtocol
app, client = app_and_client(tmp_path)
users: UsersConfig = app.config["USERS"]
users.load_time = 123456.0
before = [x.as_dict for x in users.users]
response: TestResponse = client.post("/api/reload-users")
response: Response = await client.post("/api/reload-users")
assert response.status_code == 200
assert response.json == {
assert await response.json == {
"updated": 0,
"removed": 0,
"added": 0,
Expand All @@ -62,42 +62,44 @@ def test_no_change(self, tmp_path: Path, fixtures_path: str) -> None:
assert users.users_by_fob["0091703745"].account_id == "3"
assert users.users_by_fob["0014916441"].account_id == "4"

def test_exception(self, tmp_path: Path, fixtures_path: str) -> None:
async def test_exception(self, tmp_path: Path, fixtures_path: str) -> None:
"""Test /reload-users when an exception is raised."""
# set things up
uconf: str = str(os.path.join(tmp_path, "users.json"))
copy(os.path.join(fixtures_path, "users.json"), uconf)
with patch.dict("os.environ", {"USERS_CONFIG": uconf}):
app: Flask
client: FlaskClient
app: Quart
client: TestClientProtocol
app, client = app_and_client(tmp_path)
users: UsersConfig = app.config["USERS"]
users.load_time = 123456.0
before = [x.as_dict for x in users.users]
with open(uconf, "w") as fh:
fh.write("\n")
response: TestResponse = client.post("/api/reload-users")
response: Response = await client.post("/api/reload-users")
assert response.status_code == 500
assert response.json == {"error": "Expecting value: line 2 column 1 (char 1)"}
assert await response.json == {
"error": "Expecting value: line 2 column 1 (char 1)"
}
users = app.config["USERS"]
assert users.load_time == 123456.0
after = [x.as_dict for x in users.users]
assert before == after

def test_changed(self, tmp_path: Path, fixtures_path: str) -> None:
async def test_changed(self, tmp_path: Path, fixtures_path: str) -> None:
"""Test /reload-users with changes."""
# set things up
uconf: str = str(os.path.join(tmp_path, "users.json"))
copy(os.path.join(fixtures_path, "users.json"), uconf)
with patch.dict("os.environ", {"USERS_CONFIG": uconf}):
app: Flask
client: FlaskClient
app: Quart
client: TestClientProtocol
app, client = app_and_client(tmp_path)
app.config["USERS"].load_time = 123456.0
copy(os.path.join(fixtures_path, "users-changed.json"), uconf)
response: TestResponse = client.post("/api/reload-users")
response: Response = await client.post("/api/reload-users")
assert response.status_code == 200
assert response.json == {
assert await response.json == {
"updated": 2,
"removed": 1,
"added": 1,
Expand Down
Loading

1 comment on commit 528a45e

@github-actions
Copy link

Choose a reason for hiding this comment

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

Coverage

Coverage Report
FileStmtsMissCoverMissing
src/dm_mac
   __init__.py340100% 
   cli_utils.py150100% 
   neongetter.py1830100% 
   utils.py170100% 
src/dm_mac/models
   __init__.py00100% 
   machine.py2490100% 
   users.py940100% 
src/dm_mac/views
   __init__.py00100% 
   api.py220100% 
   machine.py69691%144–145, 152, 171–172, 179
   prometheus.py98198%43
TOTAL781799% 

Tests Skipped Failures Errors Time
98 0 💤 0 ❌ 0 🔥 11.142s ⏱️

Please sign in to comment.