django-tailwind-cli 4.6.0__tar.gz → 4.6.2__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.
Files changed (55) hide show
  1. django_tailwind_cli-4.6.2/.github/dependabot.yml +19 -0
  2. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/.github/workflows/release.yml +34 -20
  3. django_tailwind_cli-4.6.2/.github/workflows/test.yml +53 -0
  4. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/CHANGELOG.md +20 -0
  5. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/PKG-INFO +2 -1
  6. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/requirements.txt +2 -2
  7. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/justfile +1 -1
  8. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/pyproject.toml +1 -0
  9. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/management/commands/tailwind.py +111 -28
  10. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_error_scenarios.py +6 -9
  11. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_integration.py +234 -18
  12. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tox.ini +2 -1
  13. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/uv.lock +158 -158
  14. django_tailwind_cli-4.6.0/.github/dependabot.yml +0 -6
  15. django_tailwind_cli-4.6.0/.github/workflows/test.yml +0 -29
  16. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/.gitignore +0 -0
  17. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/.pre-commit-config.yaml +0 -0
  18. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/.readthedocs.yml +0 -0
  19. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/CODE_OF_CONDUCT.md +0 -0
  20. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/LICENSE +0 -0
  21. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/README.md +0 -0
  22. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/base_template.md +0 -0
  23. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/changelog.md +0 -0
  24. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/conf.py +0 -0
  25. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/development.md +0 -0
  26. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/index.md +0 -0
  27. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/installation.md +0 -0
  28. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/settings.md +0 -0
  29. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/template_tags.md +0 -0
  30. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/usage.md +0 -0
  31. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/__init__.py +0 -0
  32. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/apps.py +0 -0
  33. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/config.py +0 -0
  34. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/management/__init__.py +0 -0
  35. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/management/commands/__init__.py +0 -0
  36. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/py.typed +0 -0
  37. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/templates/tailwind_cli/base.html +0 -0
  38. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/templates/tailwind_cli/tailwind_css.html +0 -0
  39. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/templatetags/__init__.py +0 -0
  40. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/templatetags/tailwind_cli.py +0 -0
  41. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/utils/__init__.py +0 -0
  42. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/utils/http.py +0 -0
  43. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/.gitignore +0 -0
  44. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/__init__.py +0 -0
  45. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/assets/css/.gitkeep +0 -0
  46. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/assets/css/tailwind.css +0 -0
  47. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/conftest.py +0 -0
  48. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/settings.py +0 -0
  49. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/templates/tests/base.html +0 -0
  50. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/templates/tests/dummy.email +0 -0
  51. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_additional_commands.py +0 -0
  52. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_config.py +0 -0
  53. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_http.py +0 -0
  54. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_management_commands.py +0 -0
  55. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_tailwind_css_tag.py +0 -0
@@ -0,0 +1,19 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "github-actions"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ cooldown:
8
+ default-days: 7
9
+
10
+ - package-ecosystem: "pip"
11
+ directory: "/"
12
+ schedule:
13
+ interval: "weekly"
14
+ cooldown:
15
+ default-days: 7
16
+ groups:
17
+ python-deps:
18
+ patterns:
19
+ - "*"
@@ -2,6 +2,13 @@ name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI
2
2
 
3
3
  on: push
4
4
 
5
+ concurrency:
6
+ group: ${{ github.workflow }}-${{ github.ref }}
7
+ cancel-in-progress: true
8
+
9
+ permissions:
10
+ contents: read
11
+
5
12
  env:
6
13
  # Change these for your project's URLs
7
14
  PYPI_URL: https://pypi.org/p/django-tailwind-cli
@@ -14,9 +21,11 @@ jobs:
14
21
  runs-on: ubuntu-latest
15
22
 
16
23
  steps:
17
- - uses: actions/checkout@v6
24
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
25
+ with:
26
+ persist-credentials: false
18
27
  - name: Set up Python
19
- uses: actions/setup-python@v6
28
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
20
29
  with:
21
30
  python-version: "3.x"
22
31
  - name: Install pypa/build
@@ -25,20 +34,21 @@ jobs:
25
34
  - name: Build a binary wheel and a source tarball
