mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-11-04 11:03:04 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			195 lines
		
	
	
		
			7.1 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			195 lines
		
	
	
		
			7.1 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
import json
 | 
						|
import logging
 | 
						|
import subprocess
 | 
						|
from dataclasses import dataclass
 | 
						|
from hashlib import sha1
 | 
						|
from itertools import chain
 | 
						|
from pathlib import Path
 | 
						|
from typing import Iterable, Self
 | 
						|
 | 
						|
import rjsmin
 | 
						|
import sass
 | 
						|
from django.conf import settings
 | 
						|
 | 
						|
from api.urls import api
 | 
						|
from staticfiles.apps import BUNDLED_FOLDER_NAME, BUNDLED_ROOT, GENERATED_ROOT
 | 
						|
 | 
						|
 | 
						|
@dataclass
 | 
						|
class JsBundlerManifestEntry:
 | 
						|
    src: str
 | 
						|
    out: str
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def from_json_entry(cls, entry: dict[str, any]) -> list[Self]:
 | 
						|
        # We have two parts for a manifest entry
 | 
						|
        # The `src` element which is what the user asks django as a static
 | 
						|
        # The `out` element which is it's real name in the output static folder
 | 
						|
 | 
						|
        # For the src part:
 | 
						|
        # The manifest file contains the path of the file relative to the project root
 | 
						|
        # We want the relative path of the file inside their respective static folder
 | 
						|
        # because that's what the user types when importing statics and that's what django gives us
 | 
						|
        # This is really similar to what we are doing in the bundler, it uses a similar algorithm
 | 
						|
        # Example:
 | 
						|
        # core/static/bundled/alpine-index.js -> bundled/alpine-index.js
 | 
						|
        # core/static/bundled/components/include-index.ts -> core/static/bundled/components/include-index.ts
 | 
						|
        def get_relative_src_name(name: str) -> str:
 | 
						|
            original_path = Path(name)
 | 
						|
            relative_path: list[str] = []
 | 
						|
            for directory in reversed(original_path.parts):
 | 
						|
                relative_path.append(directory)
 | 
						|
                # Contrary to the bundler algorithm, we do want to keep the bundled prefix
 | 
						|
                if directory == BUNDLED_FOLDER_NAME:
 | 
						|
                    break
 | 
						|
            return str(Path(*reversed(relative_path)))
 | 
						|
 | 
						|
        # For the out part:
 | 
						|
        # The bundler is configured to output files in generated/bundled and considers this folders as it's root
 | 
						|
        # Thus, the output name doesn't contain the `bundled` prefix that we need, we add it ourselves
 | 
						|
        ret = [
 | 
						|
            cls(
 | 
						|
                src=get_relative_src_name(entry["src"]),
 | 
						|
                out=str(Path(BUNDLED_FOLDER_NAME) / entry["file"]),
 | 
						|
            )
 | 
						|
        ]
 | 
						|
 | 
						|
        def remove_hash(path: Path) -> str:
 | 
						|
            # Hashes are configured to be surrounded by `.`
 | 
						|
            # Filenames are like this path/to/file.hash.ext
 | 
						|
            unhashed = ".".join(path.stem.split(".")[:-1])
 | 
						|
            return str(path.with_stem(unhashed))
 | 
						|
 | 
						|
        # CSS files generated by entrypoints don't have their own entry in the manifest
 | 
						|
        # They are however listed as an attribute of the entry point that generates them
 | 
						|
        # Their listed name is the one that has been generated inside the generated/bundled folder
 | 
						|
        # We prefix it with `bundled` and then generate an `src` name by removing the hash
 | 
						|
        for css in entry.get("css", []):
 | 
						|
            path = Path(BUNDLED_FOLDER_NAME) / css
 | 
						|
            ret.append(
 | 
						|
                cls(
 | 
						|
                    src=remove_hash(path),
 | 
						|
                    out=str(path),
 | 
						|
                )
 | 
						|
            )
 | 
						|
        return ret
 | 
						|
 | 
						|
 | 
						|
class JSBundlerManifest:
 | 
						|
    def __init__(self, manifest: Path):
 | 
						|
        with open(manifest, "r") as f:
 | 
						|
            self._manifest = json.load(f)
 | 
						|
 | 
						|
        self._files = chain(
 | 
						|
            *[
 | 
						|
                JsBundlerManifestEntry.from_json_entry(value)
 | 
						|
                for value in self._manifest.values()
 | 
						|
                if value.get("isEntry", False)
 | 
						|
            ]
 | 
						|
        )
 | 
						|
        self.mapping = {file.src: file.out for file in self._files}
 | 
						|
 | 
						|
 | 
						|
