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.
- starlette_tailwindcss-0.1.0/PKG-INFO +119 -0
- starlette_tailwindcss-0.1.0/README.md +108 -0
- starlette_tailwindcss-0.1.0/pyproject.toml +60 -0
- starlette_tailwindcss-0.1.0/src/starlette_tailwindcss/__init__.py +5 -0
- starlette_tailwindcss-0.1.0/src/starlette_tailwindcss/installer.py +202 -0
- starlette_tailwindcss-0.1.0/src/starlette_tailwindcss/py.typed +0 -0
- starlette_tailwindcss-0.1.0/src/starlette_tailwindcss/tailwindcss.py +218 -0
|
@@ -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,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
|
|
File without changes
|
|
@@ -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)
|