django-admin-runner 0.1.0__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 (37) hide show
  1. django_admin_runner-0.1.0/.gitignore +16 -0
  2. django_admin_runner-0.1.0/PKG-INFO +151 -0
  3. django_admin_runner-0.1.0/README.md +121 -0
  4. django_admin_runner-0.1.0/pyproject.toml +77 -0
  5. django_admin_runner-0.1.0/src/django_admin_runner/__init__.py +15 -0
  6. django_admin_runner-0.1.0/src/django_admin_runner/_ansi.py +227 -0
  7. django_admin_runner-0.1.0/src/django_admin_runner/admin.py +450 -0
  8. django_admin_runner-0.1.0/src/django_admin_runner/admin_compat.py +26 -0
  9. django_admin_runner-0.1.0/src/django_admin_runner/apps.py +22 -0
  10. django_admin_runner-0.1.0/src/django_admin_runner/celery_tasks.py +94 -0
  11. django_admin_runner-0.1.0/src/django_admin_runner/context.py +44 -0
  12. django_admin_runner-0.1.0/src/django_admin_runner/forms.py +322 -0
  13. django_admin_runner-0.1.0/src/django_admin_runner/hooks.py +179 -0
  14. django_admin_runner-0.1.0/src/django_admin_runner/migrations/0001_initial.py +63 -0
  15. django_admin_runner-0.1.0/src/django_admin_runner/migrations/0002_commandexecution_result_html.py +17 -0
  16. django_admin_runner-0.1.0/src/django_admin_runner/migrations/0003_registeredcommand.py +35 -0
  17. django_admin_runner-0.1.0/src/django_admin_runner/migrations/0004_alter_commandexecution_options_and_more.py +28 -0
  18. django_admin_runner-0.1.0/src/django_admin_runner/migrations/__init__.py +0 -0
  19. django_admin_runner-0.1.0/src/django_admin_runner/models.py +60 -0
  20. django_admin_runner-0.1.0/src/django_admin_runner/registry.py +138 -0
  21. django_admin_runner-0.1.0/src/django_admin_runner/runners/__init__.py +73 -0
  22. django_admin_runner-0.1.0/src/django_admin_runner/runners/celery.py +52 -0
  23. django_admin_runner-0.1.0/src/django_admin_runner/runners/django_q2.py +50 -0
  24. django_admin_runner-0.1.0/src/django_admin_runner/runners/django_tasks.py +58 -0
  25. django_admin_runner-0.1.0/src/django_admin_runner/runners/sync.py +33 -0
  26. django_admin_runner-0.1.0/src/django_admin_runner/static/django_admin_runner/ansi-output.css +239 -0
  27. django_admin_runner-0.1.0/src/django_admin_runner/sync.py +60 -0
  28. django_admin_runner-0.1.0/src/django_admin_runner/tasks.py +144 -0
  29. django_admin_runner-0.1.0/src/django_admin_runner/templates/admin/django_admin_runner/commandexecution/change_list.html +11 -0
  30. django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/base/list.html +50 -0
  31. django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/base/result.html +35 -0
  32. django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/base/run.html +55 -0
  33. django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/unfold/list.html +57 -0
  34. django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/unfold/result.html +33 -0
  35. django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/unfold/run.html +103 -0
  36. django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/widgets/file_or_path.html +15 -0
  37. django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/widgets/file_or_path_unfold.html +23 -0
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.pyo
4
+ .venv/
5
+ venv/
6
+ .env
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .coverage
11
+ htmlcov/
12
+ .pytest_cache/
13
+ .ruff_cache/
14
+ site/
15
+ *.sqlite3
16
+ uv.lock
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-admin-runner
3
+ Version: 0.1.0
4
+ Summary: Run Django management commands from the admin with auto-generated forms and pluggable task runners
5
+ Project-URL: Homepage, https://github.com/burgdev/django-admin-runner
6
+ Project-URL: Documentation, https://burgdev.github.io/django-admin-runner
7
+ Project-URL: Repository, https://github.com/burgdev/django-admin-runner
8
+ Project-URL: Issues, https://github.com/burgdev/django-admin-runner/issues
9
+ Project-URL: Changelog, https://github.com/burgdev/django-admin-runner/blob/main/CHANGELOG.md
10
+ License: MIT
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 6.0
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: django>=6.0
21
+ Provides-Extra: celery
22
+ Requires-Dist: celery>=5.3; extra == 'celery'
23
+ Provides-Extra: django-q2
24
+ Requires-Dist: django-q2>=1.5; extra == 'django-q2'
25
+ Provides-Extra: rich
26
+ Requires-Dist: rich>=13.0; extra == 'rich'
27
+ Provides-Extra: unfold
28
+ Requires-Dist: django-unfold>=0.40; extra == 'unfold'
29
+ Description-Content-Type: text/markdown
30
+
31
+ <h3 align="center"><b>django-admin-runner</b></h3>
32
+ <p align="center">
33
+ <em>Run Django management commands from the admin — with auto-generated forms,
34
+ pluggable task runners, and a unified execution log.</em>
35
+ </p>
36
+ <p align="center">
37
+ <b><a href="https://burgdev.github.io/django-admin-runner">Documentation</a></b>
38
+ | <b><a href="https://pypi.org/project/django-admin-runner">PyPI</a></b>
39
+ | <b><a href="https://github.com/burgdev/django-admin-runner/blob/main/CHANGELOG.md">Changelog</a></b>
40
+ </p>
41
+
42
+ ---
43
+
44
+ ## Features
45
+
46
+ - **`@register_command` decorator** — register any management command with metadata
47
+ - **Auto-generated forms** — argparse arguments become Django form fields automatically
48
+ - **Widget & form customisation** — override widgets per-argument (`widget=` on `add_argument`), supply a custom `Form` class, or use per-parameter field overrides in the decorator
49
+ - **Built-in file fields** — `FileOrPathField` (upload or server path), `FileField`, `ImageField`
50
+ - **Pluggable runners** — Django Tasks (default), Celery, sync, or custom
51
+ - **Execution log** — every run is stored as a `CommandExecution` record
52
+ - **Permission control** — per-command permission requirements (superuser, Django perms, or a list)
53
+ - **Model attachment** — show a "Run" button on any model's admin change-list via `models=[...]`
54
+ - **Unfold support** — auto-detected, uses Unfold templates and widgets when available
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install django-admin-runner
60
+ ```
61
+
62
+ Add to `INSTALLED_APPS`:
63
+
64
+ ```python
65
+ INSTALLED_APPS = [
66
+ ...
67
+ "django_admin_runner",
68
+ ]
69
+ ```
70
+
71
+ Run migrations:
72
+
73
+ ```bash
74
+ python manage.py migrate
75
+ ```
76
+
77
+ ## Quick start
78
+
79
+ ```python
80
+ # myapp/management/commands/my_command.py
81
+ from django.core.management.base import BaseCommand
82
+ from django_admin_runner import register_command
83
+
84
+ @register_command(group="Maintenance", permission="myapp.change_mymodel")
85
+ class Command(BaseCommand):
86
+ help = "Does something useful"
87
+
88
+ def add_arguments(self, parser):
89
+ parser.add_argument("--count", type=int, default=10)
90
+ parser.add_argument("--dry-run", action="store_true")
91
+
92
+ def handle(self, *args, **options):
93
+ self.stdout.write(f"Running {options['count']} times")
94
+ ```
95
+
96
+ Visit `/admin/django_admin_runner/commandexecution/commands/` to run your commands.
97
+
98
+ ### Custom widgets
99
+
100
+ Override how individual parameters render — right inside `add_arguments`:
101
+
102
+ ```python
103
+ from django_admin_runner import FileOrPathField, register_command
104
+
105
+ @register_command(group="Import")
106
+ class Command(BaseCommand):
107
+ def add_arguments(self, parser):
108
+ # File upload OR server-side path text field
109
+ parser.add_argument("--source", widget=FileOrPathField(), default="data.csv")
110
+ # Swap to a textarea
111
+ parser.add_argument("--notes", widget=forms.Textarea(attrs={"rows": 3}))
112
+ # Image upload (requires Pillow)
113
+ parser.add_argument("--photo", widget=forms.ImageField(required=False))
114
+ ```
115
+
116
+ Or provide a fully custom form class:
117
+
118
+ ```python
119
+ @register_command(form_class=MyImportForm)
120
+ class Command(BaseCommand):
121
+ ...
122
+ ```
123
+
124
+ See the [Widget & form customisation](https://burgdev.github.io/django-admin-runner/widgets/) docs for the full reference.
125
+
126
+ ## Development
127
+
128
+ **Requirements:** [uv](https://docs.astral.sh/uv/) and [just](https://github.com/casey/just)
129
+
130
+ ```bash
131
+ # Install dependencies and pre-commit hooks
132
+ just install
133
+
134
+ # Run tests
135
+ just tests
136
+
137
+ # Run linters
138
+ just check
139
+
140
+ # Serve docs locally
141
+ just docs
142
+ ```
143
+
144
+ ## Contributing
145
+
146
+ Contributions are welcome! Please open an issue or pull request on
147
+ [GitHub](https://github.com/burgdev/django-admin-runner).
148
+
149
+ ## License
150
+
151
+ MIT
@@ -0,0 +1,121 @@
1
+ <h3 align="center"><b>django-admin-runner</b></h3>
2
+ <p align="center">
3
+ <em>Run Django management commands from the admin — with auto-generated forms,
4
+ pluggable task runners, and a unified execution log.</em>
5
+ </p>
6
+ <p align="center">
7
+ <b><a href="https://burgdev.github.io/django-admin-runner">Documentation</a></b>
8
+ | <b><a href="https://pypi.org/project/django-admin-runner">PyPI</a></b>
9
+ | <b><a href="https://github.com/burgdev/django-admin-runner/blob/main/CHANGELOG.md">Changelog</a></b>
10
+ </p>
11
+
12
+ ---
13
+
14
+ ## Features
15
+
16
+ - **`@register_command` decorator** — register any management command with metadata
17
+ - **Auto-generated forms** — argparse arguments become Django form fields automatically
18
+ - **Widget & form customisation** — override widgets per-argument (`widget=` on `add_argument`), supply a custom `Form` class, or use per-parameter field overrides in the decorator
19
+ - **Built-in file fields** — `FileOrPathField` (upload or server path), `FileField`, `ImageField`
20
+ - **Pluggable runners** — Django Tasks (default), Celery, sync, or custom
21
+ - **Execution log** — every run is stored as a `CommandExecution` record
22
+ - **Permission control** — per-command permission requirements (superuser, Django perms, or a list)
23
+ - **Model attachment** — show a "Run" button on any model's admin change-list via `models=[...]`
24
+ - **Unfold support** — auto-detected, uses Unfold templates and widgets when available
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install django-admin-runner
30
+ ```
31
+
32
+ Add to `INSTALLED_APPS`:
33
+
34
+ ```python
35
+ INSTALLED_APPS = [
36
+ ...
37
+ "django_admin_runner",
38
+ ]
39
+ ```
40
+
41
+ Run migrations:
42
+
43
+ ```bash
44
+ python manage.py migrate
45
+ ```
46
+
47
+ ## Quick start
48
+
49
+ ```python
50
+ # myapp/management/commands/my_command.py
51
+ from django.core.management.base import BaseCommand
52
+ from django_admin_runner import register_command
53
+
54
+ @register_command(group="Maintenance", permission="myapp.change_mymodel")
55
+ class Command(BaseCommand):
56
+ help = "Does something useful"
57
+
58
+ def add_arguments(self, parser):
59
+ parser.add_argument("--count", type=int, default=10)
60
+ parser.add_argument("--dry-run", action="store_true")
61
+
62
+ def handle(self, *args, **options):
63
+ self.stdout.write(f"Running {options['count']} times")
64
+ ```
65
+
66
+ Visit `/admin/django_admin_runner/commandexecution/commands/` to run your commands.
67
+
68
+ ### Custom widgets
69
+
70
+ Override how individual parameters render — right inside `add_arguments`:
71
+
72
+ ```python
73
+ from django_admin_runner import FileOrPathField, register_command
74
+
75
+ @register_command(group="Import")
76
+ class Command(BaseCommand):
77
+ def add_arguments(self, parser):
78
+ # File upload OR server-side path text field
79
+ parser.add_argument("--source", widget=FileOrPathField(), default="data.csv")
80
+ # Swap to a textarea
81
+ parser.add_argument("--notes", widget=forms.Textarea(attrs={"rows": 3}))
82
+ # Image upload (requires Pillow)
83
+ parser.add_argument("--photo", widget=forms.ImageField(required=False))
84
+ ```
85
+
86
+ Or provide a fully custom form class:
87
+
88
+ ```python
89
+ @register_command(form_class=MyImportForm)
90
+ class Command(BaseCommand):
91
+ ...
92
+ ```
93
+
94
+ See the [Widget & form customisation](https://burgdev.github.io/django-admin-runner/widgets/) docs for the full reference.
95
+
96
+ ## Development
97
+
98
+ **Requirements:** [uv](https://docs.astral.sh/uv/) and [just](https://github.com/casey/just)
99
+
100
+ ```bash
101
+ # Install dependencies and pre-commit hooks
102
+ just install
103
+
104
+ # Run tests
105
+ just tests
106
+
107
+ # Run linters
108
+ just check
109
+
110
+ # Serve docs locally
111
+ just docs
112
+ ```
113
+
114
+ ## Contributing
115
+
116
+ Contributions are welcome! Please open an issue or pull request on
117
+ [GitHub](https://github.com/burgdev/django-admin-runner).
118
+
119
+ ## License
120
+
121
+ MIT
@@ -0,0 +1,77 @@
1
+ [project]
2
+ name = "django-admin-runner"
3
+ version = "0.1.0"
4
+ description = "Run Django management commands from the admin with auto-generated forms and pluggable task runners"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.12"
8
+ dependencies = ["django>=6.0"]
9
+
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Framework :: Django",
15
+ "Framework :: Django :: 6.0",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ celery = ["celery>=5.3"]
23
+ django-q2 = ["django-q2>=1.5"]
24
+ unfold = ["django-unfold>=0.40"]
25
+ rich = ["rich>=13.0"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/burgdev/django-admin-runner"
29
+ Documentation = "https://burgdev.github.io/django-admin-runner"
30
+ Repository = "https://github.com/burgdev/django-admin-runner"
31
+ Issues = "https://github.com/burgdev/django-admin-runner/issues"
32
+ Changelog = "https://github.com/burgdev/django-admin-runner/blob/main/CHANGELOG.md"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "bump2version>=1.0.1",
37
+ "git-cliff>=2.7.0",
38
+ "pre-commit>=4.0.1",
39
+ "pyright>=1.1.0",
40
+ "ruff>=0.8.2",
41
+ ]
42
+ docs = [
43
+ "zensical>=0.0.32",
44
+ "mkdocs-open-in-new-tab>=1.0.8",
45
+ "mkdocs-literate-nav>=0.6.1",
46
+ "mkdocs-section-index>=0.3.9",
47
+ "mkdocstrings[python]>=0.22.0",
48
+ ]
49
+ test = [
50
+ "pytest>=8.3.4",
51
+ "pytest-django>=4.10.0",
52
+ "pytest-cov>=4.1.0",
53
+ ]
54
+
55
+ [build-system]
56
+ requires = ["hatchling"]
57
+ build-backend = "hatchling.build"
58
+
59
+ [tool.hatch.build.targets.wheel]
60
+ packages = ["src/django_admin_runner"]
61
+
62
+ [tool.hatch.build.targets.sdist]
63
+ include = ["src/django_admin_runner"]
64
+
65
+ [tool.pytest.ini_options]
66
+ DJANGO_SETTINGS_MODULE = "tests.settings"
67
+ pythonpath = ["src", "."]
68
+
69
+ [tool.ruff]
70
+ src = ["src"]
71
+ line-length = 100
72
+
73
+ [tool.ruff.lint]
74
+ select = ["E", "F", "I", "UP"]
75
+
76
+ [tool.ruff.lint.per-file-ignores]
77
+ "__init__.py" = ["F401"]
@@ -0,0 +1,15 @@
1
+ from django_admin_runner.context import is_admin_runner, set_result_html
2
+ from django_admin_runner.forms import FileField, FileOrPathField, FileOrPathWidget, ImageField
3
+ from django_admin_runner.registry import register_command
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = [
8
+ "register_command",
9
+ "is_admin_runner",
10
+ "set_result_html",
11
+ "FileOrPathField",
12
+ "FileOrPathWidget",
13
+ "FileField",
14
+ "ImageField",
15
+ ]
@@ -0,0 +1,227 @@
1
+ """Minimal ANSI escape sequence to HTML converter.
2
+
3
+ Converts SGR (Select Graphic Rendition) escape sequences to HTML ``<span>``
4
+ elements with CSS classes and/or inline ``style`` attributes.
5
+
6
+ Supported codes:
7
+ - ``0`` — reset
8
+ - ``1`` bold, ``2`` dim, ``3`` italic, ``4`` underline
9
+ - ``22`` reset bold/dim, ``23`` reset italic, ``24`` reset underline
10
+ - ``30–37`` / ``90–97`` — 4-bit foreground (standard + bright) → CSS class
11
+ - ``40–47`` / ``100–107`` — 4-bit background → CSS class
12
+ - ``38;5;N`` / ``48;5;N`` — 256-colour: N<16 → CSS class, N≥16 → inline rgb()
13
+ - ``38;2;r;g;b`` / ``48;2;r;g;b`` — truecolor → inline rgb()
14
+ - ``39`` / ``49`` — reset foreground / background
15
+
16
+ 4-bit colours map to ``ansi-fg-N`` / ``ansi-bg-N`` CSS classes (N = 0–15).
17
+ Style attributes are provided by ``ansi-output.css`` via CSS custom properties,
18
+ making the palette theme-aware (dark / light mode).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import html as _html
24
+ import re
25
+ from dataclasses import dataclass, field
26
+
27
+ _ANSI_RE = re.compile(r"\x1b\[([0-9;]*)m")
28
+ _URL_RE = re.compile(r"https?://[^\s<>\"]+")
29
+
30
+
31
+ @dataclass
32
+ class _State:
33
+ fg: int | None = None # 0-15 → CSS class
34
+ bg: int | None = None
35
+ fg_rgb: tuple[int, int, int] | None = None # truecolor / 256-colour ≥16
36
+ bg_rgb: tuple[int, int, int] | None = None
37
+ attrs: set[str] = field(default_factory=set) # bold, dim, italic, underline
38
+
39
+ def reset(self) -> None:
40
+ self.fg = None
41
+ self.bg = None
42
+ self.fg_rgb = None
43
+ self.bg_rgb = None
44
+ self.attrs.clear()
45
+
46
+ def is_default(self) -> bool:
47
+ return (
48
+ self.fg is None
49
+ and self.bg is None
50
+ and self.fg_rgb is None
51
+ and self.bg_rgb is None
52
+ and not self.attrs
53
+ )
54
+
55
+ def css_classes(self) -> list[str]:
56
+ classes = [f"ansi-{a}" for a in sorted(self.attrs)]
57
+ if self.fg is not None:
58
+ classes.append(f"ansi-fg-{self.fg}")
59
+ if self.bg is not None:
60
+ classes.append(f"ansi-bg-{self.bg}")
61
+ return classes
62
+
63
+ def inline_styles(self) -> list[str]:
64
+ styles = []
65
+ if self.fg_rgb is not None:
66
+ r, g, b = self.fg_rgb
67
+ styles.append(f"color:rgb({r},{g},{b})")
68
+ if self.bg_rgb is not None:
69
+ r, g, b = self.bg_rgb
70
+ styles.append(f"background:rgb({r},{g},{b})")
71
+ return styles
72
+
73
+
74
+ def _256_to_rgb(n: int) -> tuple[int, int, int]:
75
+ """Convert 256-colour index ≥16 to ``(r, g, b)``."""
76
+ if n < 232:
77
+ n -= 16
78
+ return ((n // 36) * 51, ((n % 36) // 6) * 51, (n % 6) * 51)
79
+ v = (n - 232) * 10 + 8
80
+ return (v, v, v)
81
+
82
+
83
+ def _apply_sgr(state: _State, params: list[int]) -> None:
84
+ """Apply a list of SGR parameter values to *state* in-place."""
85
+ i = 0
86
+ while i < len(params):
87
+ p = params[i]
88
+
89
+ if p == 0:
90
+ state.reset()
91
+ elif p == 1:
92
+ state.attrs.add("bold")
93
+ elif p == 2:
94
+ state.attrs.add("dim")
95
+ elif p == 3:
96
+ state.attrs.add("italic")
97
+ elif p == 4:
98
+ state.attrs.add("underline")
99
+ elif p == 22:
100
+ state.attrs.discard("bold")
101
+ state.attrs.discard("dim")
102
+ elif p == 23:
103
+ state.attrs.discard("italic")
104
+ elif p == 24:
105
+ state.attrs.discard("underline")
106
+ elif 30 <= p <= 37:
107
+ state.fg = p - 30
108
+ state.fg_rgb = None
109
+ elif p == 38:
110
+ if i + 1 < len(params) and params[i + 1] == 5 and i + 2 < len(params):
111
+ n = params[i + 2]
112
+ if n < 16:
113
+ state.fg = n
114
+ state.fg_rgb = None
115
+ else:
116
+ state.fg = None
117
+ state.fg_rgb = _256_to_rgb(n)
118
+ i += 2
119
+ elif i + 1 < len(params) and params[i + 1] == 2 and i + 4 < len(params):
120
+ state.fg = None
121
+ state.fg_rgb = (params[i + 2], params[i + 3], params[i + 4])
122
+ i += 4
123
+ elif p == 39:
124
+ state.fg = None
125
+ state.fg_rgb = None
126
+ elif 40 <= p <= 47:
127
+ state.bg = p - 40
128
+ state.bg_rgb = None
129
+ elif p == 48:
130
+ if i + 1 < len(params) and params[i + 1] == 5 and i + 2 < len(params):
131
+ n = params[i + 2]
132
+ if n < 16:
133
+ state.bg = n
134
+ state.bg_rgb = None
135
+ else:
136
+ state.bg = None
137
+ state.bg_rgb = _256_to_rgb(n)
138
+ i += 2
139
+ elif i + 1 < len(params) and params[i + 1] == 2 and i + 4 < len(params):
140
+ state.bg = None
141
+ state.bg_rgb = (params[i + 2], params[i + 3], params[i + 4])
142
+ i += 4
143
+ elif p == 49:
144
+ state.bg = None
145
+ state.bg_rgb = None
146
+ elif 90 <= p <= 97:
147
+ state.fg = p - 90 + 8 # bright colours → indices 8–15
148
+ state.fg_rgb = None
149
+ elif 100 <= p <= 107:
150
+ state.bg = p - 100 + 8
151
+ state.bg_rgb = None
152
+
153
+ i += 1
154
+
155
+
156
+ def ansi_to_html(text: str) -> str:
157
+ """Convert ANSI escape sequences in *text* to HTML ``<span>`` elements.
158
+
159
+ Text content is HTML-escaped. Spans use CSS classes for 4-bit colours
160
+ (``ansi-fg-N`` / ``ansi-bg-N``) and inline ``style`` attributes for
161
+ 256-colour (indices ≥16) and truecolor values.
162
+ """
163
+ result: list[str] = []
164
+ state = _State()
165
+ span_open = False
166
+
167
+ def flush_span() -> None:
168
+ nonlocal span_open
169
+ if span_open:
170
+ result.append("</span>")
171
+ span_open = False
172
+
173
+ def emit_text(chunk: str) -> None:
174
+ nonlocal span_open
175
+ if not chunk:
176
+ return
177
+ if not span_open and not state.is_default():
178
+ classes = state.css_classes()
179
+ styles = state.inline_styles()
180
+ attrs = ""
181
+ if classes:
182
+ attrs += f' class="{" ".join(classes)}"'
183
+ if styles:
184
+ attrs += f' style="{";".join(styles)}"'
185
+ result.append(f"<span{attrs}>")
186
+ span_open = True
187
+ result.append(_html.escape(chunk))
188
+
189
+ pos = 0
190
+ for match in _ANSI_RE.finditer(text):
191
+ start, end = match.span()
192
+ emit_text(text[pos:start])
193
+ flush_span()
194
+ raw = match.group(1)
195
+ params = [int(x) for x in raw.split(";") if x] if raw else [0]
196
+ _apply_sgr(state, params)
197
+ pos = end
198
+
199
+ emit_text(text[pos:])
200
+ flush_span()
201
+
202
+ return "".join(result)
203
+
204
+
205
+ def linkify_urls(html: str) -> str:
206
+ """Wrap plain-text URLs in *html* with ``<a>`` tags.
207
+
208
+ Operates on the output of :func:`ansi_to_html` — text content has already
209
+ been HTML-escaped (e.g. ``&amp;``), so URL detection runs on the escaped
210
+ form. The regex avoids matching inside existing HTML tags by excluding
211
+ ``<``, ``>``, and ``"`` characters.
212
+ """
213
+ trailing_punct = set(".,;:!?")
214
+
215
+ def _replace(m: re.Match[str]) -> str:
216
+ url = m.group(0)
217
+ # Strip trailing punctuation that's unlikely to be part of the URL
218
+ stripped = []
219
+ while url and url[-1] in trailing_punct:
220
+ stripped.append(url[-1])
221
+ url = url[:-1]
222
+ href = _html.unescape(url)
223
+ link = f'<a href="{_html.escape(href)}" target="_blank">{url}</a>'
224
+ # Re-append stripped punctuation after the link
225
+ return link + "".join(reversed(stripped))
226
+
227
+ return _URL_RE.sub(_replace, html)