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.
Files changed (55) hide show
  1. django_tailwind_cli-4.6.1/.github/dependabot.yml +19 -0
  2. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/.github/workflows/release.yml +34 -20
  3. django_tailwind_cli-4.6.1/.github/workflows/test.yml +53 -0
  4. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/CHANGELOG.md +15 -0
  5. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/PKG-INFO +1 -1
  6. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/requirements.txt +2 -2
  7. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/management/commands/tailwind.py +111 -28
  8. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_error_scenarios.py +6 -9
  9. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_integration.py +234 -18
  10. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/uv.lock +11 -36
  11. django_tailwind_cli-4.6.0/.github/dependabot.yml +0 -6
  12. django_tailwind_cli-4.6.0/.github/workflows/test.yml +0 -29
  13. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/.gitignore +0 -0
  14. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/.pre-commit-config.yaml +0 -0
  15. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/.readthedocs.yml +0 -0
  16. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/CODE_OF_CONDUCT.md +0 -0
  17. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/LICENSE +0 -0
  18. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/README.md +0 -0
  19. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/base_template.md +0 -0
  20. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/changelog.md +0 -0
  21. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/conf.py +0 -0
  22. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/development.md +0 -0
  23. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/index.md +0 -0
  24. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/installation.md +0 -0
  25. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/settings.md +0 -0
  26. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/template_tags.md +0 -0
  27. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/docs/usage.md +0 -0
  28. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/justfile +0 -0
  29. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/pyproject.toml +0 -0
  30. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/__init__.py +0 -0
  31. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/apps.py +0 -0
  32. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/config.py +0 -0
  33. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/management/__init__.py +0 -0
  34. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/management/commands/__init__.py +0 -0
  35. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/py.typed +0 -0
  36. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/templates/tailwind_cli/base.html +0 -0
  37. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/templates/tailwind_cli/tailwind_css.html +0 -0
  38. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/templatetags/__init__.py +0 -0
  39. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/templatetags/tailwind_cli.py +0 -0
  40. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/utils/__init__.py +0 -0
  41. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/src/django_tailwind_cli/utils/http.py +0 -0
  42. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/.gitignore +0 -0
  43. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/__init__.py +0 -0
  44. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/assets/css/.gitkeep +0 -0
  45. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/assets/css/tailwind.css +0 -0
  46. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/conftest.py +0 -0
  47. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/settings.py +0 -0
  48. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/templates/tests/base.html +0 -0
  49. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/templates/tests/dummy.email +0 -0
  50. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_additional_commands.py +0 -0
  51. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_config.py +0 -0
  52. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_http.py +0 -0
  53. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_management_commands.py +0 -0
  54. {django_tailwind_cli-4.6.0 → django_tailwind_cli-4.6.1}/tests/test_tailwind_css_tag.py +0 -0
  55. {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="${{ 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"]
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.0
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/
@@ -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
@@ -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()
@@ -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
- # Remove single-file settings to avoid conflict
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
- # Remove single-file settings to avoid conflict
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
- # Manually trigger cleanup to test behavior
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
- # Manually trigger cleanup to test behavior
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.13"
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", marker = "python_full_version >= '3.12'" },
195
- { name = "sqlparse", marker = "python_full_version >= '3.12'" },
196
- { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },
174
+ { name = "asgiref" },
175
+ { name = "sqlparse" },
176
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
197
177
  ]
198
- sdist = { url = "https://files.pythonhosted.org/packages/60/b9/4155091ad1788b38563bd77a7258c0834e8c12a7f56f6975deaf54f8b61d/django-6.0.4.tar.gz", hash = "sha256:8cfa2572b3f2768b2e84983cf3c4811877a01edb64e817986ec5d60751c113ac", size = 10907407, upload-time = "2026-04-07T13:55:44.961Z" }
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/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" },
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", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
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", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
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", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
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", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
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", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
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,6 +0,0 @@
1
- version: 2
2
- updates:
3
- - package-ecosystem: "github-actions"
4
- directory: "/"
5
- schedule:
6
- interval: "weekly"
@@ -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