starlette-tailwindcss 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.3
2
+ Name: starlette-tailwindcss
3
+ Version: 0.1.0
4
+ Summary: TailwindCSS integration for Starlette apps
5
+ Author: pyk
6
+ Author-email: pyk <2213646+pyk@users.noreply.github.com>
7
+ Requires-Dist: platformdirs>=4.9.4
8
+ Requires-Dist: starlette>=1.0.0
9
+ Requires-Python: >=3.14
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Starlette Tailwind CSS
13
+
14
+ `starlette-tailwindcss` is a lightweight utility for
15
+ [Starlette](https://starlette.dev/) that builds Tailwind CSS on startup with
16
+ optional watch mode during development.
17
+
18
+ It integrates directly with your Starlette app and provides:
19
+
20
+ - Builds CSS on startup.
21
+ - Automatically rebuilds on changes in watch mode.
22
+ - Optional `tailwindcss` CLI binary auto-installation.
23
+ - Fully typed, following Starlette patterns.
24
+
25
+ ## Installation
26
+
27
+ ```shell
28
+ uv add starlette-tailwindcss
29
+ # or
30
+ pip install starlette-tailwindcss
31
+ ```
32
+
33
+ ## Example
34
+
35
+ ```python
36
+ from contextlib import asynccontextmanager
37
+ from pathlib import Path
38
+
39
+ from starlette.applications import Starlette
40
+ from starlette.routing import Mount
41
+ from starlette.staticfiles import StaticFiles
42
+
43
+ from starlette_tailwindcss import TailwindCSS
44
+
45
+ static_dir = Path(__file__).parent / "static"
46
+
47
+ tailwind = TailwindCSS(
48
+ version="v4.2.2",
49
+ input="src/acme/web/style.css",
50
+ output=static_dir / "css" / "output.css",
51
+ )
52
+
53
+ @asynccontextmanager
54
+ async def lifespan(app: Starlette):
55
+ async with tailwind.build(watch=app.debug):
56
+ yield
57
+
58
+ routes = [
59
+ Mount("/static", app=StaticFiles(directory=static_dir), name="static"),
60
+ ]
61
+
62
+ app = Starlette(
63
+ debug=True,
64
+ routes=routes,
65
+ lifespan=lifespan,
66
+ )
67
+ ```
68
+
69
+ Use the generated CSS file in your templates:
70
+
71
+ ```html
72
+ <link rel="stylesheet" href="{{ url_for('static', path='css/output.css') }}" />
73
+ ```
74
+
75
+ ## How it works
76
+
77
+ `starlette-tailwindcss` runs the Tailwind CLI alongside your app.
78
+
79
+ - Builds CSS when the app starts.
80
+ - Rebuilds CSS in watch mode during development.
81
+ - Stops the process when the app shuts down.
82
+
83
+ ## Usage
84
+
85
+ You can use an existing Tailwind CSS CLI binary:
86
+
87
+ ```python
88
+ tailwind = TailwindCSS(
89
+ bin_path="/usr/local/bin/tailwindcss",
90
+ input="src/acme/web/style.css",
91
+ output=static_dir / "css" / "output.css",
92
+ )
93
+ ```
94
+
95
+ Or let the package download a release automatically:
96
+
97
+ ```python
98
+ tailwind = TailwindCSS(
99
+ version="v4.2.2",
100
+ input="src/acme/web/style.css",
101
+ output=static_dir / "css" / "output.css",
102
+ )
103
+ ```
104
+
105
+ `bin_path` and `version` are mutually exclusive.
106
+
107
+ ## Debug logging
108
+
109
+ To see Tailwind CSS CLI output:
110
+
111
+ ```python
112
+ import logging
113
+
114
+ logging.basicConfig(level=logging.INFO)
115
+ ```
116
+
117
+ ## License
118
+
119
+ MIT
@@ -0,0 +1,108 @@
1
+ # Starlette Tailwind CSS
2
+
3
+ `starlette-tailwindcss` is a lightweight utility for
4
+ [Starlette](https://starlette.dev/) that builds Tailwind CSS on startup with
5
+ optional watch mode during development.
6
+
7
+ It integrates directly with your Starlette app and provides:
8
+
9
+ - Builds CSS on startup.
10
+ - Automatically rebuilds on changes in watch mode.
11
+ - Optional `tailwindcss` CLI binary auto-installation.
12
+ - Fully typed, following Starlette patterns.
13
+
14
+ ## Installation
15
+
16
+ ```shell
17
+ uv add starlette-tailwindcss
18
+ # or
19
+ pip install starlette-tailwindcss
20
+ ```
21
+
22
+ ## Example
23
+
24
+ ```python
25
+ from contextlib import asynccontextmanager
26
+ from pathlib import Path
27
+
28
+ from starlette.applications import Starlette
29
+ from starlette.routing import Mount
30
+ from starlette.staticfiles import StaticFiles
31
+
32
+ from starlette_tailwindcss import TailwindCSS
33
+
34
+ static_dir = Path(__file__).parent / "static"
35
+
36
+ tailwind = TailwindCSS(
37
+ version="v4.2.2",
38
+ input="src/acme/web/style.css",
39
+ output=static_dir / "css" / "output.css",
40
+ )
41
+
42
+ @asynccontextmanager
43
+ async def lifespan(app: Starlette):
44
+ async with tailwind.build(watch=app.debug):
45
+ yield
46
+
47
+ routes = [
48
+ Mount("/static", app=StaticFiles(directory=static_dir), name="static"),
49
+ ]
50
+
51
+ app = Starlette(
52
+ debug=True,
53
+ routes=routes,
54
+ lifespan=lifespan,
55
+ )
56
+ ```
57
+
58
+ Use the generated CSS file in your templates:
59
+
60
+ ```html
61
+ <link rel="stylesheet" href="{{ url_for('static', path='css/output.css') }}" />
62
+ ```
63
+
64
+ ## How it works
65
+
66
+ `starlette-tailwindcss` runs the Tailwind CLI alongside your app.
67
+
68
+ - Builds CSS when the app starts.
69
+ - Rebuilds CSS in watch mode during development.
70
+ - Stops the process when the app shuts down.
71
+
72
+ ## Usage
73
+
74
+ You can use an existing Tailwind CSS CLI binary:
75
+
76
+ ```python
77
+ tailwind = TailwindCSS(
78
+ bin_path="/usr/local/bin/tailwindcss",
79
+ input="src/acme/web/style.css",
80
+ output=static_dir / "css" / "output.css",
81
+ )
82
+ ```
83
+
84
+ Or let the package download a release automatically:
85
+
86
+ ```python
87
+ tailwind = TailwindCSS(
88
+ version="v4.2.2",
89
+ input="src/acme/web/style.css",
90
+ output=static_dir / "css" / "output.css",
91
+ )
92
+ ```
93
+
94
+ `bin_path` and `version` are mutually exclusive.
95
+
96
+ ## Debug logging
97
+
98
+ To see Tailwind CSS CLI output:
99
+
100
+ ```python
101
+ import logging
102
+
103
+ logging.basicConfig(level=logging.INFO)
104
+ ```
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,60 @@
1
+ [project]
2
+ name = "starlette-tailwindcss"
3
+ version = "0.1.0"
4
+ description = "TailwindCSS integration for Starlette apps"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "pyk", email = "2213646+pyk@users.noreply.github.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "platformdirs>=4.9.4",
12
+ "starlette>=1.0.0",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.11.0,<0.12.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "httpx>=0.28.1",
22
+ "jinja2>=3.1.6",
23
+ "pytest>=9.0.2",
24
+ "pytest-asyncio>=1.3.0",
25
+ "ruff>=0.15.8",
26
+ "ty>=0.0.27",
27
+ "uvicorn>=0.42.0",
28
+ ]
29
+
30
+ # ty - settings
31
+ [tool.ty.src]
32
+ exclude = ["external"]
33
+
34
+ # ruff - settings
35
+ [tool.ruff.lint]
36
+ select = ["ALL"]
37
+ ignore = [
38
+ # D212 and D213 are incompatible; we use D212 (summary on first line)
39
+ "D213",
40
+ # D203 and D211 are incompatible; we use D211 (no blank line before class)
41
+ "D203",
42
+ # Trailing comma rule conflict with formatter
43
+ "COM812",
44
+ ]
45
+ exclude = ["external/**"]
46
+
47
+ [tool.ruff.lint.per-file-ignores]
48
+ "tests/**/*.py" = ["S101"]
49
+
50
+ [tool.ruff.lint.pylint]
51
+ max-args = 5
52
+
53
+ # pytest - settings
54
+ [tool.pytest.ini_options]
55
+ testpaths = [
56
+ "tests",
57
+ ]
58
+ norecursedirs = [
59
+ "external"
60
+ ]
@@ -0,0 +1,5 @@
1
+ """Starlette integration for the Tailwind CSS CLI."""
2
+
3
+ from .tailwindcss import TailwindCSS
4
+
5
+ __all__ = ["TailwindCSS"]
@@ -0,0 +1,202 @@
1
+ """Tailwind CSS binary installation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ import platform
8
+ import stat
9
+ import tempfile
10
+ import urllib.error
11
+ import urllib.request
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from platformdirs import user_cache_dir
17
+
18
+ if TYPE_CHECKING:
19
+ import os
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _APP_NAME = "starlette-tailwindcss"
24
+ _RELEASE_BASE_URL = "https://github.com/tailwindlabs/tailwindcss/releases/download"
25
+ _PROGRESS_MAX = 100
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class _Target:
30
+ """Resolved binary names for the current platform."""
31
+
32
+ asset_name: str
33
+ cache_name: str
34
+ binary_name: str
35
+
36
+
37
+ def _normalize_machine(machine: str) -> str:
38
+ """Normalize platform machine names to Tailwind release names."""
39
+ value = machine.lower()
40
+ if value in {"x86_64", "amd64"}:
41
+ return "x64"
42
+ if value in {"aarch64", "arm64"}:
43
+ return "arm64"
44
+ msg = f"Unsupported machine architecture: {machine}"
45
+ raise RuntimeError(msg)
46
+
47
+
48
+ def _is_musl() -> bool:
49
+ """Return `True` when the current Linux libc is musl."""
50
+ libc_name, _ = platform.libc_ver()
51
+ return libc_name.lower() == "musl"
52
+
53
+
54
+ def _target_platform() -> _Target:
55
+ """Build the Tailwind release target for the current platform."""
56
+ system = platform.system().lower()
57
+ arch = _normalize_machine(platform.machine())
58
+
59
+ if system == "linux":
60
+ suffix = "-musl" if _is_musl() else ""
61
+ asset_name = f"tailwindcss-linux-{arch}{suffix}"
62
+ return _Target(
63
+ asset_name=asset_name,
64
+ cache_name=asset_name,
65
+ binary_name=asset_name,
66
+ )
67
+ if system == "darwin":
68
+ asset_name = f"tailwindcss-macos-{arch}"
69
+ return _Target(
70
+ asset_name=asset_name,
71
+ cache_name=f"macos-{arch}",
72
+ binary_name=asset_name,
73
+ )
74
+ if system == "windows":
75
+ asset_name = "tailwindcss-windows-x64.exe"
76
+ return _Target(
77
+ asset_name=asset_name,
78
+ cache_name="windows-x64",
79
+ binary_name=asset_name,
80
+ )
81
+
82
+ msg = f"Unsupported operating system: {platform.system()}"
83
+ raise RuntimeError(msg)
84
+
85
+
86
+ def _sha256(path: Path) -> str:
87
+ """Calculate a SHA-256 digest for a file."""
88
+ digest = hashlib.sha256()
89
+ with path.open("rb") as file:
90
+ for chunk in iter(lambda: file.read(1024 * 1024), b""):
91
+ digest.update(chunk)
92
+ return digest.hexdigest()
93
+
94
+
95
+ def _read_url(url: str) -> bytes:
96
+ """Read the full contents of a URL."""
97
+ with urllib.request.urlopen(url) as response: # noqa: S310
98
+ return response.read()
99
+
100
+
101
+ def _download_to_path(url: str, path: Path) -> None:
102
+ """Download a URL into a file path atomically."""
103
+ path.parent.mkdir(parents=True, exist_ok=True)
104
+ with (
105
+ urllib.request.urlopen(url) as response, # noqa: S310
106
+ tempfile.NamedTemporaryFile(
107
+ delete=False,
108
+ dir=path.parent,
109
+ prefix=f".{path.name}.",
110
+ suffix=".tmp",
111
+ ) as file,
112
+ ):
113
+ total_bytes_raw = response.headers.get("Content-Length")
114
+ total_bytes = int(total_bytes_raw) if total_bytes_raw is not None else None
115
+ downloaded = 0
116
+ next_progress = 25
117
+ if total_bytes is not None:
118
+ logger.debug("Installing Tailwind CSS binary: 0%%")
119
+ while chunk := response.read(1024 * 1024):
120
+ file.write(chunk)
121
+ if total_bytes is None:
122
+ continue
123
+ downloaded += len(chunk)
124
+ percent = (downloaded * _PROGRESS_MAX) // total_bytes
125
+ while next_progress <= _PROGRESS_MAX and percent >= next_progress:
126
+ logger.debug(
127
+ "Installing Tailwind CSS binary: %s%%",
128
+ next_progress,
129
+ )
130
+ next_progress += 25
131
+ temp_path = Path(file.name)
132
+ temp_path.replace(path)
133
+
134
+
135
+ def _parse_checksum_manifest(content: str) -> dict[str, str]:
136
+ """Parse Tailwind's checksum manifest into a mapping."""
137
+ checksums: dict[str, str] = {}
138
+ for raw_line in content.splitlines():
139
+ line = raw_line.strip()
140
+ if not line:
141
+ continue
142
+ checksum, asset_name = line.split(maxsplit=1)
143
+ checksums[asset_name.removeprefix("./")] = checksum
144
+ return checksums
145
+
146
+
147
+ def _ensure_executable(path: Path) -> None:
148
+ """Mark a file executable for the current user."""
149
+ mode = path.stat().st_mode
150
+ path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
151
+
152
+
153
+ def download_binary(
154
+ version: str,
155
+ cache_dir: str | os.PathLike[str] | None = None,
156
+ ) -> Path:
157
+ """Download, verify, and cache the Tailwind binary for a release version."""
158
+ logger.debug("Starting Tailwind CSS auto-install: version=%s", version)
159
+ target = _target_platform()
160
+ cache_root = (
161
+ Path(user_cache_dir(_APP_NAME))
162
+ if cache_dir is None
163
+ else Path(cache_dir).expanduser()
164
+ )
165
+ binary_path = cache_root / version / target.cache_name / target.binary_name
166
+ if binary_path.exists():
167
+ _ensure_executable(binary_path)
168
+ logger.debug("Using cached Tailwind CSS binary: %s", binary_path)
169
+ return binary_path
170
+
171
+ release_base = f"{_RELEASE_BASE_URL}/{version}"
172
+ logger.debug("Tailwind CSS binary cache miss: %s", binary_path)
173
+ try:
174
+ manifest = _parse_checksum_manifest(
175
+ _read_url(f"{release_base}/sha256sums.txt").decode("utf-8"),
176
+ )
177
+ except urllib.error.URLError as exc:
178
+ msg = f"Failed to download Tailwind CSS checksum manifest for {version}"
179
+ raise RuntimeError(msg) from exc
180
+ expected_checksum = manifest.get(target.asset_name)
181
+ if expected_checksum is None:
182
+ msg = f"Checksum for {target.asset_name} was not found in the release manifest"
183
+ raise RuntimeError(msg)
184
+
185
+ asset_url = f"{release_base}/{target.asset_name}"
186
+ try:
187
+ _download_to_path(asset_url, binary_path)
188
+ except urllib.error.URLError as exc:
189
+ msg = f"Failed to download Tailwind CSS binary for {version}"
190
+ raise RuntimeError(msg) from exc
191
+ actual_checksum = _sha256(binary_path)
192
+ if actual_checksum != expected_checksum:
193
+ binary_path.unlink(missing_ok=True)
194
+ msg = (
195
+ "Downloaded Tailwind CSS binary checksum mismatch: "
196
+ f"expected {expected_checksum}, got {actual_checksum}"
197
+ )
198
+ raise RuntimeError(msg)
199
+
200
+ _ensure_executable(binary_path)
201
+ logger.debug("Finished Tailwind CSS auto-install: %s", binary_path)
202
+ return binary_path
@@ -0,0 +1,218 @@
1
+ # ruff: noqa: A002
2
+ """Tailwind CSS CLI integration for Starlette applications."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import contextlib
8
+ import logging
9
+ import shutil
10
+ from contextlib import asynccontextmanager
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, overload
13
+
14
+ from .installer import download_binary
15
+
16
+ if TYPE_CHECKING:
17
+ import os
18
+ from collections.abc import AsyncIterator
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _DEFAULT_BIN_NAME = "tailwindcss"
23
+ _PROCESS_STOP_TIMEOUT = 5.0
24
+
25
+
26
+ class TailwindCSS:
27
+ """Manage Tailwind CSS CLI build and watch mode."""
28
+
29
+ @overload
30
+ def __init__(
31
+ self,
32
+ *,
33
+ bin_path: str | os.PathLike[str],
34
+ input: str | os.PathLike[str],
35
+ output: str | os.PathLike[str],
36
+ ) -> None: ...
37
+
38
+ @overload
39
+ def __init__(
40
+ self,
41
+ *,
42
+ version: str,
43
+ input: str | os.PathLike[str],
44
+ output: str | os.PathLike[str],
45
+ cache_dir: str | os.PathLike[str] | None = None,
46
+ ) -> None: ...
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ input: str | os.PathLike[str],
52
+ output: str | os.PathLike[str],
53
+ bin_path: str | os.PathLike[str] | None = None,
54
+ version: str | None = None,
55
+ cache_dir: str | os.PathLike[str] | None = None,
56
+ ) -> None:
57
+ """Create a Tailwind CSS integration configuration."""
58
+ if bin_path is not None and version is not None:
59
+ msg = "`bin_path` and `version` are mutually exclusive"
60
+ raise ValueError(msg)
61
+ if cache_dir is not None and version is None:
62
+ msg = "`cache_dir` requires `version`"
63
+ raise ValueError(msg)
64
+ if cache_dir is not None and bin_path is not None:
65
+ msg = "`cache_dir` is only valid with `version`"
66
+ raise ValueError(msg)
67
+
68
+ self.input = Path(input)
69
+ self.output = Path(output)
70
+ self.bin_path = Path(bin_path).expanduser() if bin_path is not None else None
71
+ self.version = version
72
+ self.cache_dir = Path(cache_dir).expanduser() if cache_dir is not None else None
73
+
74
+ @asynccontextmanager
75
+ async def build(self, *, watch: bool = False) -> AsyncIterator[None]:
76
+ """Build Tailwind CSS once and optionally watch for changes."""
77
+ binary = await self._resolve_binary()
78
+ await self._build_once(binary)
79
+
80
+ watch_process: asyncio.subprocess.Process | None = None
81
+ stream_tasks: list[asyncio.Task[None]] = []
82
+ if watch:
83
+ watch_process, stream_tasks = await self._spawn_watch(binary)
84
+
85
+ try:
86
+ yield
87
+ finally:
88
+ await self._shutdown_watch(watch_process, stream_tasks)
89
+
90
+ async def _build_once(self, binary: Path) -> None:
91
+ """Run a one-time Tailwind build."""
92
+ logger.info(
93
+ "Building Tailwind CSS output: %s build -i %s -o %s",
94
+ binary,
95
+ self.input,
96
+ self.output,
97
+ )
98
+ self.output.parent.mkdir(parents=True, exist_ok=True)
99
+ process = await asyncio.create_subprocess_exec(
100
+ str(binary),
101
+ "build",
102
+ "-i",
103
+ str(self.input),
104
+ "-o",
105
+ str(self.output),
106
+ stdout=asyncio.subprocess.PIPE,
107
+ stderr=asyncio.subprocess.PIPE,
108
+ )
109
+ stream_tasks: list[asyncio.Task[None]] = []
110
+ if process.stdout is not None:
111
+ stream_tasks.append(
112
+ asyncio.create_task(self._forward_stream(process.stdout, logging.INFO))
113
+ )
114
+ if process.stderr is not None:
115
+ stream_tasks.append(
116
+ asyncio.create_task(self._forward_stream(process.stderr, logging.DEBUG))
117
+ )
118
+ return_code = await process.wait()
119
+ await self._drain_stream_tasks(stream_tasks)
120
+ if return_code != 0:
121
+ msg = f"Tailwind CSS build failed with exit code {return_code}"
122
+ raise RuntimeError(msg)
123
+
124
+ async def _spawn_watch(
125
+ self,
126
+ binary: Path,
127
+ ) -> tuple[asyncio.subprocess.Process, list[asyncio.Task[None]]]:
128
+ """Start the Tailwind watch process and stream its output."""
129
+ logger.info(
130
+ "Spawning Tailwind CSS CLI in background: %s -i %s -o %s --watch",
131
+ binary,
132
+ self.input,
133
+ self.output,
134
+ )
135
+ self.output.parent.mkdir(parents=True, exist_ok=True)
136
+ process = await asyncio.create_subprocess_exec(
137
+ str(binary),
138
+ "-i",
139
+ str(self.input),
140
+ "-o",
141
+ str(self.output),
142
+ "--watch",
143
+ stdout=asyncio.subprocess.PIPE,
144
+ stderr=asyncio.subprocess.PIPE,
145
+ )
146
+ stream_tasks: list[asyncio.Task[None]] = []
147
+ if process.stdout is not None:
148
+ stream_tasks.append(
149
+ asyncio.create_task(self._forward_stream(process.stdout, logging.INFO))
150
+ )
151
+ if process.stderr is not None:
152
+ stream_tasks.append(
153
+ asyncio.create_task(self._forward_stream(process.stderr, logging.DEBUG))
154
+ )
155
+ return process, stream_tasks
156
+
157
+ async def _shutdown_watch(
158
+ self,
159
+ process: asyncio.subprocess.Process | None,
160
+ stream_tasks: list[asyncio.Task[None]],
161
+ ) -> None:
162
+ """Stop the Tailwind watch process and cancel output tasks."""
163
+ if process is None:
164
+ return
165
+
166
+ logger.info("Killing spawned Tailwind CSS CLI process: pid=%s", process.pid)
167
+ if process.returncode is None:
168
+ process.terminate()
169
+ try:
170
+ await asyncio.wait_for(process.wait(), timeout=_PROCESS_STOP_TIMEOUT)
171
+ except TimeoutError:
172
+ process.kill()
173
+ await process.wait()
174
+
175
+ await self._drain_stream_tasks(stream_tasks)
176
+
177
+ async def _drain_stream_tasks(self, stream_tasks: list[asyncio.Task[None]]) -> None:
178
+ """Cancel process stream forwarders and wait for them to finish."""
179
+ for task in stream_tasks:
180
+ if not task.done():
181
+ task.cancel()
182
+ for task in stream_tasks:
183
+ with contextlib.suppress(asyncio.CancelledError):
184
+ await task
185
+
186
+ async def _forward_stream(self, stream: asyncio.StreamReader, level: int) -> None:
187
+ """Forward a process stream into the configured logger."""
188
+ while True:
189
+ line = await stream.readline()
190
+ if not line:
191
+ return
192
+ message = line.decode("utf-8", errors="replace").rstrip()
193
+ if message:
194
+ logger.log(level, "%s", message)
195
+
196
+ async def _resolve_binary(self) -> Path:
197
+ """Return the binary to execute, resolving local or downloaded input."""
198
+ if self.version is None:
199
+ return self._resolve_local_binary()
200
+ return await asyncio.to_thread(download_binary, self.version, self.cache_dir)
201
+
202
+ def _resolve_local_binary(self) -> Path:
203
+ """Resolve a local Tailwind binary from `bin_path` or `PATH`."""
204
+ if self.bin_path is not None:
205
+ candidate = self.bin_path
206
+ if candidate.exists():
207
+ return candidate
208
+ resolved = shutil.which(str(candidate))
209
+ if resolved is not None:
210
+ return Path(resolved)
211
+ msg = f"Tailwind CSS binary not found: {candidate}"
212
+ raise FileNotFoundError(msg)
213
+
214
+ resolved = shutil.which(_DEFAULT_BIN_NAME)
215
+ if resolved is None:
216
+ msg = f"`{_DEFAULT_BIN_NAME}` was not found on PATH"
217
+ raise FileNotFoundError(msg)
218
+ return Path(resolved)