26
35
  run: python3 -m build
27
36
  - name: Store the distribution packages
28
- uses: actions/upload-artifact@v7
37
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
29
38
  with:
30
39
  name: python-package-distributions
31
40
  path: dist/
32
41
  - name: Extract changelog for tagged release
33
42
  if: startsWith(github.ref, 'refs/tags/')
43
+ env:
44
+ REF_NAME: ${{ github.ref_name }}
34
45
  run: |
35
- VERSION="${{ github.ref_name }}"
36
- VERSION="${VERSION#v}"
37
- awk "
38
- /^## ${VERSION}/ { found=1; next }
46
+ VERSION="${REF_NAME#v}"
47
+ awk -v ver="$VERSION" '
48
+ $0 ~ "^## " ver { found=1; next }
39
49
  found && /^## / { exit }
40
50
  found { print }
41
- " CHANGELOG.md > release-notes.md
51
+ ' CHANGELOG.md > release-notes.md
42
52
  if [[ ! -s release-notes.md ]]; then
43
53
  echo "::error::No changelog section found for version ${VERSION}."
44
54
  echo "::error::Did you forget to rename '## Unreleased' in CHANGELOG.md before tagging?"
@@ -48,7 +58,7 @@ jobs:
48
58
  cat release-notes.md
49
59
  - name: Upload release notes
50
60
  if: startsWith(github.ref, 'refs/tags/')
51
- uses: actions/upload-artifact@v7
61
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
52
62
  with:
53
63
  name: release-notes
54
64
  path: release-notes.md
@@ -67,12 +77,12 @@ jobs:
67
77
  id-token: write # IMPORTANT: mandatory for trusted publishing
68
78
  steps:
69
79
  - name: Download all the dists
70
- uses: actions/download-artifact@v8
80
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
71
81
  with:
72
82
  name: python-package-distributions
73
83
  path: dist/
74
84
  - name: Publish distribution 📦 to PyPI
75
- uses: pypa/gh-action-pypi-publish@release/v1.13
85
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1.14
76
86
 
77
87
  github-release:
78
88
  name: >-
@@ -88,16 +98,16 @@ jobs:
88
98
 
89
99
  steps:
90
100
  - name: Download all the dists
91
- uses: actions/download-artifact@v8
101
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
92
102
  with:
93
103
  name: python-package-distributions
94
104
  path: dist/
95
105
  - name: Download release notes
96
- uses: actions/download-artifact@v8
106
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
97
107
  with:
98
108
  name: release-notes
99
109
  - name: Sign the dists with Sigstore
100
- uses: sigstore/gh-action-sigstore-python@v3.3.0
110
+ uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
101
111
  with:
102
112
  inputs: >-
