fablab-bottle-clip-generator/app/app.py

155 lines
4.5 KiB
Python

import asyncio
import io
import shutil
import tempfile
from distutils.dir_util import copy_tree
from pathlib import Path
from quart import Quart, abort, send_file, render_template, request
from werkzeug.utils import secure_filename
app = Quart(__name__, static_folder="static", template_folder="templates")
# allow at most these many scad processes in parallel
semaphore = asyncio.Semaphore(2)
def package_path() -> Path:
return Path(__file__).parent
class Generator:
GENERATOR_SCAD_FILE_NAME = "generator.scad"
GENERATED_STL_FILE_NAME = "generated.stl"
def __init__(self, name: str, tempdir: Path | str, logo: str = None):
self._name = name
self._tempdir = Path(tempdir)
# sanitize input
if "/" in logo:
raise ValueError("invalid logo name")
self._logo = Path(logo).name
def _generate_scad_template(self) -> str:
return f"""
use <bottle-clip.scad>
$fn=180;
// one name tag for 0.5l Club Mate and similar bottles
bottle_clip(name="{self._name}", logo="thing-logos/{self._logo}.dxf");
"""
def _generate_files_in_temp_dir(self):
copy_tree(str(package_path() / "openscad"), str(self._tempdir))
with open(self._tempdir / self.GENERATOR_SCAD_FILE_NAME, "w") as f:
f.write(self._generate_scad_template())
async def generate_stl(self) -> str:
self._generate_files_in_temp_dir()
openscad_path = shutil.which("openscad")
if not openscad_path:
abort(500)
proc = await asyncio.create_subprocess_exec(
openscad_path,
self.GENERATOR_SCAD_FILE_NAME,
"-o",
self.GENERATED_STL_FILE_NAME,
# "--hardwarnings",
cwd=self._tempdir,
)
await proc.wait()
if proc.returncode != 0:
abort(500)
return self.GENERATED_STL_FILE_NAME
@app.route("/generate", methods=["POST"])
async def generate():
try:
# support both modern JSON body requests as well as classic forms
form_data = await request.form
json_data = await request.json
if form_data:
data = form_data
elif json_data:
data = json_data
else:
raise ValueError
name = data["name"]
logo = data.get("logo", None)
except (TypeError, KeyError, ValueError):
abort(400)
return
async with semaphore:
with tempfile.TemporaryDirectory(prefix="fablab-bottle-clip-generator-") as tempdir:
generator = Generator(name, tempdir, logo)
generated_stl_file_name = await generator.generate_stl()
# to be able to use send_file with a temporary directory, we need buffer the entire file in memory
# before the context manager gets to delete the dir
bytes_io = io.BytesIO()
with open(Path(tempdir) / generated_stl_file_name, "rb") as f:
while True:
data = f.read(4096)
if not data:
break
bytes_io.write(data)
out_filename = secure_filename(name)
# a tester found that, if only special characters (like _) are sent to this method, it will return an
# empty string
# to improve UX, we replace empty strings with a _
if not out_filename:
out_filename = "_"
# to further improve the UX, let's add some prefix and the .stl suffix
out_filename = f"bottle-clip-{out_filename}.stl"
# using secure_filename allows us to send the file to the user with some safe yet reasonably
# identifiable filename
response = await send_file(
bytes_io,
mimetype="model/stl",
as_attachment=True,
attachment_filename=out_filename,
)
# avoid the need for a content-disposition parser in the client code
response.headers["download-filename"] = out_filename
return response
@app.route("/")
async def index():
return await render_template("index.html")
async def test():
with tempfile.TemporaryDirectory() as td:
generator = Generator("testabc", td)
generated_stl_file_name = await generator.generate_stl()
shutil.copy(Path(td) / generated_stl_file_name, ".")
if __name__ == "__main__":
import asyncio
asyncio.run(test())