django-bidi-input 1.0.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_bidi_input-1.0.0/PKG-INFO +116 -0
- django_bidi_input-1.0.0/README.md +91 -0
- django_bidi_input-1.0.0/pyproject.toml +40 -0
- django_bidi_input-1.0.0/setup.cfg +4 -0
- django_bidi_input-1.0.0/src/django_bidi_input/__init__.py +1 -0
- django_bidi_input-1.0.0/src/django_bidi_input/apply.py +25 -0
- django_bidi_input-1.0.0/src/django_bidi_input/apps.py +12 -0
- django_bidi_input-1.0.0/src/django_bidi_input/conf.py +76 -0
- django_bidi_input-1.0.0/src/django_bidi_input/direction.py +47 -0
- django_bidi_input-1.0.0/src/django_bidi_input/patch.py +19 -0
- django_bidi_input-1.0.0/src/django_bidi_input.egg-info/PKG-INFO +116 -0
- django_bidi_input-1.0.0/src/django_bidi_input.egg-info/SOURCES.txt +15 -0
- django_bidi_input-1.0.0/src/django_bidi_input.egg-info/dependency_links.txt +1 -0
- django_bidi_input-1.0.0/src/django_bidi_input.egg-info/requires.txt +5 -0
- django_bidi_input-1.0.0/src/django_bidi_input.egg-info/top_level.txt +1 -0
- django_bidi_input-1.0.0/tests/test_admin.py +25 -0
- django_bidi_input-1.0.0/tests/test_forms.py +97 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-bidi-input
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Force correct text direction (LTR/RTL) on Django admin and form input fields, regardless of site language.
|
|
5
|
+
Author-email: Omid Shojaee <omid.shojaee@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/omidshojaee/django-bidi-input
|
|
8
|
+
Project-URL: Issues, https://github.com/omidshojaee/django-bidi-input/issues
|
|
9
|
+
Classifier: Framework :: Django
|
|
10
|
+
Classifier: Framework :: Django :: 5.2
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: Django>=5.2
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
24
|
+
Requires-Dist: pytest-django>=4; extra == "test"
|
|
25
|
+
|
|
26
|
+
# django-bidi-input
|
|
27
|
+
|
|
28
|
+
Django doesn't let you control the text direction of individual form/admin
|
|
29
|
+
input fields — when a site's language is RTL (Farsi, Arabic, Hebrew, ...),
|
|
30
|
+
every input renders RTL, including fields where that's wrong: email, URL,
|
|
31
|
+
slug, UUID, IP address, numbers, passwords, phone numbers, etc. Only free
|
|
32
|
+
text fields should follow the page direction.
|
|
33
|
+
|
|
34
|
+
This has been an accepted Django ticket
|
|
35
|
+
([#35482](https://code.djangoproject.com/ticket/35482)) for a while with no
|
|
36
|
+
implementation yet. `django-bidi-input` fixes it today, without patching
|
|
37
|
+
Django, for both **admin** and regular **forms**.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install django-bidi-input
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
INSTALLED_APPS = [
|
|
47
|
+
...,
|
|
48
|
+
"django_bidi_input",
|
|
49
|
+
]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That's it. Every `ModelForm`, plain `Form`, and admin form in your project
|
|
53
|
+
now gets a correct `dir` attribute (and a `bidi-input-ltr`/`bidi-input-rtl`
|
|
54
|
+
CSS class as a fallback) on each field, automatically.
|
|
55
|
+
|
|
56
|
+
## How direction is decided
|
|
57
|
+
|
|
58
|
+
For each field, in order (first match wins):
|
|
59
|
+
|
|
60
|
+
1. You already set `dir` on the widget yourself — left untouched.
|
|
61
|
+
2. `direction` set directly on the model field instance:
|
|
62
|
+
```python
|
|
63
|
+
phone_number = models.CharField(max_length=32)
|
|
64
|
+
phone_number.direction = "ltr"
|
|
65
|
+
```
|
|
66
|
+
3. `BIDI_INPUT_OVERRIDES` setting, for fields you can't edit directly
|
|
67
|
+
(third-party app models, etc.):
|
|
68
|
+
```python
|
|
69
|
+
BIDI_INPUT_OVERRIDES = {
|
|
70
|
+
"myapp.Contact.phone_number": "ltr",
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
4. Built-in default, in order:
|
|
74
|
+
- widget `input_type` is `password`, `number`, `tel`, `date`, `time`, etc.
|
|
75
|
+
- widget `autocomplete` is `username`, `email`, `tel`, `new-password`,
|
|
76
|
+
`current-password`, `one-time-code`. This is what makes Django's own
|
|
77
|
+
admin/auth **login form** (`username` + `password`) render LTR, even
|
|
78
|
+
though `UsernameField` is a plain `CharField` — Django sets
|
|
79
|
+
`autocomplete="username"` on it unconditionally, including when a
|
|
80
|
+
custom user model uses `USERNAME_FIELD = "email"`.
|
|
81
|
+
- field class is `EmailField`, `URLField`, `SlugField`, `UUIDField`,
|
|
82
|
+
`GenericIPAddressField`, `FilePathField`, `IntegerField`, `FloatField`,
|
|
83
|
+
`DecimalField`, `DurationField`, `RegexField`.
|
|
84
|
+
5. Otherwise left alone — inherits the page's direction (correct for plain
|
|
85
|
+
`CharField`/`TextField`).
|
|
86
|
+
|
|
87
|
+
## Settings
|
|
88
|
+
|
|
89
|
+
| Setting | Default | Purpose |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `BIDI_INPUT_ENABLED` | `True` | Turn the whole thing off. |
|
|
92
|
+
| `BIDI_INPUT_OVERRIDES` | `{}` | `"app_label.Model.field": "ltr"/"rtl"` map. |
|
|
93
|
+
| `BIDI_INPUT_LTR_FIELD_CLASSES` | see `conf.py` | Dotted paths of extra form field classes to treat as LTR. |
|
|
94
|
+
| `BIDI_INPUT_LTR_INPUT_TYPES` | see `conf.py` | Extra widget `input_type` values to treat as LTR. |
|
|
95
|
+
| `BIDI_INPUT_LTR_AUTOCOMPLETE_VALUES` | see `conf.py` | Extra widget `autocomplete` values to treat as LTR. |
|
|
96
|
+
|
|
97
|
+
## Why this works for admin too
|
|
98
|
+
|
|
99
|
+
Django admin builds its forms as ordinary `ModelForm` subclasses via
|
|
100
|
+
`modelform_factory`. This package hooks `BaseForm.__init__` once, so admin
|
|
101
|
+
inherits the behavior automatically — no `ModelAdmin` changes needed.
|
|
102
|
+
|
|
103
|
+
## Scope
|
|
104
|
+
|
|
105
|
+
This targets the actual pain point: **Django admin and Django forms**.
|
|
106
|
+
It does not attempt to make `direction` a real `Field` kwarg (that requires
|
|
107
|
+
changing Django itself, which is what ticket #35482 is for), so
|
|
108
|
+
introspection-based tools built outside the forms API (DRF serializers,
|
|
109
|
+
etc.) won't see it.
|
|
110
|
+
|
|
111
|
+
## Running the tests
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pip install -e ".[test]"
|
|
115
|
+
pytest
|
|
116
|
+
```
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# django-bidi-input
|
|
2
|
+
|
|
3
|
+
Django doesn't let you control the text direction of individual form/admin
|
|
4
|
+
input fields — when a site's language is RTL (Farsi, Arabic, Hebrew, ...),
|
|
5
|
+
every input renders RTL, including fields where that's wrong: email, URL,
|
|
6
|
+
slug, UUID, IP address, numbers, passwords, phone numbers, etc. Only free
|
|
7
|
+
text fields should follow the page direction.
|
|
8
|
+
|
|
9
|
+
This has been an accepted Django ticket
|
|
10
|
+
([#35482](https://code.djangoproject.com/ticket/35482)) for a while with no
|
|
11
|
+
implementation yet. `django-bidi-input` fixes it today, without patching
|
|
12
|
+
Django, for both **admin** and regular **forms**.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install django-bidi-input
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
INSTALLED_APPS = [
|
|
22
|
+
...,
|
|
23
|
+
"django_bidi_input",
|
|
24
|
+
]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
That's it. Every `ModelForm`, plain `Form`, and admin form in your project
|
|
28
|
+
now gets a correct `dir` attribute (and a `bidi-input-ltr`/`bidi-input-rtl`
|
|
29
|
+
CSS class as a fallback) on each field, automatically.
|
|
30
|
+
|
|
31
|
+
## How direction is decided
|
|
32
|
+
|
|
33
|
+
For each field, in order (first match wins):
|
|
34
|
+
|
|
35
|
+
1. You already set `dir` on the widget yourself — left untouched.
|
|
36
|
+
2. `direction` set directly on the model field instance:
|
|
37
|
+
```python
|
|
38
|
+
phone_number = models.CharField(max_length=32)
|
|
39
|
+
phone_number.direction = "ltr"
|
|
40
|
+
```
|
|
41
|
+
3. `BIDI_INPUT_OVERRIDES` setting, for fields you can't edit directly
|
|
42
|
+
(third-party app models, etc.):
|
|
43
|
+
```python
|
|
44
|
+
BIDI_INPUT_OVERRIDES = {
|
|
45
|
+
"myapp.Contact.phone_number": "ltr",
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
4. Built-in default, in order:
|
|
49
|
+
- widget `input_type` is `password`, `number`, `tel`, `date`, `time`, etc.
|
|
50
|
+
- widget `autocomplete` is `username`, `email`, `tel`, `new-password`,
|
|
51
|
+
`current-password`, `one-time-code`. This is what makes Django's own
|
|
52
|
+
admin/auth **login form** (`username` + `password`) render LTR, even
|
|
53
|
+
though `UsernameField` is a plain `CharField` — Django sets
|
|
54
|
+
`autocomplete="username"` on it unconditionally, including when a
|
|
55
|
+
custom user model uses `USERNAME_FIELD = "email"`.
|
|
56
|
+
- field class is `EmailField`, `URLField`, `SlugField`, `UUIDField`,
|
|
57
|
+
`GenericIPAddressField`, `FilePathField`, `IntegerField`, `FloatField`,
|
|
58
|
+
`DecimalField`, `DurationField`, `RegexField`.
|
|
59
|
+
5. Otherwise left alone — inherits the page's direction (correct for plain
|
|
60
|
+
`CharField`/`TextField`).
|
|
61
|
+
|
|
62
|
+
## Settings
|
|
63
|
+
|
|
64
|
+
| Setting | Default | Purpose |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `BIDI_INPUT_ENABLED` | `True` | Turn the whole thing off. |
|
|
67
|
+
| `BIDI_INPUT_OVERRIDES` | `{}` | `"app_label.Model.field": "ltr"/"rtl"` map. |
|
|
68
|
+
| `BIDI_INPUT_LTR_FIELD_CLASSES` | see `conf.py` | Dotted paths of extra form field classes to treat as LTR. |
|
|
69
|
+
| `BIDI_INPUT_LTR_INPUT_TYPES` | see `conf.py` | Extra widget `input_type` values to treat as LTR. |
|
|
70
|
+
| `BIDI_INPUT_LTR_AUTOCOMPLETE_VALUES` | see `conf.py` | Extra widget `autocomplete` values to treat as LTR. |
|
|
71
|
+
|
|
72
|
+
## Why this works for admin too
|
|
73
|
+
|
|
74
|
+
Django admin builds its forms as ordinary `ModelForm` subclasses via
|
|
75
|
+
`modelform_factory`. This package hooks `BaseForm.__init__` once, so admin
|
|
76
|
+
inherits the behavior automatically — no `ModelAdmin` changes needed.
|
|
77
|
+
|
|
78
|
+
## Scope
|
|
79
|
+
|
|
80
|
+
This targets the actual pain point: **Django admin and Django forms**.
|
|
81
|
+
It does not attempt to make `direction` a real `Field` kwarg (that requires
|
|
82
|
+
changing Django itself, which is what ticket #35482 is for), so
|
|
83
|
+
introspection-based tools built outside the forms API (DRF serializers,
|
|
84
|
+
etc.) won't see it.
|
|
85
|
+
|
|
86
|
+
## Running the tests
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install -e ".[test]"
|
|
90
|
+
pytest
|
|
91
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-bidi-input"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Force correct text direction (LTR/RTL) on Django admin and form input fields, regardless of site language."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Omid Shojaee", email = "omid.shojaee@gmail.com" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["Django>=5.2"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Framework :: Django",
|
|
16
|
+
"Framework :: Django :: 5.2",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
test = ["pytest>=7", "pytest-django>=4"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/omidshojaee/django-bidi-input"
|
|
32
|
+
Issues = "https://github.com/omidshojaee/django-bidi-input/issues"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
39
|
+
python_files = ["test_*.py"]
|
|
40
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from . import conf
|
|
2
|
+
from .direction import resolve_direction
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def apply_directions(form):
|
|
6
|
+
if not conf.is_enabled():
|
|
7
|
+
return
|
|
8
|
+
|
|
9
|
+
model = getattr(getattr(form, "_meta", None), "model", None)
|
|
10
|
+
|
|
11
|
+
for field_name, field in form.fields.items():
|
|
12
|
+
attrs = field.widget.attrs
|
|
13
|
+
|
|
14
|
+
if attrs.get("dir"):
|
|
15
|
+
continue
|
|
16
|
+
|
|
17
|
+
direction = resolve_direction(field, field_name, model=model)
|
|
18
|
+
if direction is None:
|
|
19
|
+
continue
|
|
20
|
+
|
|
21
|
+
attrs["dir"] = direction
|
|
22
|
+
css_class = attrs.get("class", "")
|
|
23
|
+
marker = "bidi-input-{}".format(direction)
|
|
24
|
+
if marker not in css_class.split():
|
|
25
|
+
attrs["class"] = "{} {}".format(css_class, marker).strip()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.utils.module_loading import import_string
|
|
3
|
+
|
|
4
|
+
DEFAULT_LTR_FIELD_PATHS = [
|
|
5
|
+
"django.forms.EmailField",
|
|
6
|
+
"django.forms.URLField",
|
|
7
|
+
"django.forms.SlugField",
|
|
8
|
+
"django.forms.UUIDField",
|
|
9
|
+
"django.forms.GenericIPAddressField",
|
|
10
|
+
"django.forms.FilePathField",
|
|
11
|
+
"django.forms.IntegerField",
|
|
12
|
+
"django.forms.FloatField",
|
|
13
|
+
"django.forms.DecimalField",
|
|
14
|
+
"django.forms.DurationField",
|
|
15
|
+
"django.forms.RegexField",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
DEFAULT_LTR_INPUT_TYPES = {
|
|
19
|
+
"email",
|
|
20
|
+
"url",
|
|
21
|
+
"number",
|
|
22
|
+
"password",
|
|
23
|
+
"tel",
|
|
24
|
+
"date",
|
|
25
|
+
"time",
|
|
26
|
+
"datetime-local",
|
|
27
|
+
"month",
|
|
28
|
+
"week",
|
|
29
|
+
"color",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
DEFAULT_LTR_AUTOCOMPLETE_VALUES = {
|
|
33
|
+
"username",
|
|
34
|
+
"email",
|
|
35
|
+
"tel",
|
|
36
|
+
"new-password",
|
|
37
|
+
"current-password",
|
|
38
|
+
"one-time-code",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _import_all(dotted_paths):
|
|
43
|
+
classes = []
|
|
44
|
+
for path in dotted_paths:
|
|
45
|
+
try:
|
|
46
|
+
classes.append(import_string(path))
|
|
47
|
+
except ImportError:
|
|
48
|
+
continue
|
|
49
|
+
return tuple(classes)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_ltr_field_classes():
|
|
53
|
+
paths = getattr(settings, "BIDI_INPUT_LTR_FIELD_CLASSES", DEFAULT_LTR_FIELD_PATHS)
|
|
54
|
+
return _import_all(paths)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_ltr_input_types():
|
|
58
|
+
return set(getattr(settings, "BIDI_INPUT_LTR_INPUT_TYPES", DEFAULT_LTR_INPUT_TYPES))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_ltr_autocomplete_values():
|
|
62
|
+
return set(
|
|
63
|
+
getattr(
|
|
64
|
+
settings,
|
|
65
|
+
"BIDI_INPUT_LTR_AUTOCOMPLETE_VALUES",
|
|
66
|
+
DEFAULT_LTR_AUTOCOMPLETE_VALUES,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_overrides():
|
|
72
|
+
return getattr(settings, "BIDI_INPUT_OVERRIDES", {})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_enabled():
|
|
76
|
+
return getattr(settings, "BIDI_INPUT_ENABLED", True)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
2
|
+
|
|
3
|
+
from . import conf
|
|
4
|
+
|
|
5
|
+
LTR = "ltr"
|
|
6
|
+
RTL = "rtl"
|
|
7
|
+
VALID_DIRECTIONS = (LTR, RTL)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _override_key(model, field_name):
|
|
11
|
+
return "{}.{}.{}".format(model._meta.app_label, model._meta.object_name, field_name)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _model_field_for(model, field_name):
|
|
15
|
+
if model is None:
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
return model._meta.get_field(field_name)
|
|
19
|
+
except FieldDoesNotExist:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_direction(form_field, field_name, model=None):
|
|
24
|
+
model_field = _model_field_for(model, field_name)
|
|
25
|
+
|
|
26
|
+
if model_field is not None:
|
|
27
|
+
direction = getattr(model_field, "direction", None)
|
|
28
|
+
if direction in VALID_DIRECTIONS:
|
|
29
|
+
return direction
|
|
30
|
+
|
|
31
|
+
if model is not None:
|
|
32
|
+
override = conf.get_overrides().get(_override_key(model, field_name))
|
|
33
|
+
if override in VALID_DIRECTIONS:
|
|
34
|
+
return override
|
|
35
|
+
|
|
36
|
+
input_type = getattr(form_field.widget, "input_type", None)
|
|
37
|
+
if input_type in conf.get_ltr_input_types():
|
|
38
|
+
return LTR
|
|
39
|
+
|
|
40
|
+
autocomplete = form_field.widget.attrs.get("autocomplete")
|
|
41
|
+
if autocomplete in conf.get_ltr_autocomplete_values():
|
|
42
|
+
return LTR
|
|
43
|
+
|
|
44
|
+
if isinstance(form_field, conf.get_ltr_field_classes()):
|
|
45
|
+
return LTR
|
|
46
|
+
|
|
47
|
+
return None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from django.forms.forms import BaseForm
|
|
2
|
+
|
|
3
|
+
from .apply import apply_directions
|
|
4
|
+
|
|
5
|
+
_PATCHED_ATTR = "_bidi_input_patched"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def install():
|
|
9
|
+
if getattr(BaseForm.__init__, _PATCHED_ATTR, False):
|
|
10
|
+
return
|
|
11
|
+
|
|
12
|
+
original_init = BaseForm.__init__
|
|
13
|
+
|
|
14
|
+
def patched_init(self, *args, **kwargs):
|
|
15
|
+
original_init(self, *args, **kwargs)
|
|
16
|
+
apply_directions(self)
|
|
17
|
+
|
|
18
|
+
setattr(patched_init, _PATCHED_ATTR, True)
|
|
19
|
+
BaseForm.__init__ = patched_init
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-bidi-input
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Force correct text direction (LTR/RTL) on Django admin and form input fields, regardless of site language.
|
|
5
|
+
Author-email: Omid Shojaee <omid.shojaee@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/omidshojaee/django-bidi-input
|
|
8
|
+
Project-URL: Issues, https://github.com/omidshojaee/django-bidi-input/issues
|
|
9
|
+
Classifier: Framework :: Django
|
|
10
|
+
Classifier: Framework :: Django :: 5.2
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: Django>=5.2
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
24
|
+
Requires-Dist: pytest-django>=4; extra == "test"
|
|
25
|
+
|
|
26
|
+
# django-bidi-input
|
|
27
|
+
|
|
28
|
+
Django doesn't let you control the text direction of individual form/admin
|
|
29
|
+
input fields — when a site's language is RTL (Farsi, Arabic, Hebrew, ...),
|
|
30
|
+
every input renders RTL, including fields where that's wrong: email, URL,
|
|
31
|
+
slug, UUID, IP address, numbers, passwords, phone numbers, etc. Only free
|
|
32
|
+
text fields should follow the page direction.
|
|
33
|
+
|
|
34
|
+
This has been an accepted Django ticket
|
|
35
|
+
([#35482](https://code.djangoproject.com/ticket/35482)) for a while with no
|
|
36
|
+
implementation yet. `django-bidi-input` fixes it today, without patching
|
|
37
|
+
Django, for both **admin** and regular **forms**.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install django-bidi-input
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
INSTALLED_APPS = [
|
|
47
|
+
...,
|
|
48
|
+
"django_bidi_input",
|
|
49
|
+
]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That's it. Every `ModelForm`, plain `Form`, and admin form in your project
|
|
53
|
+
now gets a correct `dir` attribute (and a `bidi-input-ltr`/`bidi-input-rtl`
|
|
54
|
+
CSS class as a fallback) on each field, automatically.
|
|
55
|
+
|
|
56
|
+
## How direction is decided
|
|
57
|
+
|
|
58
|
+
For each field, in order (first match wins):
|
|
59
|
+
|
|
60
|
+
1. You already set `dir` on the widget yourself — left untouched.
|
|
61
|
+
2. `direction` set directly on the model field instance:
|
|
62
|
+
```python
|
|
63
|
+
phone_number = models.CharField(max_length=32)
|
|
64
|
+
phone_number.direction = "ltr"
|
|
65
|
+
```
|
|
66
|
+
3. `BIDI_INPUT_OVERRIDES` setting, for fields you can't edit directly
|
|
67
|
+
(third-party app models, etc.):
|
|
68
|
+
```python
|
|
69
|
+
BIDI_INPUT_OVERRIDES = {
|
|
70
|
+
"myapp.Contact.phone_number": "ltr",
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
4. Built-in default, in order:
|
|
74
|
+
- widget `input_type` is `password`, `number`, `tel`, `date`, `time`, etc.
|
|
75
|
+
- widget `autocomplete` is `username`, `email`, `tel`, `new-password`,
|
|
76
|
+
`current-password`, `one-time-code`. This is what makes Django's own
|
|
77
|
+
admin/auth **login form** (`username` + `password`) render LTR, even
|
|
78
|
+
though `UsernameField` is a plain `CharField` — Django sets
|
|
79
|
+
`autocomplete="username"` on it unconditionally, including when a
|
|
80
|
+
custom user model uses `USERNAME_FIELD = "email"`.
|
|
81
|
+
- field class is `EmailField`, `URLField`, `SlugField`, `UUIDField`,
|
|
82
|
+
`GenericIPAddressField`, `FilePathField`, `IntegerField`, `FloatField`,
|
|
83
|
+
`DecimalField`, `DurationField`, `RegexField`.
|
|
84
|
+
5. Otherwise left alone — inherits the page's direction (correct for plain
|
|
85
|
+
`CharField`/`TextField`).
|
|
86
|
+
|
|
87
|
+
## Settings
|
|
88
|
+
|
|
89
|
+
| Setting | Default | Purpose |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `BIDI_INPUT_ENABLED` | `True` | Turn the whole thing off. |
|
|
92
|
+
| `BIDI_INPUT_OVERRIDES` | `{}` | `"app_label.Model.field": "ltr"/"rtl"` map. |
|
|
93
|
+
| `BIDI_INPUT_LTR_FIELD_CLASSES` | see `conf.py` | Dotted paths of extra form field classes to treat as LTR. |
|
|
94
|
+
| `BIDI_INPUT_LTR_INPUT_TYPES` | see `conf.py` | Extra widget `input_type` values to treat as LTR. |
|
|
95
|
+
| `BIDI_INPUT_LTR_AUTOCOMPLETE_VALUES` | see `conf.py` | Extra widget `autocomplete` values to treat as LTR. |
|
|
96
|
+
|
|
97
|
+
## Why this works for admin too
|
|
98
|
+
|
|
99
|
+
Django admin builds its forms as ordinary `ModelForm` subclasses via
|
|
100
|
+
`modelform_factory`. This package hooks `BaseForm.__init__` once, so admin
|
|
101
|
+
inherits the behavior automatically — no `ModelAdmin` changes needed.
|
|
102
|
+
|
|
103
|
+
## Scope
|
|
104
|
+
|
|
105
|
+
This targets the actual pain point: **Django admin and Django forms**.
|
|
106
|
+
It does not attempt to make `direction` a real `Field` kwarg (that requires
|
|
107
|
+
changing Django itself, which is what ticket #35482 is for), so
|
|
108
|
+
introspection-based tools built outside the forms API (DRF serializers,
|
|
109
|
+
etc.) won't see it.
|
|
110
|
+
|
|
111
|
+
## Running the tests
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pip install -e ".[test]"
|
|
115
|
+
pytest
|
|
116
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/django_bidi_input/__init__.py
|
|
4
|
+
src/django_bidi_input/apply.py
|
|
5
|
+
src/django_bidi_input/apps.py
|
|
6
|
+
src/django_bidi_input/conf.py
|
|
7
|
+
src/django_bidi_input/direction.py
|
|
8
|
+
src/django_bidi_input/patch.py
|
|
9
|
+
src/django_bidi_input.egg-info/PKG-INFO
|
|
10
|
+
src/django_bidi_input.egg-info/SOURCES.txt
|
|
11
|
+
src/django_bidi_input.egg-info/dependency_links.txt
|
|
12
|
+
src/django_bidi_input.egg-info/requires.txt
|
|
13
|
+
src/django_bidi_input.egg-info/top_level.txt
|
|
14
|
+
tests/test_admin.py
|
|
15
|
+
tests/test_forms.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django_bidi_input
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.contrib.admin.sites import AdminSite
|
|
3
|
+
|
|
4
|
+
from tests.testapp.admin import ContactAdmin, ProfileAdmin
|
|
5
|
+
from tests.testapp.models import Contact, Profile
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.django_db
|
|
9
|
+
def test_admin_form_gets_directions_without_any_admin_config():
|
|
10
|
+
admin = ContactAdmin(Contact, AdminSite())
|
|
11
|
+
form_class = admin.get_form(request=None)
|
|
12
|
+
form = form_class()
|
|
13
|
+
|
|
14
|
+
assert "dir" not in form.fields["full_name"].widget.attrs
|
|
15
|
+
assert form.fields["email"].widget.attrs["dir"] == "ltr"
|
|
16
|
+
assert form.fields["notes"].widget.attrs["dir"] == "rtl"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.django_db
|
|
20
|
+
def test_admin_form_picks_up_settings_override():
|
|
21
|
+
admin = ProfileAdmin(Profile, AdminSite())
|
|
22
|
+
form_class = admin.get_form(request=None)
|
|
23
|
+
form = form_class()
|
|
24
|
+
|
|
25
|
+
assert form.fields["bio"].widget.attrs["dir"] == "ltr"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from tests.testapp.models import Contact, Profile
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ContactForm(forms.ModelForm):
|
|
8
|
+
class Meta:
|
|
9
|
+
model = Contact
|
|
10
|
+
fields = [
|
|
11
|
+
"full_name",
|
|
12
|
+
"email",
|
|
13
|
+
"website",
|
|
14
|
+
"slug",
|
|
15
|
+
"reference_id",
|
|
16
|
+
"ip_address",
|
|
17
|
+
"age",
|
|
18
|
+
"notes",
|
|
19
|
+
"phone_number",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProfileForm(forms.ModelForm):
|
|
24
|
+
class Meta:
|
|
25
|
+
model = Profile
|
|
26
|
+
fields = ["bio"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PlainForm(forms.Form):
|
|
30
|
+
subject = forms.CharField()
|
|
31
|
+
contact_email = forms.EmailField()
|
|
32
|
+
password = forms.CharField(widget=forms.PasswordInput)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.django_db
|
|
36
|
+
def test_plain_text_field_is_left_alone():
|
|
37
|
+
form = ContactForm()
|
|
38
|
+
assert "dir" not in form.fields["full_name"].widget.attrs
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.django_db
|
|
42
|
+
@pytest.mark.parametrize(
|
|
43
|
+
"field_name",
|
|
44
|
+
["email", "website", "slug", "reference_id", "ip_address", "age"],
|
|
45
|
+
)
|
|
46
|
+
def test_builtin_heuristic_defaults_to_ltr(field_name):
|
|
47
|
+
form = ContactForm()
|
|
48
|
+
assert form.fields[field_name].widget.attrs["dir"] == "ltr"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.django_db
|
|
52
|
+
def test_explicit_model_field_override_rtl():
|
|
53
|
+
form = ContactForm()
|
|
54
|
+
assert form.fields["notes"].widget.attrs["dir"] == "rtl"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.django_db
|
|
58
|
+
def test_explicit_model_field_override_ltr_for_plain_charfield():
|
|
59
|
+
# phone_number is a bare CharField; only the .direction attribute
|
|
60
|
+
# set on the model field in models.py makes it LTR.
|
|
61
|
+
form = ContactForm()
|
|
62
|
+
assert form.fields["phone_number"].widget.attrs["dir"] == "ltr"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.django_db
|
|
66
|
+
def test_settings_override_map():
|
|
67
|
+
form = ProfileForm()
|
|
68
|
+
assert form.fields["bio"].widget.attrs["dir"] == "ltr"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_plain_non_model_form_uses_class_based_heuristic():
|
|
72
|
+
form = PlainForm()
|
|
73
|
+
assert "dir" not in form.fields["subject"].widget.attrs
|
|
74
|
+
assert form.fields["contact_email"].widget.attrs["dir"] == "ltr"
|
|
75
|
+
assert form.fields["password"].widget.attrs["dir"] == "ltr"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_admin_login_form_username_and_password_are_ltr():
|
|
79
|
+
from django.contrib.auth.forms import AuthenticationForm
|
|
80
|
+
|
|
81
|
+
form = AuthenticationForm()
|
|
82
|
+
# UsernameField is a plain CharField -- only the autocomplete="username"
|
|
83
|
+
# token (set by Django itself) makes this resolve to LTR. This also
|
|
84
|
+
# covers a custom user model with USERNAME_FIELD = "email": Django's
|
|
85
|
+
# AuthenticationForm always uses UsernameField regardless of the
|
|
86
|
+
# underlying model field type, so the field-class heuristic alone
|
|
87
|
+
# would never catch it.
|
|
88
|
+
assert form.fields["username"].widget.attrs["dir"] == "ltr"
|
|
89
|
+
assert form.fields["password"].widget.attrs["dir"] == "ltr"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_developer_set_dir_is_never_overwritten():
|
|
93
|
+
class ExplicitForm(forms.Form):
|
|
94
|
+
note = forms.CharField(widget=forms.TextInput(attrs={"dir": "rtl"}))
|
|
95
|
+
|
|
96
|
+
form = ExplicitForm()
|
|
97
|
+
assert form.fields["note"].widget.attrs["dir"] == "rtl"
|