class JSBundler:
 | 
						|
    @staticmethod
 | 
						|
    def compile():
 | 
						|
        """Bundle js files with the javascript bundler for production."""
 | 
						|
        process = subprocess.Popen(["npm", "run", "compile"])
 | 
						|
        _ = process.wait()
 | 
						|
        if process.returncode:
 | 
						|
            raise RuntimeError(f"Bundler failed with returncode {process.returncode}")
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def get_manifest() -> JSBundlerManifest:
 | 
						|
        return JSBundlerManifest(BUNDLED_ROOT / ".vite" / "manifest.json")
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def is_in_bundle(name: str | None) -> bool:
 | 
						|
        if name is None:
 | 
						|
            return False
 | 
						|
        return Path(name).parts[0] == BUNDLED_FOLDER_NAME
 | 
						|
 | 
						|
 | 
						|
class Scss:
 | 
						|
    @dataclass
 | 
						|
    class CompileArg:
 | 
						|
        absolute: Path  # Absolute path to the file
 | 
						|
        relative: Path  # Relative path inside the folder it has been collected
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def compile(files: CompileArg | Iterable[CompileArg]):
 | 
						|
        """Compile scss files to css files."""
 | 
						|
        # Generate files inside the generated folder
 | 
						|
        # .css files respects the hierarchy in the static folder it was found
 | 
						|
        # This converts arg.absolute -> generated/{arg.relative}.scss
 | 
						|
        # Example:
 | 
						|
        #   app/static/foo.scss          -> generated/foo.css
 | 
						|
        #   app/static/bar/foo.scss      -> generated/bar/foo.css
 | 
						|
        #   custom/location/bar/foo.scss -> generated/bar/foo.css
 | 
						|
        if isinstance(files, Scss.CompileArg):
 | 
						|
            files = [files]
 | 
						|
 | 
						|
        base_args = {"output_style": "compressed", "precision": settings.SASS_PRECISION}
 | 
						|
 | 
						|
        compiled_files = {
 | 
						|
            file.relative.with_suffix(".css"): sass.compile(
 | 
						|
                filename=str(file.absolute), **base_args
 | 
						|
            )
 | 
						|
            for file in files
 | 
						|
        }
 | 
						|
        for file, content in compiled_files.items():
 | 
						|
            dest = GENERATED_ROOT / file
 | 
						|
            dest.parent.mkdir(exist_ok=True, parents=True)
 | 
						|
            dest.write_text(content)
 | 
						|
 | 
						|
 | 
						|
class JS:
 | 
						|
    @staticmethod
 | 
						|
    def minify():
 | 
						|
        to_exec = [
 | 
						|
            p
 | 
						|
            for p in settings.STATIC_ROOT.rglob("*.js")
 | 
						|
            if ".min" not in p.suffixes
 | 
						|
            and (settings.STATIC_ROOT / BUNDLED_FOLDER_NAME) not in p.parents
 | 
						|
        ]
 | 
						|
        for path in to_exec:
 | 
						|
            p = path.resolve()
 | 
						|
            minified = rjsmin.jsmin(p.read_text())
 | 
						|
            p.write_text(minified)
 | 
						|
            logging.getLogger("main").info(f"Minified {path}")
 | 
						|
 | 
						|
 | 
						|
class OpenApi:
 | 
						|
    OPENAPI_DIR = GENERATED_ROOT / "openapi"
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def compile(cls) -> subprocess.Popen[bytes] | None:
 | 
						|
        """Compile a TS client for the sith API. Only generates it if it changed."""
 | 
						|
        logging.getLogger("django").info("Compiling open api typescript client")
 | 
						|
        out = cls.OPENAPI_DIR / "schema.json"
 | 
						|
        cls.OPENAPI_DIR.mkdir(parents=True, exist_ok=True)
 | 
						|
 | 
						|
        old_hash = ""
 | 
						|
        if out.exists():
 | 
						|
            with open(out, "rb") as f:
 | 
						|
                old_hash = sha1(f.read()).hexdigest()
 | 
						|
 | 
						|
        schema = api.get_openapi_schema()
 | 
						|
        # Remove hash from operationIds
 | 
						|
        # This is done for cache invalidation but this is too aggressive
 | 
						|
        for path in schema["paths"].values():
 | 
						|
            for action, desc in path.items():
 | 
						|
                path[action]["operationId"] = "_".join(
 | 
						|
                    desc["operationId"].split("_")[:-1]
 | 
						|
                )
 | 
						|
        schema = str(schema)
 | 
						|
 | 
						|
        if old_hash == sha1(schema.encode("utf-8")).hexdigest():
 | 
						|
            logging.getLogger("django").info("✨ Api did not change, nothing to do ✨")
 | 
						|
            return
 | 
						|
 | 
						|
        with open(out, "w") as f:
 | 
						|
            _ = f.write(schema)
 | 
						|
 | 
						|
        return subprocess.Popen(["npm", "run", "openapi"])
 |