Skip to content

Commit e19c8b4

Browse files
committed
feat(config): support system-installed Tailwind CSS CLI
Add TAILWIND_CLI_USE_SYSTEM_BINARY (opt-in) so users with a tailwindcss binary already on PATH — e.g. installed via Homebrew — can skip the automatic download entirely. The binary is resolved via shutil.which(), which works uniformly across Intel/ARM macOS, Linuxbrew and system package managers without hardcoding paths. When enabled: - The download flow is bypassed in _download_cli_with_verbose(); no network calls, no files under TAILWIND_CLI_PATH. - remove_cli refuses to delete the binary, since the library did not install it. - The tailwind config command reports the origin ("system binary" vs "managed download") and lists the new settings. - If TAILWIND_CLI_VERSION is pinned and the installed binary reports a different version, a UserWarning is emitted. No warning when VERSION is "latest" (user has no explicit expectation) or when version detection fails (subprocess error, unparseable output). Version detection runs `<binary> --help` and parses the first line of stdout. The --version flag is deliberately not used: unknown flags are interpreted by the CLI as a build invocation. Optional TAILWIND_CLI_SYSTEM_BINARY_NAME lets users override the executable name; defaults to "tailwindcss", or "tailwindcss-extra" when DaisyUI is enabled. Mutually exclusive with TAILWIND_CLI_PATH.
1 parent 68342bb commit e19c8b4

7 files changed

Lines changed: 599 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### 🎯 New Features
66
- **Configurable minification**: New `TAILWIND_CLI_AUTOMATIC_MINIFY` setting and `--minify` / `--no-minify` flag on `tailwind build` for projects whose asset pipelines already minify CSS. Defaults preserve existing behavior.
7+
- **System binary support**: New `TAILWIND_CLI_USE_SYSTEM_BINARY` setting lets `django-tailwind-cli` use a Tailwind CSS CLI that is already installed on `PATH` (e.g. via Homebrew), skipping the auto-download. Pairs with optional `TAILWIND_CLI_SYSTEM_BINARY_NAME` override. Emits a warning if the installed binary's version differs from an explicitly pinned `TAILWIND_CLI_VERSION`.
78

89
### 🛠️ Developer Experience
910
- **Gitignore cleanup**: Trimmed `.gitignore` to project-relevant entries only

docs/installation.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@
6969

7070
## Optional steps
7171

