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.
- django_admin_runner-0.1.0/.gitignore +16 -0
- django_admin_runner-0.1.0/PKG-INFO +151 -0
- django_admin_runner-0.1.0/README.md +121 -0
- django_admin_runner-0.1.0/pyproject.toml +77 -0
- django_admin_runner-0.1.0/src/django_admin_runner/__init__.py +15 -0
- django_admin_runner-0.1.0/src/django_admin_runner/_ansi.py +227 -0
- django_admin_runner-0.1.0/src/django_admin_runner/admin.py +450 -0
- django_admin_runner-0.1.0/src/django_admin_runner/admin_compat.py +26 -0
- django_admin_runner-0.1.0/src/django_admin_runner/apps.py +22 -0
- django_admin_runner-0.1.0/src/django_admin_runner/celery_tasks.py +94 -0
- django_admin_runner-0.1.0/src/django_admin_runner/context.py +44 -0
- django_admin_runner-0.1.0/src/django_admin_runner/forms.py +322 -0
- django_admin_runner-0.1.0/src/django_admin_runner/hooks.py +179 -0
- django_admin_runner-0.1.0/src/django_admin_runner/migrations/0001_initial.py +63 -0
- django_admin_runner-0.1.0/src/django_admin_runner/migrations/0002_commandexecution_result_html.py +17 -0
- django_admin_runner-0.1.0/src/django_admin_runner/migrations/0003_registeredcommand.py +35 -0
- django_admin_runner-0.1.0/src/django_admin_runner/migrations/0004_alter_commandexecution_options_and_more.py +28 -0
- django_admin_runner-0.1.0/src/django_admin_runner/migrations/__init__.py +0 -0
- django_admin_runner-0.1.0/src/django_admin_runner/models.py +60 -0
- django_admin_runner-0.1.0/src/django_admin_runner/registry.py +138 -0
- django_admin_runner-0.1.0/src/django_admin_runner/runners/__init__.py +73 -0
- django_admin_runner-0.1.0/src/django_admin_runner/runners/celery.py +52 -0
- django_admin_runner-0.1.0/src/django_admin_runner/runners/django_q2.py +50 -0
- django_admin_runner-0.1.0/src/django_admin_runner/runners/django_tasks.py +58 -0
- django_admin_runner-0.1.0/src/django_admin_runner/runners/sync.py +33 -0
- django_admin_runner-0.1.0/src/django_admin_runner/static/django_admin_runner/ansi-output.css +239 -0
- django_admin_runner-0.1.0/src/django_admin_runner/sync.py +60 -0
- django_admin_runner-0.1.0/src/django_admin_runner/tasks.py +144 -0
- django_admin_runner-0.1.0/src/django_admin_runner/templates/admin/django_admin_runner/commandexecution/change_list.html +11 -0
- django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/base/list.html +50 -0
- django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/base/result.html +35 -0
- django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/base/run.html +55 -0
- django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/unfold/list.html +57 -0
- django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/unfold/result.html +33 -0
- django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/unfold/run.html +103 -0
- django_admin_runner-0.1.0/src/django_admin_runner/templates/django_admin_runner/widgets/file_or_path.html +15 -0
- 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,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. ``&``), 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)
|