django-tailwind-cli 4.6.0__tar.gz → 4.6.1__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.
- django_tailwind_cli-4.6.1/.github/dependabot.yml +19 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/.github/workflows/release.yml +34 -20
- django_tailwind_cli-4.6.1/.github/workflows/test.yml +53 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/CHANGELOG.md +15 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/PKG-INFO +1 -1
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/requirements.txt +2 -2
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/management/commands/tailwind.py +111 -28
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_error_scenarios.py +6 -9
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_integration.py +234 -18
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/uv.lock +11 -36
- django_tailwind_cli-4.6.0/.github/dependabot.yml +0 -6
- django_tailwind_cli-4.6.0/.github/workflows/test.yml +0 -29
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/.gitignore +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/.pre-commit-config.yaml +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/.readthedocs.yml +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/CODE_OF_CONDUCT.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/LICENSE +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/README.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/base_template.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/changelog.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/conf.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/development.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/index.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/installation.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/settings.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/template_tags.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/usage.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/justfile +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/pyproject.toml +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/__init__.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/apps.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/config.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/management/__init__.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/management/commands/__init__.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/py.typed +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/templates/tailwind_cli/base.html +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/templates/tailwind_cli/tailwind_css.html +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/templatetags/__init__.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/templatetags/tailwind_cli.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/utils/__init__.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/utils/http.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/.gitignore +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/__init__.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/assets/css/.gitkeep +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/assets/css/tailwind.css +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/conftest.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/settings.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/templates/tests/base.html +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/templates/tests/dummy.email +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_additional_commands.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_config.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_http.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_management_commands.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_tailwind_css_tag.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tox.ini +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="${
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
111
|
-
--repo
|
|
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
|
-
|
|
122
|
-
--repo
|
|
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.
|
|
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"]
|
|
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,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 4.6.1 (2026-05-10)
|
|
6
|
+
|
|
7
|
+
### 🐛 Bug Fixes
|
|
8
|
+
- **`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).
|
|
9
|
+
- **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`).
|
|
10
|
+
- **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.
|
|
11
|
+
|
|
12
|
+
### 🛠️ Developer Experience
|
|
13
|
+
- **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.
|
|
14
|
+
|
|
15
|
+
### 🔧 Technical Improvements
|
|
16
|
+
- **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.
|
|
17
|
+
|
|
3
18
|
## 4.6.0 (2026-04-11)
|
|
4
19
|
|
|
5
20
|
### 💥 Breaking Changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-tailwind-cli
|
|
3
|
-
Version: 4.6.
|
|
3
|
+
Version: 4.6.1
|
|
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/
|
|
@@ -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
|
-
#
|
|
1042
|
-
signal.signal(
|
|
1043
|
-
|
|
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
|
-
"""
|
|
1080
|
-
|
|
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
|
|
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
|
-
#
|
|
1134
|
-
signal.signal(
|
|
1135
|
-
|
|
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
|
|
1145
|
-
#
|
|
1146
|
-
#
|
|
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
|
-
"""
|
|
1163
|
-
|
|
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
|
|
487
|
-
"""
|
|
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
|
-
|
|
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
|
|
504
|
-
"""
|
|
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
|
-
|
|
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()
|
|
@@ -5,8 +5,11 @@ file operations, and cross-platform compatibility.
|
|
|
5
5
|
"""
|
|
6
6
|
# pyright: reportPrivateUsage=false
|
|
7
7
|
|
|
8
|
+
import io
|
|
8
9
|
import os
|
|
9
10
|
import platform
|
|
11
|
+
import signal
|
|
12
|
+
import threading
|
|
10
13
|
import time
|
|
11
14
|
from pathlib import Path
|
|
12
15
|
from collections.abc import Callable
|
|
@@ -24,7 +27,12 @@ from django_tailwind_cli.config import get_config
|
|
|
24
27
|
from django_tailwind_cli.management.commands.tailwind import (
|
|
25
28
|
DAISY_UI_SOURCE_CSS,
|
|
26
29
|
DEFAULT_SOURCE_CSS,
|
|
30
|
+
_WATCH_SPAWN_STAGGER_S,
|
|
31
|
+
MultiWatchProcessManager,
|
|
27
32
|
ProcessManager,
|
|
33
|
+
_drain_filtered_stderr,
|
|
34
|
+
_is_bun_noise,
|
|
35
|
+
_run_watch_loop,
|
|
28
36
|
)
|
|
29
37
|
|
|
30
38
|
|
|
@@ -33,6 +41,13 @@ def _call_directly(func: Any, *args: Any, **kwargs: Any) -> Any:
|
|
|
33
41
|
return func(*args, **kwargs)
|
|
34
42
|
|
|
35
43
|
|
|
44
|
+
def _clear_legacy_css_settings(settings: LazySettings) -> None:
|
|
45
|
+
"""Drop single-file CSS settings so CSS_MAP is the sole source of truth."""
|
|
46
|
+
for name in ("TAILWIND_CLI_SRC_CSS", "TAILWIND_CLI_DIST_CSS"):
|
|
47
|
+
if hasattr(settings, name):
|
|
48
|
+
delattr(settings, name)
|
|
49
|
+
|
|
50
|
+
|
|
36
51
|
class TestBuildWorkflowIntegration:
|
|
37
52
|
"""Test complete build workflow from setup to CSS generation."""
|
|
38
53
|
|
|
@@ -197,11 +212,7 @@ class TestBuildWorkflowIntegration:
|
|
|
197
212
|
("admin.css", "admin.output.css"),
|
|
198
213
|
("web.css", "web.output.css"),
|
|
199
214
|
]
|
|
200
|
-
|
|
201
|
-
if hasattr(settings, "TAILWIND_CLI_SRC_CSS"):
|
|
202
|
-
delattr(settings, "TAILWIND_CLI_SRC_CSS")
|
|
203
|
-
if hasattr(settings, "TAILWIND_CLI_DIST_CSS"):
|
|
204
|
-
delattr(settings, "TAILWIND_CLI_DIST_CSS")
|
|
215
|
+
_clear_legacy_css_settings(settings)
|
|
205
216
|
|
|
206
217
|
# Create source CSS files
|
|
207
218
|
(tmp_path / "admin.css").write_text('@import "tailwindcss";')
|
|
@@ -308,11 +319,7 @@ class TestWatchModeIntegration:
|
|
|
308
319
|
("admin.css", "admin.output.css"),
|
|
309
320
|
("web.css", "web.output.css"),
|
|
310
321
|
]
|
|
311
|
-
|
|
312
|
-
if hasattr(settings, "TAILWIND_CLI_SRC_CSS"):
|
|
313
|
-
delattr(settings, "TAILWIND_CLI_SRC_CSS")
|
|
314
|
-
if hasattr(settings, "TAILWIND_CLI_DIST_CSS"):
|
|
315
|
-
delattr(settings, "TAILWIND_CLI_DIST_CSS")
|
|
322
|
+
_clear_legacy_css_settings(settings)
|
|
316
323
|
|
|
317
324
|
# Create source CSS files
|
|
318
325
|
(tmp_path / "admin.css").write_text('@import "tailwindcss";')
|
|
@@ -339,6 +346,7 @@ class TestWatchModeIntegration:
|
|
|
339
346
|
mock_process = Mock()
|
|
340
347
|
mock_process.poll.return_value = 0 # Process already exited
|
|
341
348
|
mock_process.wait.return_value = 0
|
|
349
|
+
mock_process.stderr = None # skip the stderr drain thread
|
|
342
350
|
mock_popen.return_value = mock_process
|
|
343
351
|
|
|
344
352
|
call_command("tailwind", "watch")
|
|
@@ -361,6 +369,117 @@ class TestWatchModeIntegration:
|
|
|
361
369
|
assert "web.output.css" in str(call_args_1)
|
|
362
370
|
assert "--watch" in call_args_1
|
|
363
371
|
|
|
372
|
+
def test_watch_with_css_map_runs_in_worker_thread(self, settings: LazySettings, tmp_path: Path):
|
|
373
|
+
"""Regression for #201: multi-entry watch must work outside the main thread.
|
|
374
|
+
|
|
375
|
+
Django's autoreload.run_with_reloader executes the wrapped callable
|
|
376
|
+
in a worker thread. signal.signal() raises ValueError there, so the
|
|
377
|
+
multi-entry watch path must not rely on signal handlers.
|
|
378
|
+
"""
|
|
379
|
+
settings.BASE_DIR = tmp_path
|
|
380
|
+
settings.TAILWIND_CLI_PATH = tmp_path / ".django_tailwind_cli"
|
|
381
|
+
settings.STATICFILES_DIRS = (tmp_path / "assets",)
|
|
382
|
+
settings.TAILWIND_CLI_VERSION = "4.1.3"
|
|
383
|
+
settings.TAILWIND_CLI_CSS_MAP = [
|
|
384
|
+
("admin.css", "admin.output.css"),
|
|
385
|
+
("web.css", "web.output.css"),
|
|
386
|
+
]
|
|
387
|
+
_clear_legacy_css_settings(settings)
|
|
388
|
+
|
|
389
|
+
(tmp_path / "admin.css").write_text('@import "tailwindcss";')
|
|
390
|
+
(tmp_path / "web.css").write_text('@import "tailwindcss";')
|
|
391
|
+
|
|
392
|
+
with (
|
|
393
|
+
patch("django_tailwind_cli.utils.http.download_with_progress") as mock_download,
|
|
394
|
+
patch("subprocess.Popen") as mock_popen,
|
|
395
|
+
):
|
|
396
|
+
|
|
397
|
+
def mock_download_func(
|
|
398
|
+
url: str,
|
|
399
|
+
filepath: Path,
|
|
400
|
+
timeout: int = 30,
|
|
401
|
+
progress_callback: Callable[[int, int, float], None] | None = None,
|
|
402
|
+
) -> None:
|
|
403
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
404
|
+
filepath.write_bytes(b"fake-cli-binary")
|
|
405
|
+
filepath.chmod(0o755)
|
|
406
|
+
|
|
407
|
+
mock_download.side_effect = mock_download_func
|
|
408
|
+
|
|
409
|
+
mock_process = Mock()
|
|
410
|
+
mock_process.poll.return_value = 0 # already exited → monitor loop returns immediately
|
|
411
|
+
mock_process.wait.return_value = 0
|
|
412
|
+
mock_process.stderr = None # skip the stderr drain thread
|
|
413
|
+
mock_popen.return_value = mock_process
|
|
414
|
+
|
|
415
|
+
errors: list[BaseException] = []
|
|
416
|
+
|
|
417
|
+
def runner() -> None:
|
|
418
|
+
try:
|
|
419
|
+
_run_watch_loop(verbose=False)
|
|
420
|
+
except BaseException as exc:
|
|
421
|
+
errors.append(exc)
|
|
422
|
+
|
|
423
|
+
worker = threading.Thread(target=runner)
|
|
424
|
+
worker.start()
|
|
425
|
+
worker.join(timeout=5)
|
|
426
|
+
|
|
427
|
+
assert not worker.is_alive(), "_run_watch_loop did not terminate within 5s"
|
|
428
|
+
assert not errors, f"_run_watch_loop crashed in worker thread: {errors[0]!r}"
|
|
429
|
+
|
|
430
|
+
@pytest.mark.parametrize(
|
|
431
|
+
"entry_count,expected_staggers",
|
|
432
|
+
[(2, 1), (3, 2), (4, 3)],
|
|
433
|
+
)
|
|
434
|
+
def test_watch_staggers_multi_entry_spawn(
|
|
435
|
+
self,
|
|
436
|
+
settings: LazySettings,
|
|
437
|
+
tmp_path: Path,
|
|
438
|
+
mocker: MockerFixture,
|
|
439
|
+
entry_count: int,
|
|
440
|
+
expected_staggers: int,
|
|
441
|
+
):
|
|
442
|
+
"""Multi-entry Popen calls must be spaced by _WATCH_SPAWN_STAGGER_S to avoid the Bun DLOPEN race."""
|
|
443
|
+
settings.BASE_DIR = tmp_path
|
|
444
|
+
settings.TAILWIND_CLI_PATH = tmp_path / ".django_tailwind_cli"
|
|
445
|
+
settings.STATICFILES_DIRS = (tmp_path / "assets",)
|
|
446
|
+
settings.TAILWIND_CLI_VERSION = "4.1.3"
|
|
447
|
+
settings.TAILWIND_CLI_CSS_MAP = [(f"src{i}.css", f"out{i}.output.css") for i in range(entry_count)]
|
|
448
|
+
_clear_legacy_css_settings(settings)
|
|
449
|
+
|
|
450
|
+
for i in range(entry_count):
|
|
451
|
+
(tmp_path / f"src{i}.css").write_text('@import "tailwindcss";')
|
|
452
|
+
|
|
453
|
+
def mock_download_func(
|
|
454
|
+
url: str,
|
|
455
|
+
filepath: Path,
|
|
456
|
+
timeout: int = 30,
|
|
457
|
+
progress_callback: Callable[[int, int, float], None] | None = None,
|
|
458
|
+
) -> None:
|
|
459
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
460
|
+
filepath.write_bytes(b"fake-cli-binary")
|
|
461
|
+
filepath.chmod(0o755)
|
|
462
|
+
|
|
463
|
+
mocker.patch("django_tailwind_cli.utils.http.download_with_progress", side_effect=mock_download_func)
|
|
464
|
+
|
|
465
|
+
mock_process = Mock()
|
|
466
|
+
mock_process.poll.return_value = 0 # exits immediately so monitor loop returns
|
|
467
|
+
mock_process.wait.return_value = 0
|
|
468
|
+
mock_process.stderr = None # skip the stderr drain thread
|
|
469
|
+
mock_popen = mocker.patch("subprocess.Popen", return_value=mock_process)
|
|
470
|
+
|
|
471
|
+
# Patch only the time reference inside tailwind.py to avoid disturbing
|
|
472
|
+
# the rest of the test runtime.
|
|
473
|
+
mock_sleep = mocker.patch("django_tailwind_cli.management.commands.tailwind.time.sleep")
|
|
474
|
+
|
|
475
|
+
call_command("tailwind", "watch")
|
|
476
|
+
|
|
477
|
+
assert mock_popen.call_count == entry_count
|
|
478
|
+
stagger_calls = [c for c in mock_sleep.call_args_list if c.args == (_WATCH_SPAWN_STAGGER_S,)]
|
|
479
|
+
assert len(stagger_calls) == expected_staggers, (
|
|
480
|
+
f"expected {expected_staggers} stagger sleeps, got {len(stagger_calls)} of {mock_sleep.call_args_list}"
|
|
481
|
+
)
|
|
482
|
+
|
|
364
483
|
def test_watch_keyboard_interrupt_handling(
|
|
365
484
|
self, settings: LazySettings, tmp_path: Path, capsys: CaptureFixture[str]
|
|
366
485
|
):
|
|
@@ -413,10 +532,7 @@ class TestProcessManagerIntegration:
|
|
|
413
532
|
mock_process.wait.return_value = None
|
|
414
533
|
manager.processes = [mock_process]
|
|
415
534
|
|
|
416
|
-
|
|
417
|
-
import signal
|
|
418
|
-
|
|
419
|
-
manager._signal_handler(signal.SIGINT, None)
|
|
535
|
+
manager._cleanup_processes()
|
|
420
536
|
|
|
421
537
|
# Verify cleanup was called
|
|
422
538
|
mock_process.terminate.assert_called_once()
|
|
@@ -431,15 +547,115 @@ class TestProcessManagerIntegration:
|
|
|
431
547
|
mock_process.poll.return_value = 0 # Already exited
|
|
432
548
|
manager.processes = [mock_process]
|
|
433
549
|
|
|
434
|
-
|
|
435
|
-
import signal
|
|
436
|
-
|
|
437
|
-
manager._signal_handler(signal.SIGINT, None)
|
|
550
|
+
manager._cleanup_processes()
|
|
438
551
|
|
|
439
552
|
# Should not call terminate on already-exited process
|
|
440
553
|
mock_process.terminate.assert_not_called()
|
|
441
554
|
assert manager.processes == []
|
|
442
555
|
|
|
556
|
+
@pytest.mark.parametrize("manager_cls", [ProcessManager, MultiWatchProcessManager])
|
|
557
|
+
def test_signal_handler_flips_shutdown_flag_without_cleanup(self, manager_cls: type):
|
|
558
|
+
"""Handler must only flip the flag — cleanup is owned by start_xxx' finally."""
|
|
559
|
+
manager = manager_cls()
|
|
560
|
+
mock_process = Mock()
|
|
561
|
+
mock_process.poll.return_value = None
|
|
562
|
+
manager.processes = [mock_process]
|
|
563
|
+
|
|
564
|
+
manager._signal_handler(signal.SIGTERM, None)
|
|
565
|
+
|
|
566
|
+
assert manager.shutdown_requested is True
|
|
567
|
+
# Cleanup must NOT happen inside the handler — re-entrancy risk.
|
|
568
|
+
mock_process.terminate.assert_not_called()
|
|
569
|
+
assert manager.processes == [mock_process]
|
|
570
|
+
|
|
571
|
+
@pytest.mark.parametrize("manager_cls", [ProcessManager, MultiWatchProcessManager])
|
|
572
|
+
def test_sigterm_in_main_thread_triggers_shutdown(self, manager_cls: type):
|
|
573
|
+
"""SIGTERM received in the main thread must flip the shutdown flag."""
|
|
574
|
+
manager = manager_cls()
|
|
575
|
+
original = signal.getsignal(signal.SIGTERM)
|
|
576
|
+
signal.signal(signal.SIGTERM, manager._signal_handler)
|
|
577
|
+
try:
|
|
578
|
+
signal.raise_signal(signal.SIGTERM)
|
|
579
|
+
assert manager.shutdown_requested is True
|
|
580
|
+
finally:
|
|
581
|
+
signal.signal(signal.SIGTERM, original)
|
|
582
|
+
|
|
583
|
+
@pytest.mark.parametrize("manager_cls", [ProcessManager, MultiWatchProcessManager])
|
|
584
|
+
def test_signal_handler_is_idempotent(self, manager_cls: type, capsys: CaptureFixture[str]):
|
|
585
|
+
"""Repeated SIGTERMs must produce a single shutdown message (e.g. when pkill matches both wrapper and child)."""
|
|
586
|
+
manager = manager_cls()
|
|
587
|
+
manager._signal_handler(signal.SIGTERM, None)
|
|
588
|
+
manager._signal_handler(signal.SIGTERM, None)
|
|
589
|
+
manager._signal_handler(signal.SIGTERM, None)
|
|
590
|
+
|
|
591
|
+
out = capsys.readouterr().out
|
|
592
|
+
assert out.count("Shutdown signal received") == 1
|
|
593
|
+
assert manager.shutdown_requested is True
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
class TestBunStderrFilter:
|
|
597
|
+
"""Pattern-match and drain logic for filtering Bun native-runtime crash traces."""
|
|
598
|
+
|
|
599
|
+
@pytest.mark.parametrize(
|
|
600
|
+
"line",
|
|
601
|
+
[
|
|
602
|
+
"EIO: i/o error, read\n",
|
|
603
|
+
" fd: 7,\n",
|
|
604
|
+
' syscall: "read",\n',
|
|
605
|
+
" errno: -5,\n",
|
|
606
|
+
' code: "EIO"\n',
|
|
607
|
+
"Bun v1.2.8 (macOS arm64)\n",
|
|
608
|
+
"error: dlopen(/$bunfs/root/watcher-tzt47keb.node, 0x0001): tried...\n",
|
|
609
|
+
" at <anonymous> (/$bunfs/root/tailwindcss-macos-arm64:22717:29)\n",
|
|
610
|
+
' code: "ERR_DLOPEN_FAILED"\n',
|
|
611
|
+
"22716 | module.exports = __require(...);\n",
|
|
612
|
+
" ^\n",
|
|
613
|
+
],
|
|
614
|
+
)
|
|
615
|
+
def test_is_bun_noise_drops_known_patterns(self, line: str):
|
|
616
|
+
assert _is_bun_noise(line) is True
|
|
617
|
+
|
|
618
|
+
@pytest.mark.parametrize(
|
|
619
|
+
"line",
|
|
620
|
+
[
|
|
621
|
+
"≈ tailwindcss v4.1.3\n",
|
|
622
|
+
"Done in 32ms\n",
|
|
623
|
+
'[ERROR] Could not resolve "tailwindcss"\n',
|
|
624
|
+
"Watching 'admin': /path/to/admin.css\n",
|
|
625
|
+
"warn - Cannot find module './foo'\n",
|
|
626
|
+
"\n", # blank line — drained but not filtered
|
|
627
|
+
],
|
|
628
|
+
)
|
|
629
|
+
def test_is_bun_noise_keeps_legitimate_lines(self, line: str):
|
|
630
|
+
assert _is_bun_noise(line) is False
|
|
631
|
+
|
|
632
|
+
def test_drain_filtered_stderr_drops_bun_noise(self, capsys: CaptureFixture[str]):
|
|
633
|
+
stream = io.StringIO(
|
|
634
|
+
"Done in 32ms\n"
|
|
635
|
+
"EIO: i/o error, read\n"
|
|
636
|
+
" fd: 7,\n"
|
|
637
|
+
"Bun v1.2.8 (macOS arm64)\n"
|
|
638
|
+
'[ERROR] Could not resolve "tailwindcss"\n'
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
_drain_filtered_stderr(stream, lambda: False)
|
|
642
|
+
|
|
643
|
+
err = capsys.readouterr().err
|
|
644
|
+
assert "Done in 32ms" in err
|
|
645
|
+
assert "[ERROR] Could not resolve" in err
|
|
646
|
+
assert "EIO" not in err
|
|
647
|
+
assert "Bun v" not in err
|
|
648
|
+
assert "fd: 7" not in err
|
|
649
|
+
|
|
650
|
+
def test_drain_filtered_stderr_drops_everything_after_shutdown(self, capsys: CaptureFixture[str]):
|
|
651
|
+
"""Post-shutdown stderr is just churn from terminating subprocesses; drop it."""
|
|
652
|
+
stream = io.StringIO("Done in 32ms\n[ERROR] something legit\n")
|
|
653
|
+
|
|
654
|
+
_drain_filtered_stderr(stream, lambda: True)
|
|
655
|
+
|
|
656
|
+
err = capsys.readouterr().err
|
|
657
|
+
assert err == ""
|
|
658
|
+
|
|
443
659
|
|
|
444
660
|
class TestCLIDownloadIntegration:
|
|
445
661
|
"""Test CLI download and setup workflows."""
|
|
@@ -168,36 +168,16 @@ toml = [
|
|
|
168
168
|
|
|
169
169
|
[[package]]
|
|
170
170
|
name = "django"
|
|
171
|
-
version = "5.2.
|
|
171
|
+
version = "5.2.14"
|
|
172
172
|
source = { registry = "https://pypi.org/simple" }
|
|
173
|
-
resolution-markers = [
|
|
174
|
-
"python_full_version < '3.12'",
|
|
175
|
-
]
|
|
176
|
-
dependencies = [
|
|
177
|
-
{ name = "asgiref", marker = "python_full_version < '3.12'" },
|
|
178
|
-
{ name = "sqlparse", marker = "python_full_version < '3.12'" },
|
|
179
|
-
{ name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" },
|
|
180
|
-
]
|
|
181
|
-
sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" }
|
|
182
|
-
wheels = [
|
|
183
|
-
{ url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" },
|
|
184
|
-
]
|
|
185
|
-
|
|
186
|
-
[[package]]
|
|
187
|
-
name = "django"
|
|
188
|
-
version = "6.0.4"
|
|
189
|
-
source = { registry = "https://pypi.org/simple" }
|
|
190
|
-
resolution-markers = [
|
|
191
|
-
"python_full_version >= '3.12'",
|
|
192
|
-
]
|
|
193
173
|
dependencies = [
|
|
194
|
-
{ name = "asgiref"
|
|
195
|
-
{ name = "sqlparse"
|
|
196
|
-
{ name = "tzdata", marker = "
|
|
174
|
+
{ name = "asgiref" },
|
|
175
|
+
{ name = "sqlparse" },
|
|
176
|
+
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
|
197
177
|
]
|
|
198
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
178
|
+
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
|
|
199
179
|
wheels = [
|
|
200
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
180
|
+
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
|
|
201
181
|
]
|
|
202
182
|
|
|
203
183
|
[[package]]
|
|
@@ -205,8 +185,7 @@ name = "django-extensions"
|
|
|
205
185
|
version = "4.1"
|
|
206
186
|
source = { registry = "https://pypi.org/simple" }
|
|
207
187
|
dependencies = [
|
|
208
|
-
{ name = "django"
|
|
209
|
-
{ name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
|
188
|
+
{ name = "django" },
|
|
210
189
|
]
|
|
211
190
|
sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" }
|
|
212
191
|
wheels = [
|
|
@@ -218,8 +197,7 @@ name = "django-stubs"
|
|
|
218
197
|
version = "6.0.2"
|
|
219
198
|
source = { registry = "https://pypi.org/simple" }
|
|
220
199
|
dependencies = [
|
|
221
|
-
{ name = "django"
|
|
222
|
-
{ name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
|
200
|
+
{ name = "django" },
|
|
223
201
|
{ name = "django-stubs-ext" },
|
|
224
202
|
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
|
225
203
|
{ name = "types-pyyaml" },
|
|
@@ -235,8 +213,7 @@ name = "django-stubs-ext"
|
|
|
235
213
|
version = "6.0.2"
|
|
236
214
|
source = { registry = "https://pypi.org/simple" }
|
|
237
215
|
dependencies = [
|
|
238
|
-
{ name = "django"
|
|
239
|
-
{ name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
|
216
|
+
{ name = "django" },
|
|
240
217
|
{ name = "typing-extensions" },
|
|
241
218
|
]
|
|
242
219
|
sdist = { url = "https://files.pythonhosted.org/packages/52/e0/f2e6caf627d176a51fba1ca9c34082c7ea10d3f521ff2c828532ca99fa91/django_stubs_ext-6.0.2.tar.gz", hash = "sha256:70b7b7ae837e7a6036e2facb64435550bf7cf8143c1a6e802864d4824ce6058c", size = 6751, upload-time = "2026-04-01T08:27:01.987Z" }
|
|
@@ -248,8 +225,7 @@ wheels = [
|
|
|
248
225
|
name = "django-tailwind-cli"
|
|
249
226
|
source = { editable = "." }
|
|
250
227
|
dependencies = [
|
|
251
|
-
{ name = "django"
|
|
252
|
-
{ name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
|
228
|
+
{ name = "django" },
|
|
253
229
|
{ name = "django-typer" },
|
|
254
230
|
{ name = "semver" },
|
|
255
231
|
]
|
|
@@ -298,8 +274,7 @@ version = "3.7.1"
|
|
|
298
274
|
source = { registry = "https://pypi.org/simple" }
|
|
299
275
|
dependencies = [
|
|
300
276
|
{ name = "click" },
|
|
301
|
-
{ name = "django"
|
|
302
|
-
{ name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
|
277
|
+
{ name = "django" },
|
|
303
278
|
{ name = "typer" },
|
|
304
279
|
]
|
|
305
280
|
sdist = { url = "https://files.pythonhosted.org/packages/a8/3e/2c22722c02cbae3a1ba65a11b4764418562dd0962f533463dd4b8e2afb0f/django_typer-3.7.1.tar.gz", hash = "sha256:e20720f2f85e30db86be6abd2d5f67e673cfe0bebf7ef4ddd22c37bf8699ea06", size = 2975518, upload-time = "2026-03-31T23:14:15.061Z" }
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
name: Test
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches:
|
|
6
|
-
- main
|
|
7
|
-
pull_request:
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
build:
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
strategy:
|
|
13
|
-
matrix:
|
|
14
|
-
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
15
|
-
|
|
16
|
-
steps:
|
|
17
|
-
- uses: actions/checkout@v6
|
|
18
|
-
|
|
19
|
-
- name: Set up Python ${{ matrix.python-version }}
|
|
20
|
-
uses: actions/setup-python@v6
|
|
21
|
-
with:
|
|
22
|
-
python-version: ${{ matrix.python-version }}
|
|
23
|
-
allow-prereleases: true
|
|
24
|
-
|
|
25
|
-
- name: Install uv
|
|
26
|
-
uses: astral-sh/setup-uv@v7
|
|
27
|
-
|
|
28
|
-
- name: Test with tox
|
|
29
|
-
run: uvx --with tox-uv --with tox-gh-actions tox
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/utils/__init__.py
RENAMED
|
File without changes
|
{django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/utils/http.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|