72+
### Use a system-installed Tailwind CSS CLI
73+
74+
By default, `django-tailwind-cli` downloads the Tailwind CSS CLI binary on first use and caches it inside your project. If you already have `tailwindcss` installed through [Homebrew](https://formulae.brew.sh/formula/tailwindcss) or another package manager, you can tell the library to use that binary instead and skip the download:
75+
76+
```python
77+
# settings.py
78+
TAILWIND_CLI_USE_SYSTEM_BINARY = True
79+
```
80+
81+
See [`TAILWIND_CLI_USE_SYSTEM_BINARY`](settings.md#tailwind_cli_use_system_binary) for details.
82+
7283
### Install `django-browser-reload`
7384

7485
If you enjoy automatic reloading during development. Install the [django-browser-reload](https://github.com/adamchainz/django-browser-reload) app. The following installation steps are taken from the README of the project.

docs/settings.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,39 @@ In case you want to use the new behaviour, it is highly recommended to also set
4242

4343
Enable or disable the automatic downloading of the official CLI to your machine.
4444

45+
### TAILWIND_CLI_USE_SYSTEM_BINARY
46+
47+
**Default**: `False`
48+
49+
If set to `True`, the library uses a Tailwind CSS CLI that is already installed on your system's `PATH` (for example via [Homebrew](https://formulae.brew.sh/formula/tailwindcss) or a system package manager) instead of downloading its own copy. The binary is resolved with Python's `shutil.which()`, so it works on any platform as long as the executable is reachable via `PATH`.
50+
51+
When enabled:
52+
53+
- The automatic download is skipped entirely — no network calls, no files created under `TAILWIND_CLI_PATH`.
54+
- `python manage.py tailwind remove_cli` refuses to delete the binary (since the library did not install it).
55+
- If `TAILWIND_CLI_VERSION` is pinned to a specific version and the system binary reports a different version, a warning is emitted so you can reconcile the discrepancy. No warning is issued when `TAILWIND_CLI_VERSION = "latest"`.
56+
57+
```python
58+
# settings.py
59+
TAILWIND_CLI_USE_SYSTEM_BINARY = True
60+
```
61+
62+
:::{warning}
63+
`TAILWIND_CLI_USE_SYSTEM_BINARY` is **mutually exclusive** with `TAILWIND_CLI_PATH`. Use one or the other.
64+
:::
65+
66+
### TAILWIND_CLI_SYSTEM_BINARY_NAME
67+
68+
**Default**: `"tailwindcss"` (or `"tailwindcss-extra"` when `TAILWIND_CLI_USE_DAISY_UI = True`)
69+
70+
Overrides the executable name that is looked up on `PATH` when `TAILWIND_CLI_USE_SYSTEM_BINARY = True`. You rarely need to set this — the default picks the right name automatically.
71+
72+
```python
73+
# settings.py
74+
TAILWIND_CLI_USE_SYSTEM_BINARY = True
75+
TAILWIND_CLI_SYSTEM_BINARY_NAME = "my-tailwindcss" # optional
76+
```
77+
4578
### TAILWIND_CLI_AUTOMATIC_MINIFY
4679

4780
**Default**: `True`
@@ -285,6 +318,21 @@ In your templates, include all CSS files or filter by name:
285318

286319
The `build` command processes all entries, and the `watch` command monitors all source files simultaneously.
287320

321+
### Using a Homebrew-installed Tailwind CSS CLI
322+
323+
If you have already installed `tailwindcss` through [Homebrew](https://formulae.brew.sh/formula/tailwindcss) (or any other package manager that puts it on your `PATH`), you can skip the automatic download entirely:
324+
325+
```bash
326+
brew install tailwindcss
327+
```
328+
329+
```python
330+
# settings.py
331+
TAILWIND_CLI_USE_SYSTEM_BINARY = True
332+
```
333+
334+
That's it — the library resolves `tailwindcss` via `PATH` on every invocation and runs your builds against that binary. Pairs well with `TAILWIND_CLI_VERSION = "latest"` so you don't have to update two places when Homebrew bumps the version.
335+
288336
### Staging Environment
289337

290338
Balanced between dev flexibility and prod stability:

src/django_tailwind_cli/config.py

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@
5555
Default: True
5656
Example: TAILWIND_CLI_AUTOMATIC_DOWNLOAD = False
5757
58+
TAILWIND_CLI_USE_SYSTEM_BINARY (optional): Use a CLI on PATH
59+
Default: False
60+
Example: TAILWIND_CLI_USE_SYSTEM_BINARY = True
61+
Note: Mutually exclusive with TAILWIND_CLI_PATH. When enabled,
62+
the binary is resolved via shutil.which() and the
63+
automatic download is skipped.
64+
65+
TAILWIND_CLI_SYSTEM_BINARY_NAME (optional): Name for PATH lookup
66+
Default: "tailwindcss" (or "tailwindcss-extra" with DaisyUI)
67+
Example: TAILWIND_CLI_SYSTEM_BINARY_NAME = "my-tailwindcss"
68+
5869
TAILWIND_CLI_REQUEST_TIMEOUT (optional): Network request timeout
5970
Default: 10 (seconds)
6071
Example: TAILWIND_CLI_REQUEST_TIMEOUT = 30
@@ -87,10 +98,15 @@
8798
]
8899
"""
89100

101+
import functools
90102
import os
91103
import platform
104+
import re
105+
import shutil
106+
import subprocess
92107
import tempfile
93108
import time
109+
import warnings
94110
from dataclasses import dataclass
95111
from pathlib import Path
96112
from typing import NamedTuple
@@ -121,6 +137,7 @@ class Config:
121137
overwrite_default_config: bool = True
122138
automatic_download: bool = True
123139
use_daisy_ui: bool = False
140+
uses_system_binary: bool = False
124141

125142
# Backward compatibility properties
126143
@property
@@ -222,10 +239,36 @@ def _validate_required_settings() -> None:
222239
"TAILWIND_CLI_SRC_REPO must not be empty. Either remove the setting or provide a valid repository URL."
223240
)
224241

242+
# Validate system-binary settings
243+
_validate_system_binary_settings()
244+
225245
# Validate mutual exclusivity of CSS settings
226246
_validate_css_settings()
227247

228248

249+
def _validate_system_binary_settings() -> None:
250+
"""Validate TAILWIND_CLI_USE_SYSTEM_BINARY and friends.
251+
252+
Raises:
253+
ValueError: If configuration is inconsistent or invalid.
254+
"""
255+
use_system_binary = getattr(settings, "TAILWIND_CLI_USE_SYSTEM_BINARY", False)
256+
binary_name = getattr(settings, "TAILWIND_CLI_SYSTEM_BINARY_NAME", None)
257+
258+
if binary_name is not None and not binary_name:
259+
raise ValueError(
260+
"TAILWIND_CLI_SYSTEM_BINARY_NAME must not be empty. "
261+
"Either remove the setting or provide a valid binary name."
262+
)
263+
264+
if use_system_binary and getattr(settings, "TAILWIND_CLI_PATH", None):
265+
raise ValueError(
266+
"Cannot use TAILWIND_CLI_USE_SYSTEM_BINARY together with TAILWIND_CLI_PATH. "
267+
"Choose one: either point TAILWIND_CLI_PATH at a specific binary, or set "
268+
"TAILWIND_CLI_USE_SYSTEM_BINARY = True to look up the binary on PATH."
269+
)
270+
271+
229272
def _validate_css_settings() -> None:
230273
"""Validate CSS configuration settings for mutual exclusivity.
231274
@@ -407,6 +450,90 @@ def get_version() -> tuple[str, Version]:
407450
return version_str, Version.parse(version_str)
408451

409452

453+
_VERSION_PATTERN = re.compile(r"tailwindcss v(\d+\.\d+\.\d+)")
454+
455+
456+
@functools.cache
457+
def detect_binary_version(cli_path: Path) -> Version | None:
458+
"""Detect the version of a Tailwind CSS CLI binary.
459+
460+
Runs ``<cli_path> --help`` and parses the version from the first line of
461+
stdout. Returns None on any failure (timeout, non-zero exit, unparseable
462+
output) — callers are expected to treat None as "unknown" and skip any
463+
version comparisons rather than raising.
464+
465+
Note: ``--version`` is intentionally not used because the Tailwind CLI
466+
interprets unknown flags as a build invocation, which would run a full
467+
CSS build instead of reporting the version.
468+
469+
Args:
470+
cli_path: Path to the CLI binary.
471+
472+
Returns:
473+
Parsed Version on success, None on any failure.
474+
"""
475+
try:
476+
result = subprocess.run(
477+
[str(cli_path), "--help"],
478+
capture_output=True,
479+
text=True,
480+
timeout=5,
481+
check=False,
482+
)
483+
except (OSError, subprocess.SubprocessError):
484+
return None
485+
486+
if result.returncode != 0:
487+
return None
488+
489+
match = _VERSION_PATTERN.search(result.stdout)
490+
if not match:
491+
return None
492+
493+
try:
494+
return Version.parse(match.group(1))
495+
except ValueError:
496+
return None
497+
498+
499+
def _resolve_system_binary(binary_name: str) -> Path:
500+
"""Resolve a binary on the user's PATH.
501+
502+
Args:
503+
binary_name: Name of the executable to look up (e.g. "tailwindcss").
504+
505+
Returns:
506+
Absolute path to the binary.
507+
508+
Raises:
509+
ValueError: If the binary cannot be found on PATH.
510+
"""
511+
found = shutil.which(binary_name)
512+
if not found:
513+
raise ValueError(
514+
f"TAILWIND_CLI_USE_SYSTEM_BINARY is enabled, but the binary {binary_name!r} "
515+
"could not be found on PATH. Install it (e.g. 'brew install tailwindcss') "
516+
"or disable TAILWIND_CLI_USE_SYSTEM_BINARY to use the automatic download instead."
517+
)
518+
return Path(found)
519+
520+
521+
def _get_system_binary_name(*, use_daisy_ui: bool) -> str:
522+
"""Return the system binary name to look up via shutil.which.
523+
524+
Args:
525+
use_daisy_ui: Whether DaisyUI support is enabled.
526+
527+
Returns:
528+
Binary name — honours the explicit TAILWIND_CLI_SYSTEM_BINARY_NAME
529+
override if set, otherwise picks a DaisyUI-aware default.
530+
"""
531+
override = getattr(settings, "TAILWIND_CLI_SYSTEM_BINARY_NAME", None)
532+
if override:
533+
return override
534+
return "tailwindcss-extra" if use_daisy_ui else "tailwindcss"
535+
536+
410537
def _resolve_cli_path(platform_info: PlatformInfo, version_str: str, asset_name: str) -> Path:
411538
"""Resolve the CLI executable path.
412539
@@ -569,6 +696,7 @@ def get_config() -> Config:
569696
# Get basic settings
570697
use_daisy_ui = getattr(settings, "TAILWIND_CLI_USE_DAISY_UI", False)
571698
automatic_download = getattr(settings, "TAILWIND_CLI_AUTOMATIC_DOWNLOAD", True)
699+
uses_system_binary = bool(getattr(settings, "TAILWIND_CLI_USE_SYSTEM_BINARY", False))
572700

573701
# Get platform information
574702
platform_info = get_platform_info()
@@ -580,7 +708,15 @@ def get_config() -> Config:
580708
repo_url, asset_name = _get_repository_settings(use_daisy_ui=use_daisy_ui)
581709

582710
# Resolve paths
583-
cli_path = _resolve_cli_path(platform_info, version_str, asset_name)
711+
if uses_system_binary:
712+
binary_name = _get_system_binary_name(use_daisy_ui=use_daisy_ui)
713+
cli_path = _resolve_system_binary(binary_name)
714+
# System binary mode implies auto-download is off — we never downloaded it.
715+
automatic_download = False
716+
_maybe_warn_version_mismatch(cli_path, version_str)
717+
else:
718+
cli_path = _resolve_cli_path(platform_info, version_str, asset_name)
719+
584720
css_entries, overwrite_default_config = _resolve_css_paths()
585721

586722
# Build download URL
@@ -598,4 +734,37 @@ def get_config() -> Config:
598734
overwrite_default_config=overwrite_default_config,
599735
automatic_download=automatic_download,
600736
use_daisy_ui=use_daisy_ui,
737+
uses_system_binary=uses_system_binary,
738+
)
739+
740+
741+
def _maybe_warn_version_mismatch(cli_path: Path, configured_version: str) -> None:
742+
"""Warn when the system binary reports a different version than configured.
743+
744+
No warning is emitted when:
745+
- TAILWIND_CLI_VERSION is 'latest' (user has no explicit expectation).
746+
- Version detection fails (subprocess error, unparseable output).
747+
- The versions match.
748+
749+
Args:
750+
cli_path: Path to the system binary.
751+
configured_version: Version string as resolved by get_version().
752+
"""
753+
# When the user set VERSION='latest', they accepted whatever is installed.
754+
if getattr(settings, "TAILWIND_CLI_VERSION", "latest") == "latest":
755+
return
756+
757+
detected = detect_binary_version(cli_path)
758+
if detected is None:
759+
return
760+
761+
if str(detected) == configured_version:
762+
return
763+
764+
warnings.warn(
765+
f"TAILWIND_CLI_VERSION is set to {configured_version}, but the system binary at "
766+
f"{cli_path} reports version {detected}. Using the system binary anyway — "
767+
"update TAILWIND_CLI_VERSION or your installed binary to silence this warning.",
768+
UserWarning,
769+
stacklevel=2,
601770
)

src/django_tailwind_cli/management/commands/tailwind.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,8 @@ def show_config():
583583
# Path information
584584
typer.secho("\n📁 File Paths:", fg=typer.colors.YELLOW, bold=True)
585585
cli_exists = "✅" if config.cli_path.exists() else "❌"
586-
typer.secho(f" CLI Binary: {config.cli_path} {cli_exists}", fg=typer.colors.GREEN)
586+
origin = "system binary" if config.uses_system_binary else "managed download"
587+
typer.secho(f" CLI Binary: {config.cli_path} {cli_exists} ({origin})", fg=typer.colors.GREEN)
587588

588589
# CSS Entries
589590
typer.secho(f"\n📄 CSS Entries ({len(config.css_entries)}):", fg=typer.colors.YELLOW, bold=True)
@@ -606,6 +607,12 @@ def show_config():
606607
if cli_path_setting:
607608
typer.secho(f" TAILWIND_CLI_PATH: {cli_path_setting}", fg=typer.colors.GREEN)
608609

610+
if getattr(settings, "TAILWIND_CLI_USE_SYSTEM_BINARY", False):
611+
typer.secho(" TAILWIND_CLI_USE_SYSTEM_BINARY: True", fg=typer.colors.GREEN)
612+
system_binary_name = getattr(settings, "TAILWIND_CLI_SYSTEM_BINARY_NAME", None)
613+
if system_binary_name:
614+
typer.secho(f" TAILWIND_CLI_SYSTEM_BINARY_NAME: {system_binary_name}", fg=typer.colors.GREEN)
615+
609616
# Show CSS settings based on mode
610617
css_map_setting = getattr(settings, "TAILWIND_CLI_CSS_MAP", None)
611618
if css_map_setting:
@@ -1058,6 +1065,15 @@ def remove_cli():
10581065
"""Remove the Tailwind CSS CLI."""
10591066
c = get_config()
10601067

1068+
if c.uses_system_binary:
1069+
typer.secho(
1070+
f"Refusing to remove system Tailwind CSS CLI at '{c.cli_path}'. "
1071+
"It was installed outside of django-tailwind-cli (e.g. via Homebrew) and must be "
1072+
"uninstalled the same way.",
1073+
fg=typer.colors.YELLOW,
1074+
)
1075+
return
1076+
10611077
if c.cli_path.exists():
10621078
c.cli_path.unlink()
10631079
typer.secho(f"Removed Tailwind CSS CLI at '{c.cli_path}'.", fg=typer.colors.GREEN)
@@ -1613,6 +1629,16 @@ def _download_cli_with_verbose(*, verbose: bool = False, force_download: bool =
16131629
typer.secho(f" • Download URL: {c.download_url}", fg=typer.colors.BLUE)
16141630
typer.secho(f" • Automatic download: {c.automatic_download}", fg=typer.colors.BLUE)
16151631

1632+
# System-binary mode: the CLI lives on PATH, never download it.
1633+
if c.uses_system_binary:
1634+
if verbose:
1635+
typer.secho("✅ Using system Tailwind CSS CLI — download skipped", fg=typer.colors.GREEN)
1636+
typer.secho(
1637+
f"Using system Tailwind CSS CLI at '{c.cli_path}'.",
1638+
fg=typer.colors.GREEN,
1639+
)
1640+
return
1641+
16161642
if not force_download and not c.automatic_download:
16171643
if not _check_file_exists_cached(c.cli_path):
16181644
if verbose:

0 commit comments

Comments
 (0)