103
113
  ./dist/*.tar.gz
@@ -105,21 +115,25 @@ jobs:
105
115
  - name: Create GitHub Release
106
116
  env:
107
117
  GITHUB_TOKEN: ${{ github.token }}
118
+ REF_NAME: ${{ github.ref_name }}
119
+ REPO: ${{ github.repository }}
108
120
  run: >-
109
121
  gh release create
110
- '${{ github.ref_name }}'
111
- --repo '${{ github.repository }}'
122
+ "$REF_NAME"
123
+ --repo "$REPO"
112
124
  --notes-file release-notes.md
113
125
  - name: Upload artifact signatures to GitHub Release
114
126
  env:
115
127
  GITHUB_TOKEN: ${{ github.token }}
128
+ REF_NAME: ${{ github.ref_name }}
129
+ REPO: ${{ github.repository }}
116
130
  # Upload to GitHub Release using the `gh` CLI.
117
131
  # `dist/` contains the built packages, and the
118
132
  # sigstore-produced signatures and certificates.
119
133
  run: >-
120
134
  gh release upload
121
- '${{ github.ref_name }}' dist/**
122
- --repo '${{ github.repository }}'
135
+ "$REF_NAME" dist/**
136
+ --repo "$REPO"
123
137
 
124
138
  publish-to-testpypi:
125
139
  name: Publish Python 🐍 distribution 📦 to TestPyPI
@@ -137,12 +151,12 @@ jobs:
137
151
 
138
152
  steps:
139
153
  - name: Download all the dists
140
- uses: actions/download-artifact@v8
154
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
141
155
  with:
142
156
  name: python-package-distributions
143
157
  path: dist/
144
158
  - name: Publish distribution 📦 to TestPyPI
145
- uses: pypa/gh-action-pypi-publish@release/v1.13
159
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1.14
146
160
  with:
147
161
  repository-url: https://test.pypi.org/legacy/
148
162
  skip-existing: true
@@ -0,0 +1,53 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ concurrency:
10
+ group: ${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ build:
18
+ name: Test (Python ${{ matrix.python-version }})
19
+ runs-on: ubuntu-latest
20
+ strategy:
21
+ matrix:
22
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
23
+
24
+ steps:
25
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
26
+ with:
27
+ persist-credentials: false
28
+
29
+ - name: Set up Python ${{ matrix.python-version }}
30
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
31
+ with:
32
+ python-version: ${{ matrix.python-version }}
33
+ allow-prereleases: true
34
+
35
+ - name: Install uv
36
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
37
+
38
+ - name: Test with tox
39
+ run: uvx --with tox-uv --with tox-gh-actions tox
40
+
41
+ zizmor:
42
+ name: Audit workflows (zizmor)
43
+ runs-on: ubuntu-latest
44
+ permissions:
45
+ contents: read
46
+ actions: read # zizmor-action reads workflow definitions
47
+ security-events: write # upload SARIF results to code scanning
48
+ steps:
49
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
50
+ with:
51
+ persist-credentials: false
52
+
53
+ - uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 4.6.2 (2026-05-14)
6
+
7
+ ### 🛠️ Developer Experience
8
+ - **Python 3.15 support**: Added `py315` to the tox matrix (Django 6.0) and to the PyPI classifiers.
9
+
10
+ ## 4.6.1 (2026-05-10)
11
+
12
+ ### 🐛 Bug Fixes
13
+ - **`tailwind watch` crash with `TAILWIND_CLI_CSS_MAP`**: `MultiWatchProcessManager` installed `signal.signal` handlers that fail with `ValueError: signal only works in main thread of the main interpreter` under Django's autoreloader (the watch loop runs in a worker thread). Cleanup now relies on `KeyboardInterrupt` propagation, matching the single-entry path. Fixes [#201](https://github.com/django-commons/django-tailwind-cli/issues/201).
14
+ - **SIGTERM-graceful shutdown for both watch managers**: `ProcessManager` and `MultiWatchProcessManager` now install a SIGTERM handler only when running on the main thread, so `kill -TERM` cleans up child watchers in `tailwind watch --noreload` and `tailwind runserver` without re-introducing the autoreloader crash. SIGINT keeps using Python's default handler (which raises `KeyboardInterrupt`).
15
+ - **Sidestepped a Bun DLOPEN race in multi-entry watch**: `MultiWatchProcessManager` now staggers successive watcher subprocess spawns by 300 ms. The Bun-built tailwindcss standalone binary extracts its embedded `@parcel/watcher` native module to `/$bunfs/` on first use; two parallel processes raced on that path and the loser crashed with `ERR_DLOPEN_FAILED`. The 300 ms gap is below noticeable in interactive use.
16
+
17
+ ### 🛠️ Developer Experience
18
+ - **Filtered Bun native-runtime noise from watcher stderr**: `MultiWatchProcessManager` captures each `tailwindcss` subprocess' stderr and drops upstream Bun stack traces (`EIO: i/o error` on shutdown, `ERR_DLOPEN_FAILED` on the rare DLOPEN race) while forwarding Tailwind's own diagnostics verbatim. Cleanup output stays clean even when the user `kill -TERM`s the runserver.
19
+
20
+ ### 🔧 Technical Improvements
21
+ - **Hardened GitHub Actions workflows**: pinned all actions to commit SHAs, scoped top-level permissions, added concurrency groups, moved `github.ref_name` / `github.repository` out of shell interpolation into `env:` vars, and added a [zizmor](https://docs.zizmor.sh/) audit job to keep workflow security regressions out of CI.
22
+
3
23
  ## 4.6.0 (2026-04-11)
4
24
 
5
25
  ### 💥 Breaking Changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-tailwind-cli
3
- Version: 4.6.0
3
+ Version: 4.6.2
4
4
  Summary: Django and Tailwind integration based on the prebuilt Tailwind CSS CLI.
5
5
  Project-URL: Home, https://django-tailwind-cli.rtfd.io/
6
6
  Project-URL: Documentation, https://django-tailwind-cli.rtfd.io/
@@ -39,6 +39,7 @@ Classifier: Programming Language :: Python :: 3.11
39
39
  Classifier: Programming Language :: Python :: 3.12
40
40
  Classifier: Programming Language :: Python :: 3.13
41
41
  Classifier: Programming Language :: Python :: 3.14
42
+ Classifier: Programming Language :: Python :: 3.15
42
43
  Classifier: Topic :: Software Development :: Libraries
43
44
  Classifier: Topic :: Utilities
44
45
  Requires-Python: >=3.10
@@ -1,5 +1,5 @@
1
- sphinx>=8.0
2
- myst-parser>=4.0
1
+ sphinx>=8.1.3
2
+ myst-parser>=4.0.1
3
3
  sphinx-copybutton
4
4
  sphinx-autobuild
5
5
  furo
@@ -35,7 +35,7 @@ VENV_DIRNAME := ".venv"
35
35
  @test: check_uv
36
36
  uv run pytest --cov --cov-report=html --cov-report=term
37
37
 
38
- # run test suite
38
+ # run test suite on all supported python versions
39
39
  @test-all: check_uv
40
40
  uvx --with tox-uv tox
41
41
 
@@ -14,6 +14,7 @@ classifiers = [
14
14
  "Programming Language :: Python :: 3.12",
15
15
  "Programming Language :: Python :: 3.13",
16
16
  "Programming Language :: Python :: 3.14",
17
+ "Programming Language :: Python :: 3.15",
17
18
  "Operating System :: OS Independent",
18
19
  "Topic :: Software Development :: Libraries",
19
20
  "Topic :: Utilities",
@@ -3,13 +3,15 @@
3
3
  import importlib.util
4
4
  import functools
5
5
  import os
6
+ import re
6
7
  import signal
7
8
  import subprocess
8
9
  import sys
10
+ import threading
9
11
  import time
10
12
  from pathlib import Path
11
13
  from types import FrameType
12
- from typing import Any
14
+ from typing import IO, Any
13
15
  from collections.abc import Callable
14
16
 
15
17
  from django_tailwind_cli.utils import http
@@ -43,6 +45,51 @@ For more information about a specific command, use:
43
45
  rich_markup_mode="markdown",
44
46
  ) # type: ignore
45
47
 
48
+ # Delay between successive multi-watch Popen calls. The Bun-built tailwindcss
49
+ # standalone binary extracts its embedded @parcel/watcher native module to
50
+ # /$bunfs/ on first use; two parallel spawns race on the same path and one
51
+ # crashes with ERR_DLOPEN_FAILED. Staggering by 300 ms sidesteps the race
52
+ # without being noticeable in interactive use.
53
+ _WATCH_SPAWN_STAGGER_S = 0.3
54
+
55
+ # Bun-built tailwindcss occasionally leaks unhandled native-module errors
56
+ # (DLOPEN race on startup, EIO on shutdown when its watch FD is closed under
57
+ # it). The traces are upstream noise — neither actionable nor caused by
58
+ # user code. We drop matching lines from forwarded stderr while keeping
59
+ # Tailwind's own diagnostics intact.
60
+ _BUN_NOISE = re.compile(
61
+ r"""
62
+ ^EIO:\ i/o\ error | # EIO header
63
+ ^Bun\ v\d | # crash footer
64
+ ^error:\ dlopen\( | # DLOPEN header
65
+ ^\d+\ ?\| | # numbered source-context line
66
+ ^\s+(?:fd|syscall|errno|code): | # error-detail field
67
+ ^\s+at\ <anonymous>\ \(/\$bunfs/ | # bunfs stack frame
68
+ ^\s+\^\s*$ | # caret pointer
69
+ ^\s+code:\s*"(?:EIO|ERR_DLOPEN_FAILED)" # error-code value
70
+ """,
71
+ re.VERBOSE,
72
+ )
73
+
74
+
75
+ def _is_bun_noise(line: str) -> bool:
76
+ """Return True if the line looks like a Bun native-runtime crash trace."""
77
+ return bool(_BUN_NOISE.match(line))
78
+
79
+
80
+ def _drain_filtered_stderr(stream: IO[str], is_shutting_down: Callable[[], bool]) -> None:
81
+ """Forward subprocess stderr to the parent's stderr, dropping Bun noise.
82
+
83
+ Runs in a daemon thread until the subprocess closes the pipe. Drops every
84
+ line once `is_shutting_down()` returns True — post-shutdown stderr is not
85
+ actionable and just churns output during cleanup.
86
+ """
87
+ for line in stream:
88
+ if is_shutting_down() or _is_bun_noise(line):
89
+ continue
90
+ sys.stderr.write(line)
91
+ sys.stderr.flush()
92
+
46
93
 
47
94
  # DECORATORS AND COMMON SETUP ---------------------------------------------------------------------
48
95
 
@@ -1027,6 +1074,8 @@ def runserver(
1027
1074
  class ProcessManager:
1028
1075
  """Manages concurrent processes for Tailwind watch and Django runserver."""
1029
1076
 
1077
+ _SHUTDOWN_MESSAGE = "\nShutdown signal received, stopping processes..."
1078
+
1030
1079
  def __init__(self) -> None:
1031
1080
  self.processes: list[subprocess.Popen[str]] = []
1032
1081
  self.shutdown_requested = False
@@ -1038,9 +1087,11 @@ class ProcessManager:
1038
1087
  watch_cmd: Command to start Tailwind watch process.
1039
1088
  server_cmd: Command to start Django development server.
1040
1089
  """
1041
- # Set up signal handlers for graceful shutdown
1042
- signal.signal(signal.SIGINT, self._signal_handler)
1043
- signal.signal(signal.SIGTERM, self._signal_handler)
1090
+ # SIGINT propagates as KeyboardInterrupt via Python's default handler.
1091
+ # Override SIGTERM only on the main thread — signal.signal() raises
1092
+ # ValueError in worker threads (e.g. under Django's autoreloader).
1093
+ if threading.current_thread() is threading.main_thread():
1094
+ signal.signal(signal.SIGTERM, self._signal_handler)
1044
1095
 
1045
1096
  try:
1046
1097
  # Start Tailwind watch process — inherit stdout/stderr so the
@@ -1067,22 +1118,31 @@ class ProcessManager:
1067
1118
  self.processes.append(server_process)
1068
1119
  typer.secho("Started Django development server", fg=typer.colors.GREEN)
1069
1120
 
1070
- # Monitor processes
1071
1121
  self._monitor_processes()
1072
-
1122
+ except KeyboardInterrupt:
1123
+ self._request_shutdown()
1073
1124
  except Exception as e:
1074
1125
  typer.secho(f"Error starting processes: {e}", fg=typer.colors.RED)
1075
- self._cleanup_processes()
1076
1126
  raise
1127
+ finally:
1128
+ self._cleanup_processes()
1077
1129
 
1078
1130
  def _signal_handler(self, _signum: int, _frame: FrameType | None) -> None:
1079
- """Handle shutdown signals gracefully."""
1080
- typer.secho("\nShutdown signal received, stopping processes...", fg=typer.colors.YELLOW)
1131
+ """Adapter for signal.signal — delegates to the idempotent shutdown request."""
1132
+ self._request_shutdown()
1133
+
1134
+ def _request_shutdown(self) -> None:
1135
+ """Print the shutdown message once and flip the flag. Idempotent.
1136
+
1137
+ Cleanup is owned by start_concurrent_processes' finally.
1138
+ """
1139
+ if self.shutdown_requested:
1140
+ return
1141
+ typer.secho(self._SHUTDOWN_MESSAGE, fg=typer.colors.YELLOW)
1081
1142
  self.shutdown_requested = True
1082
- self._cleanup_processes()
1083
1143
 
1084
1144
  def _monitor_processes(self) -> None:
1085
- """Monitor running processes and handle their lifecycle."""
1145
+ """Monitor running processes. Cleanup is owned by start_concurrent_processes' finally."""
1086
1146
  while not self.shutdown_requested and any(p.poll() is None for p in self.processes):
1087
1147
  time.sleep(0.5)
1088
1148
 
@@ -1093,9 +1153,6 @@ class ProcessManager:
1093
1153
  self.shutdown_requested = True
1094
1154
  break
1095
1155
 
1096
- # Clean up any remaining processes
1097
- self._cleanup_processes()
1098
-
1099
1156
  def _cleanup_processes(self) -> None:
1100
1157
  """Clean up all managed processes."""
1101
1158
  for process in self.processes:
@@ -1119,6 +1176,8 @@ class ProcessManager:
1119
1176
  class MultiWatchProcessManager:
1120
1177
  """Manages multiple Tailwind watch processes for multi-file mode."""
1121
1178
 
1179
+ _SHUTDOWN_MESSAGE = "\nShutdown signal received, stopping watch processes..."
1180
+
1122
1181
  def __init__(self) -> None:
1123
1182
  self.processes: list[subprocess.Popen[str]] = []
1124
1183
  self.shutdown_requested = False
@@ -1130,42 +1189,68 @@ class MultiWatchProcessManager:
1130
1189
  config: Configuration object with css_entries.
1131
1190
  verbose: Whether to show detailed information.
1132
1191
  """
1133
- # Set up signal handlers for graceful shutdown
1134
- signal.signal(signal.SIGINT, self._signal_handler)
1135
- signal.signal(signal.SIGTERM, self._signal_handler)
1192
+ # SIGINT propagates as KeyboardInterrupt via Python's default handler.
1193
+ # Override SIGTERM only on the main thread — signal.signal() raises
1194
+ # ValueError in worker threads (e.g. under Django's autoreloader).
1195
+ if threading.current_thread() is threading.main_thread():
1196
+ signal.signal(signal.SIGTERM, self._signal_handler)
1136
1197
 
1137
1198
  try:
1138
- for entry in config.css_entries:
1199
+ for index, entry in enumerate(config.css_entries):
1200
+ if index > 0:
1201
+ time.sleep(_WATCH_SPAWN_STAGGER_S)
1202
+
1139
1203
  watch_cmd = config.get_watch_cmd(entry)
1140
1204
  if verbose:
1141
1205
  typer.secho(f"🚀 Starting watch for '{entry.name}'...", fg=typer.colors.CYAN)
1142
1206
  typer.secho(f" • Command: {' '.join(watch_cmd)}", fg=typer.colors.BLUE)
1143
1207
 
1144
- # Inherit stdout/stderr so output flows to the terminal and we
1145
- # avoid a pipe-fill deadlock — the OS pipe buffer would otherwise
1146
- # block the watcher after ~64 KB of rebuild status lines.
1208
+ # Inherit stdout (high-volume rebuild progress) to avoid a pipe-fill
1209
+ # deadlock — the OS pipe buffer would otherwise block the watcher
1210
+ # after ~64 KB. stderr is captured so we can filter Bun's native-
1211
+ # runtime noise (DLOPEN race, EIO on shutdown) before it hits the
1212
+ # terminal; volume there is low enough that the drain thread keeps
1213
+ # up easily.
1147
1214
  process = subprocess.Popen(
1148
1215
  watch_cmd,
1149
1216
  cwd=settings.BASE_DIR,
1150
1217
  text=True,
1218
+ stderr=subprocess.PIPE,
1151
1219
  )
1152
1220
  self.processes.append(process)
1221
+ if process.stderr is not None:
1222
+ threading.Thread(
1223
+ target=_drain_filtered_stderr,
1224
+ args=(process.stderr, lambda: self.shutdown_requested),
1225
+ daemon=True,
1226
+ ).start()
1153
1227
  typer.secho(f"Watching '{entry.name}': {entry.src_css}", fg=typer.colors.GREEN)
1154
1228
 
1155
1229
  self._monitor_processes()
1230
+ except KeyboardInterrupt:
1231
+ self._request_shutdown()
1156
1232
  except Exception as e:
1157
1233
  typer.secho(f"Error starting watch processes: {e}", fg=typer.colors.RED)
1158
- self._cleanup_processes()
1159
1234
  raise
1235
+ finally:
1236
+ self._cleanup_processes()
1160
1237
 
1161
1238
  def _signal_handler(self, _signum: int, _frame: FrameType | None) -> None:
1162
- """Handle shutdown signals gracefully."""
1163
- typer.secho("\nShutdown signal received, stopping watch processes...", fg=typer.colors.YELLOW)
1239
+ """Adapter for signal.signal — delegates to the idempotent shutdown request."""
1240
+ self._request_shutdown()
1241
+
1242
+ def _request_shutdown(self) -> None:
1243
+ """Print the shutdown message once and flip the flag. Idempotent.
1244
+
1245
+ Cleanup is owned by start_watch_processes' finally.
1246
+ """
1247
+ if self.shutdown_requested:
1248
+ return
1249
+ typer.secho(self._SHUTDOWN_MESSAGE, fg=typer.colors.YELLOW)
1164
1250
  self.shutdown_requested = True
1165
- self._cleanup_processes()
1166
1251
 
1167
1252
  def _monitor_processes(self) -> None:
1168
- """Monitor all watch processes."""
1253
+ """Monitor all watch processes. Cleanup is owned by start_watch_processes' finally."""
1169
1254
  while not self.shutdown_requested and any(p.poll() is None for p in self.processes):
1170
1255
  time.sleep(0.5)
1171
1256
 
@@ -1175,8 +1260,6 @@ class MultiWatchProcessManager:
1175
1260
  self.shutdown_requested = True
1176
1261
  break
1177
1262
 
1178
- self._cleanup_processes()
1179
-
1180
1263
  def _cleanup_processes(self) -> None:
1181
1264
  """Clean up all watch processes."""
1182
1265
  for process in self.processes:
@@ -7,7 +7,6 @@ file operations, and subprocess execution.
7
7
  # pyright: reportPrivateUsage=false
8
8
 
9
9
  import os
10
- import signal
11
10
  import subprocess
12
11
  import time
13
12
  from pathlib import Path
@@ -483,8 +482,8 @@ class TestFileSystemErrorScenarios:
483
482
  class TestConcurrencyErrorScenarios:
484
483
  """Test concurrency and race condition handling."""
485
484
 
486
- def test_process_manager_signal_handling_error(self):
487
- """Test ProcessManager error handling during signal processing."""
485
+ def test_process_manager_cleanup_swallows_terminate_error(self):
486
+ """ProcessManager cleanup must not propagate OSError from terminate()."""
488
487
  manager = ProcessManager()
489
488
 
490
489
  # Mock process that raises exception during terminate
@@ -494,14 +493,13 @@ class TestConcurrencyErrorScenarios:
494
493
  mock_process.wait.return_value = None
495
494
  manager.processes = [mock_process]
496
495
 
497
- # Should handle terminate errors gracefully
498
- manager._signal_handler(signal.SIGINT, None)
496
+ manager._cleanup_processes()
499
497
 
500
498
  # Should still clean up processes list
501
499
  assert manager.processes == []
502
500
 
503
- def test_process_manager_wait_timeout_error(self):
504
- """Test ProcessManager handling of wait timeout."""
501
+ def test_process_manager_cleanup_escalates_on_wait_timeout(self):
502
+ """ProcessManager cleanup must kill() when terminate() + wait() time out."""
505
503
  manager = ProcessManager()
506
504
 
507
505
  # Mock process that times out during wait
@@ -512,8 +510,7 @@ class TestConcurrencyErrorScenarios:
512
510
  mock_process.kill.return_value = None
513
511
  manager.processes = [mock_process]
514
512
 
515
- # Should escalate to kill when terminate times out
516
- manager._signal_handler(signal.SIGINT, None)
513
+ manager._cleanup_processes()
517
514
 
518
515
  mock_process.terminate.assert_called_once()
519
516
  mock_process.kill.assert_called_once()