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): self._name = name self._tempdir = Path(tempdir) def _generate_scad_template(self) -> str: return f""" use $fn=180; // one name tag for 0.5l Club Mate and similar bottles bottle_clip(name="{self._name}", logo="thing-logos/fablab-cube2.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"] except (TypeError, KeyError, ValueError): abort(400) return async with semaphore: with tempfile.TemporaryDirectory(prefix="fablab-bottle-clip-generator-") as tempdir: generator = Generator(name, tempdir) 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())