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.
- django_tailwind_cli-4.6.2/.github/dependabot.yml +19 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/.github/workflows/release.yml +34 -20
- django_tailwind_cli-4.6.2/.github/workflows/test.yml +53 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/CHANGELOG.md +20 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/PKG-INFO +2 -1
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/requirements.txt +2 -2
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/justfile +1 -1
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/pyproject.toml +1 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/management/commands/tailwind.py +111 -28
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_error_scenarios.py +6 -9
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_integration.py +234 -18
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tox.ini +2 -1
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/uv.lock +158 -158
- 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.2}/.gitignore +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/.pre-commit-config.yaml +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/.readthedocs.yml +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/CODE_OF_CONDUCT.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/LICENSE +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/README.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/base_template.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/changelog.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/conf.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/development.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/index.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/installation.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/settings.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/template_tags.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/docs/usage.md +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/__init__.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/apps.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/config.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/management/__init__.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/management/commands/__init__.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/py.typed +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/templates/tailwind_cli/base.html +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/templates/tailwind_cli/tailwind_css.html +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/templatetags/__init__.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/templatetags/tailwind_cli.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/utils/__init__.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/src/django_tailwind_cli/utils/http.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/.gitignore +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/__init__.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/assets/css/.gitkeep +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/assets/css/tailwind.css +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/conftest.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/settings.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/templates/tests/base.html +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/templates/tests/dummy.email +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_additional_commands.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_config.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_http.py +0 -0
- {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.2}/tests/test_management_commands.py +0 -0
- {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="${
|
|
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", "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.
|
|
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
|
|
@@ -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
|
-
#
|
|
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()
|