Skip to content

Commit b8a306f

Browse files
committed
feat(config): auto @source external apps and watch-mode auto-reload
Two related features addressing issue #187, both landed together so they compose on the first release. 1. Auto @source injection (opt-in): New setting TAILWIND_CLI_AUTO_SOURCE_EXTERNAL_APPS (default False). When enabled, the auto-generated default source.css gains one @source directive per installed Django app whose path lies both outside BASE_DIR and outside every known site-packages directory. That's the exact shape of the issue #187 pain point: editable installs of a sibling source repository whose templates Tailwind cannot see on its own. Internal apps are left to Tailwind's CWD walk; regular pip-installed third-party apps are filtered out via a site.getsitepackages() / sysconfig check so we don't pull in django.contrib.admin and friends. The directive points at the app base dir, not at a glob, so Tailwind's own file walker handles extension detection and .gitignore exclusion — and incidentally picks up Tailwind class strings embedded in Python code (form widgets, admin Media classes, etc.). 2. Watch-mode auto-reload: tailwind watch now wraps its loop in django.utils.autoreload.run_with_reloader — the same machinery manage.py runserver uses. Editing settings.py (e.g. adding a new INSTALLED_APPS entry) restarts the watch process, regenerates source.css with the fresh external-app list, and respawns the Tailwind CLI subprocess. Pass --noreload to disable. Tests that exercise watch via call_command now bypass the reloader with an autouse fixture that mocks run_with_reloader to invoke its callable directly. This keeps the existing test suite in-process instead of forking per invocation. Opt-in on the setting was a deliberate choice: the feature injects extra directives into a generated file and expands Tailwind's scan scope. Users who don't need it see no behaviour change, and the one user who hits the issue flips a single flag.
1 parent 861d7af commit b8a306f

10 files changed

