v0.166.0
  1import os
  2import platform
  3import subprocess
  4import sys
  5import tomllib
  6from pathlib import Path
  7from typing import Any
  8
  9import click
 10import httpx
 11import tomlkit
 12from plain.packages import packages_registry
 13from plain.runtime import APP_PATH, PLAIN_CACHE_PATH, PLAIN_TEMP_PATH, settings
 14
 15
 16class Tailwind:
 17    @property
 18    def target_directory(self) -> str:
 19        return str(PLAIN_TEMP_PATH)
 20
 21    def binary_path(self, version: str) -> Path:
 22        """Machine-level cache path for a specific Tailwind version."""
 23        filename = "tailwind.exe" if platform.system() == "Windows" else "tailwind"
 24        return PLAIN_CACHE_PATH / "tailwind" / version / filename
 25
 26    @property
 27    def src_css_path(self) -> Path:
 28        return settings.TAILWIND_SRC_PATH
 29
 30    @property
 31    def dist_css_path(self) -> Path:
 32        return settings.TAILWIND_DIST_PATH
 33
 34    def update_plain_sources(self) -> None:
 35        source_paths: list[str] = []
 36        import_paths: list[str] = []
 37        abs_app_path = APP_PATH.absolute()
 38
 39        def rel_to_target(p: Path) -> str:
 40            # CSS uses forward slashes regardless of platform, and backslashes
 41            # in CSS strings are escape sequences — so normalize to POSIX.
 42            return Path(os.path.relpath(p, self.target_directory)).as_posix()
 43
 44        for package_config in packages_registry.get_package_configs():
 45            abs_package_path = Path(package_config.path).absolute()
 46
 47            # App-local packages are already covered by Tailwind's default
 48            # scan of the project root, so we skip @source for them — but
 49            # still pick up any tailwind.css they contribute.
 50            if not abs_package_path.is_relative_to(abs_app_path):
 51                source_paths.append(rel_to_target(abs_package_path))
 52
 53            tailwind_css = abs_package_path / "tailwind.css"
 54            if tailwind_css.is_file():
 55                import_paths.append(rel_to_target(tailwind_css))
 56
 57        os.makedirs(self.target_directory, exist_ok=True)
 58        plain_sources_path = os.path.join(self.target_directory, "tailwind.css")
 59        with open(plain_sources_path, "w") as f:
 60            # @import rules must come before any other rules per the CSS spec.
 61            f.writelines(f'@import "{path}";\n' for path in import_paths)
 62            f.writelines(f'@source "{path}";\n' for path in source_paths)
 63
 64    def invoke(self, *args: Any, cwd: str | None = None) -> None:
 65        version = self.get_version_from_config()
 66        if not version:
 67            raise RuntimeError(
 68                "No Tailwind version configured in pyproject.toml — run `plain tailwind install`"
 69            )
 70        result = subprocess.run(
 71            [self.binary_path(version)] + list(args), cwd=cwd, check=False
 72        )
 73        if result.returncode != 0:
 74            sys.exit(result.returncode)
 75
 76    def is_installed(self) -> bool:
 77        version = self.get_version_from_config()
 78        return bool(version) and self.binary_path(version).exists()
 79
 80    def create_src_css(self) -> None:
 81        os.makedirs(os.path.dirname(self.src_css_path), exist_ok=True)
 82        with open(self.src_css_path, "w") as f:
 83            f.write("""@import "tailwindcss";\n@import "./.plain/tailwind.css";\n""")
 84
 85    def get_version_from_config(self) -> str:
 86        pyproject_path = os.path.join(
 87            os.path.dirname(self.target_directory), "pyproject.toml"
 88        )
 89
 90        if not os.path.exists(pyproject_path):
 91            return ""
 92
 93        with open(pyproject_path, "rb") as f:
 94            config = tomllib.load(f)
 95            return (
 96                config.get("tool", {})
 97                .get("plain", {})
 98                .get("tailwind", {})
 99                .get("version", "")
100            )
101
102    def set_version_in_config(self, version: str) -> None:
103        pyproject_path = os.path.join(
104            os.path.dirname(self.target_directory), "pyproject.toml"
105        )
106
107        with open(pyproject_path) as f:
108            config = tomlkit.load(f)
109
110        config.setdefault("tool", {}).setdefault("plain", {}).setdefault(
111            "tailwind", {}
112        )["version"] = version
113
114        with open(pyproject_path, "w") as f:
115            tomlkit.dump(config, f)
116
117    def download(self, version: str = "") -> str:
118        if version:
119            if not version.startswith("v"):
120                version = f"v{version}"
121            url = f"https://github.com/tailwindlabs/tailwindcss/releases/download/{version}/tailwindcss-{self.detect_platform_slug()}"
122        else:
123            url = f"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-{self.detect_platform_slug()}"
124
125        headers = {
126            "Accept-Encoding": "gzip, deflate, br",
127            "User-Agent": "plain-tailwind/1.0",
128        }
129
130        # Download to a temp file first, then atomically move it into the
131        # versioned cache path (parallel checkouts can download concurrently).
132        download_dir = PLAIN_CACHE_PATH / "tailwind"
133        download_dir.mkdir(parents=True, exist_ok=True)
134        tmp_path = download_dir / f".download-{os.getpid()}"
135
136        try:
137            with (
138                httpx.Client(
139                    transport=httpx.HTTPTransport(retries=3),
140                    follow_redirects=True,
141                    timeout=300,
142                ) as client,
143                client.stream("GET", url, headers=headers) as response,
144            ):
145                response.raise_for_status()
146                total = int(response.headers.get("Content-Length", 0))
147
148                with (
149                    open(tmp_path, "wb") as f,
150                    click.progressbar(
151                        length=total,
152                        label="Downloading Tailwind",
153                        width=0,
154                    ) as bar,
155                ):
156                    for chunk in response.iter_bytes(chunk_size=1024 * 1024):
157                        if chunk:
158                            f.write(chunk)
159                            bar.update(len(chunk))
160
161            os.chmod(tmp_path, 0o755)
162
163            if not version:
164                # Get the version from the redirect chain (latest -> vX.Y.Z)
165                version = str(response.history[1].url).split("/")[-2]
166
167            version = version.lstrip("v")
168
169            binary_path = self.binary_path(version)
170            binary_path.parent.mkdir(parents=True, exist_ok=True)
171            try:
172                os.replace(tmp_path, binary_path)
173            except PermissionError:
174                # Windows raises this if another process has binary_path open
175                # (e.g. `plain tailwind build --watch` in a different checkout
176                # sharing this machine-wide cache). If it's already there,
177                # another process finished installing this exact version —
178                # nothing left to do.
179                if not binary_path.exists():
180                    raise
181        finally:
182            tmp_path.unlink(missing_ok=True)
183
184        return version
185
186    def install(self, version: str = "") -> str:
187        installed_version = self.download(version)
188        self.set_version_in_config(installed_version)
189        return installed_version
190
191    @staticmethod
192    def detect_platform_slug() -> str:
193        uname = platform.uname()[0]
194
195        if uname == "Windows":
196            return "windows-x64.exe"
197
198        if uname == "Linux" and platform.uname()[4] == "aarch64":
199            return "linux-arm64"
200
201        if uname == "Linux":
202            return "linux-x64"
203
204        if uname == "Darwin" and platform.uname().machine == "arm64":
205            return "macos-arm64"
206
207        if uname == "Darwin":
208            return "macos-x64"
209
210        raise RuntimeError("Unsupported platform for Tailwind standalone")