Lines changed: 508 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
### 🎯 New Features
99
- **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.
1010
- **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`.
11+
- **Auto `@source` for editable external apps** (opt-in): New `TAILWIND_CLI_AUTO_SOURCE_EXTERNAL_APPS` setting (default `False`). When enabled, the auto-generated default source CSS receives one `@source` directive per installed Django app whose path lives outside both `BASE_DIR` and site-packages — typically editable-installed packages that ship their own templates. This removes the need for fragile `@source "../../../../../..."` workarounds. Addresses [#187](https://github.com/django-commons/django-tailwind-cli/issues/187).
12+
- **Watch mode auto-reload**: `python manage.py tailwind watch` now runs under Django's own auto-reloader (the same machinery `runserver` uses). Changing `settings.py` or any Python file restarts the watch process, regenerates the source CSS (picking up new `INSTALLED_APPS`), and restarts the Tailwind CLI subprocess. Pass `--noreload` to disable.
1113

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

docs/settings.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,38 @@ 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_AUTO_SOURCE_EXTERNAL_APPS
46+
47+
**Default**: `False` (opt-in)
48+
49+
When enabled, the auto-generated default source CSS file gains one `@source` directive per installed Django app whose path lives **outside** `BASE_DIR` **and** outside every known site-packages directory. This covers exactly one real-world case: editable-installed packages that ship with their own templates, e.g. `pip install -e ../my-ui-library`.
50+
51+
Why it matters: Tailwind CSS 4.x discovers source files by walking the current working directory tree. Apps installed as editable packages from a sibling repository sit outside that tree and are therefore invisible to Tailwind unless declared explicitly. Turning this setting on makes `django-tailwind-cli` emit the declarations for you, using absolute paths that Tailwind can follow.
52+
53+
```python
54+
# settings.py
55+
TAILWIND_CLI_AUTO_SOURCE_EXTERNAL_APPS = True
56+
```
57+
58+
With the setting enabled and an editable package `extra` installed, the auto-generated `source.css` looks like:
59+
60+
```css
61+
@import "tailwindcss";
62+
63+
/* Auto-generated: installed apps outside BASE_DIR and site-packages. */
64+
@source "/absolute/path/to/editable/extra";
65+
```
66+
67+
:::{note}
68+
The directive points at the app base dir, not at a glob. Tailwind CSS 4.x walks the directory and applies its own exclusions (`.gitignore`, binaries, etc.) — this also means class names embedded in Python files (e.g. form widget `attrs={"class": "..."}` strings) are picked up automatically.
69+
:::
70+
71+
:::{warning}
72+
This setting only affects the **auto-generated** default source CSS. If you set `TAILWIND_CLI_SRC_CSS` to point at a hand-written CSS file, that file is left untouched — add the `@source` directives yourself if you need them.
73+
:::
74+
75+
The list of external apps is re-evaluated whenever the source CSS is written. When combined with the `tailwind watch` auto-reloader, installing a new app or editing `INSTALLED_APPS` automatically regenerates the declarations and triggers a Tailwind rebuild.
76+
4577
### TAILWIND_CLI_USE_SYSTEM_BINARY
4678

4779
**Default**: `False`

docs/usage.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ Run `python manage.py tailwind build` to create an optimized production built of
2222

2323
Run `python manage.py tailwind watch` to just start a tailwind watcher process if you prefer to start your debug server in a seperate shell or prefer a different solution than runserver or runserver_plus.
2424

25+
By default the watch command runs under Django's own auto-reloader (the same one `runserver` uses). Whenever you change a Python file — including `settings.py` — the watcher restarts its Python process, regenerates the default source CSS file (picking up freshly added `INSTALLED_APPS`), and restarts the Tailwind CLI subprocess. This pairs nicely with [`TAILWIND_CLI_AUTO_SOURCE_EXTERNAL_APPS`](settings.md#tailwind_cli_auto_source_external_apps): adding an editable-installed app and updating `INSTALLED_APPS` is enough — no manual restart needed.
26+
27+
Pass `--noreload` if you want a single-process watch loop (e.g. in CI or when debugging the watcher itself):
28+
29+
```bash
30+
python manage.py tailwind watch --noreload
31+
```
32+
2533
### runserver
2634

2735
Run `python manage.py tailwind runserver` to start the classic Django debug server in parallel to a tailwind watcher process.

src/django_tailwind_cli/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@
5555
Default: True
5656
Example: TAILWIND_CLI_AUTOMATIC_DOWNLOAD = False
5757
58+
TAILWIND_CLI_AUTO_SOURCE_EXTERNAL_APPS (optional): Auto @source
59+
Default: False (opt-in)
60+
Example: TAILWIND_CLI_AUTO_SOURCE_EXTERNAL_APPS = True
61+
Note: When enabled AND the default source.css is auto-generated,
62+
scan INSTALLED_APPS for Django apps that live outside both
63+
BASE_DIR and site-packages (typically editable-installed
64+
user packages), and emit an @source directive for each.
65+
This makes Tailwind CSS scan templates of those apps even
66+
though they sit outside its default CWD walk. Opt-in
67+
because it inserts extra directives into the generated
68+
source.css and expands Tailwind's scan scope — users who
69+
don't need it should see no behavior change.
70+
5871
TAILWIND_CLI_USE_SYSTEM_BINARY (optional): Use a CLI on PATH
5972
Default: False
6073
Example: TAILWIND_CLI_USE_SYSTEM_BINARY = True
@@ -138,6 +151,7 @@ class Config:
138151
automatic_download: bool = True
139152
use_daisy_ui: bool = False
140153
uses_system_binary: bool = False
154+
auto_source_external_apps: bool = False
141155

142156
# Backward compatibility properties
143157
@property
@@ -697,6 +711,7 @@ def get_config() -> Config:
697711
use_daisy_ui = getattr(settings, "TAILWIND_CLI_USE_DAISY_UI", False)
698712
automatic_download = getattr(settings, "TAILWIND_CLI_AUTOMATIC_DOWNLOAD", True)
699713
uses_system_binary = bool(getattr(settings, "TAILWIND_CLI_USE_SYSTEM_BINARY", False))
714+
auto_source_external_apps = bool(getattr(settings, "TAILWIND_CLI_AUTO_SOURCE_EXTERNAL_APPS", False))
700715

701716
# Get platform information
702717
platform_info = get_platform_info()
@@ -735,6 +750,7 @@ def get_config() -> Config:
735750
automatic_download=automatic_download,
736751
use_daisy_ui=use_daisy_ui,
737752
uses_system_binary=uses_system_binary,
753+
auto_source_external_apps=auto_source_external_apps,
738754
)
739755

740756

src/django_tailwind_cli/management/commands/tailwind.py

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,11 @@ def watch(
291291
"-v",
292292
help="Show detailed watch information and diagnostics.",
293293
),
294+
no_reloader: bool = typer.Option(
295+
False,
296+
"--noreload",
297+
help="Disable auto-reload on Python file changes.",
298+
),
294299
):
295300
"""Start Tailwind CSS in watch mode for development.
296301
@@ -307,14 +312,25 @@ def watch(
307312
- Build progress and timing
308313
- Any build errors or warnings
309314
315+
\b
316+
By default the Python process that runs the watch mode is itself
317+
auto-reloaded on any .py file change (using Django's own autoreload
318+
machinery — the same one runserver uses). This means that installing
319+
a new Django app or editing settings.py rebuilds the source.css and
320+
restarts the Tailwind CLI subprocess automatically. Pass --noreload
321+
to disable this and run the watch loop in a single process.
322+
310323
\b
311324
Examples:
312-
# Start watch mode
325+
# Start watch mode with auto-reload
313326
python manage.py tailwind watch
314327
315328
# Watch with detailed diagnostics
316329
python manage.py tailwind watch --verbose
317330
331+
# Single-process watch without auto-reload
332+
python manage.py tailwind watch --noreload
333+
318334
\b
319335
Tips:
320336
- Keep this running in a separate terminal during development
@@ -323,6 +339,25 @@ def watch(
323339
324340
Press Ctrl+C to stop watching.
325341
"""
342+
if no_reloader:
343+
_run_watch_loop(verbose=verbose)
344+
return
345+
346+
from django.utils import autoreload
347+
348+
autoreload.run_with_reloader(_run_watch_loop, verbose=verbose) # pyright: ignore[reportUnknownMemberType]
349+
350+
351+
def _run_watch_loop(*, verbose: bool = False) -> None:
352+
"""Run the Tailwind CSS watch loop in the current process.
353+
354+
This is invoked directly by ``tailwind watch --noreload`` and as the
355+
inner callable when Django's autoreload machinery spawns a child
356+
process for the default (auto-reload) path. On reload the entire
357+
child process is torn down and respawned, so this function starts
358+
from a clean slate every time — including a fresh get_config() call
359+
that picks up any INSTALLED_APPS or settings changes.
360+
"""
326361
config = get_config()
327362

328363
if verbose:
@@ -1538,6 +1573,88 @@ def _download_cli_with_verbose(*, verbose: bool = False, force_download: bool =
15381573
DAISY_UI_SOURCE_CSS = '@import "tailwindcss";\n@plugin "daisyui";\n'
15391574

15401575

1576+
def _get_site_packages_paths() -> list[Path]:
1577+
"""Return all known site-packages paths used to filter out regular installs.
1578+
1579+
We combine ``site.getsitepackages()``, ``site.getusersitepackages()`` and
1580+
``sysconfig.get_paths()`` to catch every standard location — editable
1581+
installs of the user's own source packages live outside all of these.
1582+
"""
1583+
import site
1584+
import sysconfig
1585+
1586+
paths: set[Path] = set()
1587+
for p in site.getsitepackages():
1588+
paths.add(Path(p).resolve())
1589+
try:
1590+
user_site = site.getusersitepackages()
1591+
if user_site:
1592+
paths.add(Path(user_site).resolve())
1593+
except AttributeError: # pragma: no cover - defensive
1594+
pass
1595+
for key in ("purelib", "platlib"):
1596+
p = sysconfig.get_paths().get(key)
1597+
if p:
1598+
paths.add(Path(p).resolve())
1599+
return sorted(paths)
1600+
1601+
1602+
def _is_under(child: Path, parent: Path) -> bool:
1603+
"""Return True if ``child`` lies under ``parent`` in the filesystem tree."""
1604+
try:
1605+
child.relative_to(parent)
1606+
except ValueError:
1607+
return False
1608+
return True
1609+
1610+
1611+
def _discover_external_app_base_dirs() -> list[Path]:
1612+
"""Return base dirs of installed Django apps that need explicit @source.
1613+
1614+
An app is considered "external" if its path is NOT under ``BASE_DIR``
1615+
(Tailwind's CWD walk would not reach it) AND NOT under any known
1616+
site-packages directory (regular pip installs are not user-editable
1617+
source). This targets the editable-install case from issue #187.
1618+
"""
1619+
from django.apps import apps
1620+
1621+
base_dir = Path(settings.BASE_DIR).resolve()
1622+
site_packages = _get_site_packages_paths()
1623+
external: list[Path] = []
1624+
1625+
for app_config in apps.get_app_configs():
1626+
app_path = Path(app_config.path).resolve()
1627+
if _is_under(app_path, base_dir):
1628+
continue
1629+
if any(_is_under(app_path, sp) for sp in site_packages):
1630+
continue
1631+
external.append(app_path)
1632+
1633+
return sorted(external)
1634+
1635+
1636+
def _build_source_css_content(*, use_daisy_ui: bool, inject_external_apps: bool) -> str:
1637+
"""Build the auto-generated source.css content.
1638+
1639+
Starts from the minimal ``@import "tailwindcss";`` (+ ``@plugin "daisyui";``
1640+
when DaisyUI is enabled) and appends one ``@source`` directive per
1641+
discovered external Django app base dir.
1642+
"""
1643+
lines = ['@import "tailwindcss";']
1644+
if use_daisy_ui:
1645+
lines.append('@plugin "daisyui";')
1646+
1647+
if inject_external_apps:
1648+
external = _discover_external_app_base_dirs()
1649+
if external:
1650+
lines.append("")
1651+
lines.append("/* Auto-generated: installed apps outside BASE_DIR and site-packages. */")
1652+
for app_path in external:
1653+
lines.append(f'@source "{app_path}";')
1654+
1655+
return "\n".join(lines) + "\n"
1656+
1657+
15411658
def _create_standard_config_with_verbose(*, verbose: bool = False) -> None:
15421659
"""Create a standard Tailwind CSS config file with optional verbose logging."""
15431660
c = get_config()
@@ -1553,8 +1670,12 @@ def _create_standard_config_with_verbose(*, verbose: bool = False) -> None:
15531670
typer.secho("⏭️ No source CSS path configured, skipping creation", fg=typer.colors.YELLOW)
15541671
return
15551672

1556-
# Determine the content based on DaisyUI setting
1557-
content = DAISY_UI_SOURCE_CSS if c.use_daisy_ui else DEFAULT_SOURCE_CSS
1673+
# Build content dynamically — includes auto @source directives for
1674+
# external apps when TAILWIND_CLI_AUTO_SOURCE_EXTERNAL_APPS is enabled.
1675+
content = _build_source_css_content(
1676+
use_daisy_ui=c.use_daisy_ui,
1677+
inject_external_apps=c.auto_source_external_apps,
1678+
)
15581679

15591680
if verbose:
15601681
typer.secho(f"📝 Content template: {'DaisyUI' if c.use_daisy_ui else 'Default'}", fg=typer.colors.BLUE)

tests/settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,10 @@
4949
USE_TZ = True
5050

5151
SILENCED_SYSTEM_CHECKS = ["staticfiles.W004"]
52+
53+
# Explicit test default (also matches the library default): disable auto
54+
# @source injection so the generated source.css content is deterministic
55+
# regardless of how django_tailwind_cli itself happens to be installed in
56+
# the dev env (editable src/ vs. wheel). Feature tests opt back in
57+
# explicitly where they exercise the external-app logic.
58+
TAILWIND_CLI_AUTO_SOURCE_EXTERNAL_APPS = False

tests/test_additional_commands.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from pathlib import Path
99
from collections.abc import Callable
10+
from typing import Any
1011

1112
import pytest
1213
from django.conf import LazySettings
@@ -18,6 +19,11 @@
1819
from django_tailwind_cli.management.commands.tailwind import handle_command_errors
1920

2021

22+
def _call_directly(func: Any, *args: Any, **kwargs: Any) -> Any:
23+
"""Helper that bypasses django.utils.autoreload.run_with_reloader in tests."""
24+
return func(*args, **kwargs)
25+
26+
2127
@pytest.fixture(autouse=True)
2228
def configure_test_settings(settings: LazySettings, tmp_path: Path, mocker: MockerFixture):
2329
"""Configure settings for all tests in this module."""
@@ -191,6 +197,14 @@ def test_optimize_command_basic_output(self, capsys: CaptureFixture[str]):
191197
class TestErrorHandling:
192198
"""Test error handling decorator and error scenarios."""
193199

200+
@pytest.fixture(autouse=True)
201+
def _bypass_autoreload(self, mocker: MockerFixture):
202+
"""Bypass django autoreload so watch tests run in-process."""
203+
mocker.patch(
204+
"django.utils.autoreload.run_with_reloader",
205+
side_effect=_call_directly,
206+
)
207+
194208
def test_handle_command_errors_decorator_command_error(self, mocker: MockerFixture):
195209
"""Test error decorator handles CommandError properly."""
196210
mock_exit = mocker.patch("sys.exit")

tests/test_error_scenarios.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
import time
1313
from pathlib import Path
1414
from collections.abc import Callable
15+
from typing import Any
1516
from unittest.mock import Mock, patch
1617

1718
import pytest
1819
from django.conf import LazySettings
1920
from django_tailwind_cli.utils import http
2021
from django.core.management import CommandError, call_command
2122
from pytest import CaptureFixture
23+
from pytest_mock import MockerFixture
2224
from semver import Version
2325

2426
from django_tailwind_cli.config import (
@@ -32,6 +34,11 @@
3234
from django_tailwind_cli.management.commands.tailwind import ProcessManager
3335

3436

37+
def _call_directly(func: Any, *args: Any, **kwargs: Any) -> Any:
38+
"""Helper that bypasses django.utils.autoreload.run_with_reloader in tests."""
39+
return func(*args, **kwargs)
40+
41+
3542
class TestConfigurationErrorScenarios:
3643
"""Test configuration validation and error handling."""
3744

@@ -226,6 +233,14 @@ def test_version_cache_corruption_handling(self, settings: LazySettings, tmp_pat
226233
class TestSubprocessErrorScenarios:
227234
"""Test subprocess execution error handling."""
228235

236+
@pytest.fixture(autouse=True)
237+
def _bypass_autoreload(self, mocker: MockerFixture):
238+
"""Bypass django autoreload so watch tests run in-process."""
239+
mocker.patch(
240+
"django.utils.autoreload.run_with_reloader",
241+
side_effect=_call_directly,
242+
)
243+
229244
def test_build_command_execution_failure(self, settings: LazySettings, tmp_path: Path, capsys: CaptureFixture[str]):
230245
"""Test handling of CLI execution failure during build."""
231246
settings.BASE_DIR = tmp_path

0 commit comments

Comments
 (0)