fa-console 2.0.0__py3-none-any.whl
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.
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fa-console
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Correct Persian (Farsi) console input/output for Python on Windows: automatic UTF-8 setup plus visual letter shaping and bidi for the classic console.
|
|
5
|
+
Author-email: Alireza Hosseini <alireza.hosseini@hotmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Padandish/fa-console
|
|
8
|
+
Project-URL: Repository, https://github.com/Padandish/fa-console
|
|
9
|
+
Project-URL: Issues, https://github.com/Padandish/fa-console/issues
|
|
10
|
+
Project-URL: Tech Channel (Bale), https://ble.ir/TechInsightsHub
|
|
11
|
+
Project-URL: Tech Channel (Eitaa), https://eitaa.com/TechInsightsHub
|
|
12
|
+
Project-URL: Video Tutorials (Aparat), https://aparat.com/TechInsightsHub
|
|
13
|
+
Keywords: persian,farsi,console,terminal,windows,unicode,utf-8,bidi,rtl,arabic,reshaper,cmd,codepage
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
27
|
+
Classifier: Topic :: Software Development :: Internationalization
|
|
28
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
29
|
+
Classifier: Topic :: Terminals
|
|
30
|
+
Requires-Python: >=3.8
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Provides-Extra: high-fidelity
|
|
34
|
+
Requires-Dist: arabic-reshaper>=3.0; extra == "high-fidelity"
|
|
35
|
+
Requires-Dist: python-bidi>=0.4.2; extra == "high-fidelity"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# fa-console
|
|
39
|
+
|
|
40
|
+
> **One import. Correct Persian (Farsi) in the Windows console.**
|
|
41
|
+
|
|
42
|
+
**English** | [فارسی](README.fa.md)
|
|
43
|
+
|
|
44
|
+
[](https://pypi.org/project/fa-console/)
|
|
45
|
+
[](https://pypi.org/project/fa-console/)
|
|
46
|
+
[](LICENSE)
|
|
47
|
+
[](#compatibility)
|
|
48
|
+
|
|
49
|
+
`fa_console` makes Python's built-in `print()` and `input()` work correctly
|
|
50
|
+
with Persian text on Windows — **automatically**, with zero required
|
|
51
|
+
dependencies and no changes to your code beyond a single `import`.
|
|
52
|
+
|
|
53
|
+
It fixes both independent layers of the problem:
|
|
54
|
+
|
|
55
|
+
| Layer | Problem without `fa_console` | With `fa_console` |
|
|
56
|
+
|-------|------------------------------|-------------------|
|
|
57
|
+
| **Encoding** | Legacy console code page (720 / 1252 / …) mangles UTF-8 on redirect or piping; crashes with `UnicodeEncodeError` | Console code page switched to UTF-8 (65001); standard streams reconfigured to UTF-8; child processes pinned via `PYTHONUTF8` |
|
|
58
|
+
| **Rendering** | Classic console host (`conhost`) has no Arabic shaping and no bidirectional reordering — correct text is *drawn* disconnected and backwards | Every write is converted to visual order (letter shaping + bidi) so text appears joined and right-to-left. While typing, `fa_input` echoes correctly in real time |
|
|
59
|
+
|
|
60
|
+
**Before**
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
>>> print("سلام دنیا")
|
|
64
|
+
ﺎﯿﻧﺩ ﻡﻼﺳ # disconnected, reversed, unreadable
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**After**
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
>>> import fa_console
|
|
71
|
+
>>> print("سلام دنیا")
|
|
72
|
+
سلام دنیا # joined, right-to-left, readable
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Features
|
|
76
|
+
|
|
77
|
+
- ✅ **One-line setup** — `import fa_console` configures everything at import time
|
|
78
|
+
- ✅ **Encoding layer** — UTF-8 code page, UTF-8 `sys.stdin`/`sys.stdout`/`sys.stderr`
|
|
79
|
+
(neutralises even a broken `PYTHONIOENCODING`), and pinned environment variables
|
|
80
|
+
for spawned subprocesses
|
|
81
|
+
- ✅ **Rendering layer** — logical→visual transformation (letter shaping into
|
|
82
|
+
Unicode Arabic Presentation Forms + bidirectional reordering) applied to *all*
|
|
83
|
+
output, including plain `print()` and f-strings
|
|
84
|
+
- ✅ **Two transformation backends** — uses `arabic-reshaper` + `python-bidi`
|
|
85
|
+
when installed (full UAX #9, lam-alef ligatures, Persian rules); otherwise a
|
|
86
|
+
built-in, dependency-free engine
|
|
87
|
+
- ✅ **Live-typing echo** — `fa_input()` renders letters joined and right-to-left
|
|
88
|
+
*while you type*, with built-in-`input()`-compatible semantics
|
|
89
|
+
(`KeyboardInterrupt` / `EOFError`)
|
|
90
|
+
- ✅ **Smart detection** — Windows Terminal, VS Code-style terminals, IDEs and
|
|
91
|
+
redirected files/pipes are detected and left untouched (standard text flows
|
|
92
|
+
there, as it should)
|
|
93
|
+
- ✅ **Fail-open design** — the module never crashes the host application; every
|
|
94
|
+
internal failure is logged through the `fa_console` logger and degrades
|
|
95
|
+
gracefully to standard Python behaviour
|
|
96
|
+
- ✅ **Zero required dependencies** — pure standard library, Python 3.8+
|
|
97
|
+
|
|
98
|
+
## Installation
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install fa-console
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Optional, higher-fidelity rendering backend:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pip install "fa-console[high-fidelity]" # arabic-reshaper + python-bidi
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or simply copy `fa_console.py` into your project — it is intentionally a
|
|
111
|
+
single, self-contained module.
|
|
112
|
+
|
|
113
|
+
## Quick start
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import fa_console # everything is configured here
|
|
117
|
+
|
|
118
|
+
print("سلام دنیا!") # renders correctly everywhere
|
|
119
|
+
name = fa_console.fa_input("نام شما: ") # live, correct echo while typing
|
|
120
|
+
print(f"سلام، {name} جان!") # logical data, correct display
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The value returned by `input()` / `fa_input()` is always **logical-order**
|
|
124
|
+
Unicode: comparisons, `len()`, slicing, regexes and file writes all see plain,
|
|
125
|
+
correct Persian text. Visual transformation is a display-time-only concern.
|
|
126
|
+
|
|
127
|
+
## API overview
|
|
128
|
+
|
|
129
|
+
| Member | Purpose |
|
|
130
|
+
|--------|---------|
|
|
131
|
+
| `setup_console(force=False)` | Apply the full configuration; idempotent. Returns a `ConsoleSetupReport` |
|
|
132
|
+
| `fa_print(*args, **kwargs)` | Persian-safe drop-in for `print()` |
|
|
133
|
+
| `fa_input(prompt="")` | Persian-safe `input()` with live correct echo |
|
|
134
|
+
| `is_visual_mode()` | `True` when the display transform is active |
|
|
135
|
+
| `get_console_info()` | Dict snapshot of the detected environment (great for bug reports) |
|
|
136
|
+
| `VisualStream` | The transparent stream wrapper (advanced use) |
|
|
137
|
+
| `FaConsoleError` | Deliberate low-level failures (consumed internally — fail-open) |
|
|
138
|
+
|
|
139
|
+
### Self-diagnostics
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
python fa_console.py
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Prints a full environment report: encodings, console code page, detected
|
|
146
|
+
terminal type, active bidi backend and a rendered sample.
|
|
147
|
+
|
|
148
|
+
## How it works
|
|
149
|
+
|
|
150
|
+
1. **Encoding layer** — `SetConsoleCP`/`SetConsoleOutputCP` (Win32) switch the
|
|
151
|
+
console to UTF-8; `sys.stdin`/`sys.stdout`/`sys.stderr` are reconfigured to
|
|
152
|
+
UTF-8; `PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8` are pinned for child
|
|
153
|
+
processes.
|
|
154
|
+
2. **Rendering layer** — on classic `conhost` only, a `VisualStream` wrapper is
|
|
155
|
+
installed on stdout/stderr. Each write is converted: Arabic/Persian letters
|
|
156
|
+
are replaced by their contextual presentation forms (isolated / initial /
|
|
157
|
+
medial / final, including پ چ ژ ک گ ی), then the line is reordered for an
|
|
158
|
+
LTR-drawing terminal while Latin words and numbers stay upright and brackets
|
|
159
|
+
are mirrored.
|
|
160
|
+
3. **Detection** — the transform engages only when stdout is an interactive
|
|
161
|
+
classic console. Windows Terminal (`WT_SESSION`), VS Code terminals
|
|
162
|
+
(`TERM_PROGRAM`) and redirected output are bypassed, because they render
|
|
163
|
+
Arabic correctly or must receive standard logical text.
|
|
164
|
+
|
|
165
|
+
## Configuration
|
|
166
|
+
|
|
167
|
+
| Environment variable | Effect |
|
|
168
|
+
|----------------------|--------|
|
|
169
|
+
| `FA_CONSOLE_FORCE_VISUAL=1` | Force the visual transform on (debugging) |
|
|
170
|
+
| `FA_CONSOLE_NO_VISUAL=1` | Unconditionally disable the visual transform |
|
|
171
|
+
|
|
172
|
+
## Compatibility
|
|
173
|
+
|
|
174
|
+
| Environment | Behaviour |
|
|
175
|
+
|-------------|-----------|
|
|
176
|
+
| `cmd.exe` / standalone PowerShell (conhost) | Encoding fixed + visual transform + live typing echo |
|
|
177
|
+
| Windows Terminal | Encoding fixed; native rendering (module steps aside) |
|
|
178
|
+
| VS Code / JetBrains / IDLE consoles | Encoding fixed; module steps aside |
|
|
179
|
+
| Output redirected to file / pipe | Standard logical UTF-8 — safe for other tools |
|
|
180
|
+
| Linux / macOS | Safe no-op (standard behaviour) |
|
|
181
|
+
|
|
182
|
+
## Limitations & FAQ
|
|
183
|
+
|
|
184
|
+
- **Letters look disconnected while I type in `cmd`.** `fa_input()` redraws the
|
|
185
|
+
line correctly as you type; the console's own echo of system prompts is
|
|
186
|
+
outside any program's control.
|
|
187
|
+
- **Perfect native rendering?** Use [Windows Terminal](https://aka.ms/terminal)
|
|
188
|
+
— the module detects it and lets it do the shaping.
|
|
189
|
+
- **Does the transform corrupt my data?** No — it is display-only. Values,
|
|
190
|
+
comparisons and files always hold logical text.
|
|
191
|
+
- **Other RTL languages?** The shaping tables cover Arabic and Persian letters;
|
|
192
|
+
the engine is language-agnostic for the letters it knows.
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
git clone https://github.com/Padandish/fa-console.git
|
|
198
|
+
cd fa-console
|
|
199
|
+
python -m pip install build
|
|
200
|
+
python -m build # creates dist/
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Run the interactive example in any console:
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
python example.py
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Run the full environment diagnostics:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
python fa_console.py
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Author
|
|
216
|
+
|
|
217
|
+
**Alireza Hosseini** — [alireza.hosseini@hotmail.com](mailto:alireza.hosseini@hotmail.com)
|
|
218
|
+
|
|
219
|
+
### Tech channels
|
|
220
|
+
|
|
221
|
+
Follow **TechInsightsHub** for updates, tutorials and related projects:
|
|
222
|
+
|
|
223
|
+
| Platform | Link |
|
|
224
|
+
|----------|------|
|
|
225
|
+
| Bale | [ble.ir/TechInsightsHub](https://ble.ir/TechInsightsHub) |
|
|
226
|
+
| Eitaa | [eitaa.com/TechInsightsHub](https://eitaa.com/TechInsightsHub) |
|
|
227
|
+
| Aparat | [aparat.com/TechInsightsHub](https://aparat.com/TechInsightsHub) |
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
[MIT](LICENSE) © Alireza Hosseini
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
fa_console.py,sha256=K79zSx5N7cE17NTwNlS1sZUlIVpQD--CgOYVPAbdYcE,41302
|
|
2
|
+
fa_console-2.0.0.dist-info/licenses/LICENSE,sha256=6BXxmrMvrdeUZ3YSlskyvo_JhEiezQRa6iHwxs7V-Z4,1073
|
|
3
|
+
fa_console-2.0.0.dist-info/METADATA,sha256=4USIg_ETIwTiWSaCjDgc-EpthJI9FBUwksuhz9HVlU4,9735
|
|
4
|
+
fa_console-2.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
fa_console-2.0.0.dist-info/top_level.txt,sha256=qGOvfS8CVBqEE-zzrHE_yALBIbHVGi56Hoi91m8If1w,11
|
|
6
|
+
fa_console-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alireza Hosseini
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fa_console
|
fa_console.py
ADDED
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
fa_console — Correct Persian (Farsi) console I/O for Python on Windows
|
|
4
|
+
======================================================================
|
|
5
|
+
|
|
6
|
+
:Author: Alireza Hosseini <alireza.hosseini@hotmail.com>
|
|
7
|
+
:Social: Bale https://ble.ir/TechInsightsHub
|
|
8
|
+
Eitaa https://eitaa.com/TechInsightsHub
|
|
9
|
+
Aparat https://aparat.com/TechInsightsHub
|
|
10
|
+
:Version: 2.0.0
|
|
11
|
+
:Platform: Windows (fully functional) / POSIX (safe no-op)
|
|
12
|
+
:Requires: Python 3.8+
|
|
13
|
+
|
|
14
|
+
Overview
|
|
15
|
+
--------
|
|
16
|
+
|
|
17
|
+
Persian text in the classic Windows console fails in two independent
|
|
18
|
+
layers, and this module fixes both — automatically, with a single
|
|
19
|
+
``import``:
|
|
20
|
+
|
|
21
|
+
**Layer 1 — Encoding.** The default console code page on Windows is a
|
|
22
|
+
legacy legacy page (e.g. 720, 850, 1252), not UTF-8. Whenever a stream is
|
|
23
|
+
redirected or piped, Python decodes/encodes bytes with that legacy codec,
|
|
24
|
+
producing mojibake or raising ``UnicodeEncodeError``.
|
|
25
|
+
|
|
26
|
+
*Fix:* the console code page is switched to UTF-8 (65001) via the Win32
|
|
27
|
+
API, ``sys.stdin`` / ``sys.stdout`` / ``sys.stderr`` are reconfigured to
|
|
28
|
+
UTF-8 (neutralising even a broken ``PYTHONIOENCODING``), and the child-
|
|
29
|
+
process environment variables ``PYTHONUTF8`` / ``PYTHONIOENCODING`` are
|
|
30
|
+
pinned so spawned interpreters behave identically.
|
|
31
|
+
|
|
32
|
+
**Layer 2 — Rendering.** The classic Windows console host (``conhost`` —
|
|
33
|
+
the engine behind ``cmd.exe`` and the standalone PowerShell window) has
|
|
34
|
+
*no* Arabic script shaping and *no* bidirectional reordering. Logically
|
|
35
|
+
correct text is therefore *drawn* disconnected and left-to-right, i.e.
|
|
36
|
+
backwards and unreadable, even though the underlying data is perfect.
|
|
37
|
+
|
|
38
|
+
*Fix:* when the process is attached to a classic console, a
|
|
39
|
+
:class:`VisualStream` wrapper is installed on ``sys.stdout`` /
|
|
40
|
+
``sys.stderr``. Every write is converted from logical order to visual
|
|
41
|
+
order (letter shaping into Unicode Arabic Presentation Forms + a
|
|
42
|
+
bidirectional line reorder) before it reaches the screen. When
|
|
43
|
+
``arabic-reshaper`` and ``python-bidi`` are installed they are used for
|
|
44
|
+
maximum fidelity; otherwise a built-in, dependency-free engine is used.
|
|
45
|
+
:func:`fa_input` additionally provides a single-line editor with *live*
|
|
46
|
+
correct echo while typing.
|
|
47
|
+
|
|
48
|
+
Quick start
|
|
49
|
+
-----------
|
|
50
|
+
|
|
51
|
+
Fix everything in one line::
|
|
52
|
+
|
|
53
|
+
import fa_console # configuration applied on import
|
|
54
|
+
print("سلام دنیا") # renders correctly everywhere
|
|
55
|
+
name = fa_console.fa_input("نام: ")
|
|
56
|
+
|
|
57
|
+
Explicit API::
|
|
58
|
+
|
|
59
|
+
from fa_console import setup_console, fa_print, fa_input
|
|
60
|
+
report = setup_console() # idempotent; force=True to redo
|
|
61
|
+
fa_print("سلام", name)
|
|
62
|
+
name = fa_input("نام: ")
|
|
63
|
+
|
|
64
|
+
Programmatic inspection::
|
|
65
|
+
|
|
66
|
+
from fa_console import get_console_info, is_visual_mode
|
|
67
|
+
print(get_console_info()) # detected environment / backends
|
|
68
|
+
print(is_visual_mode()) # display transform active?
|
|
69
|
+
|
|
70
|
+
Interactive self-diagnostics::
|
|
71
|
+
|
|
72
|
+
python fa_console.py # prints a full environment report
|
|
73
|
+
|
|
74
|
+
Design contract
|
|
75
|
+
---------------
|
|
76
|
+
|
|
77
|
+
**Fail-open.** This module must never break the host application. Every
|
|
78
|
+
internal failure (missing Win32 API, no console attached, exotic stream
|
|
79
|
+
type, editor keystroke surprises, …) is caught, reported through the
|
|
80
|
+
``fa_console`` logger (a ``NullHandler`` is pre-installed, so nothing is
|
|
81
|
+
printed unless the application configures logging) and degrades to
|
|
82
|
+
standard Python behaviour. :class:`FaConsoleError` is raised only by the
|
|
83
|
+
low-level Win32 helpers and is always consumed inside :func:`setup_console`.
|
|
84
|
+
|
|
85
|
+
**Logical data invariant.** Values returned by :func:`fa_input` /
|
|
86
|
+
``input()`` are always *logical-order* Unicode. Visual transformation is
|
|
87
|
+
a *display-time-only* concern: comparisons, ``len()``, slicing, regexes
|
|
88
|
+
and file/storage writes all see plain correct Persian text. When standard
|
|
89
|
+
output is redirected to a file or pipe, no transformation is applied, so
|
|
90
|
+
downstream tools receive standards-compliant UTF-8.
|
|
91
|
+
|
|
92
|
+
Scope and limitations
|
|
93
|
+
---------------------
|
|
94
|
+
|
|
95
|
+
* The visual transform targets classic ``conhost``. Windows Terminal
|
|
96
|
+
(``WT_SESSION``) and VS Code-style terminals (``TERM_PROGRAM``) render
|
|
97
|
+
Arabic correctly by themselves; the module detects them and steps
|
|
98
|
+
aside. Escape hatches: ``FA_CONSOLE_FORCE_VISUAL=1`` forces the
|
|
99
|
+
transform on, ``FA_CONSOLE_NO_VISUAL=1`` forces it off.
|
|
100
|
+
* The live-typing editor mirrors built-in ``input()`` semantics
|
|
101
|
+
(``KeyboardInterrupt`` on Ctrl+C, ``EOFError`` on Ctrl+Z) but provides
|
|
102
|
+
no history or cursor movement; arrow keys are consumed and ignored.
|
|
103
|
+
Extremely long inputs that wrap across terminal rows redraw only the
|
|
104
|
+
last visual row.
|
|
105
|
+
* Not thread-safe beyond normal stream usage: the module mutates
|
|
106
|
+
``sys.stdin``/``sys.stdout`` once at import, which is standard practice;
|
|
107
|
+
the console editor is inherently single-threaded.
|
|
108
|
+
|
|
109
|
+
Module layout
|
|
110
|
+
-------------
|
|
111
|
+
|
|
112
|
+
1. Metadata and logging
|
|
113
|
+
2. Public exceptions and result types
|
|
114
|
+
3. Constants and environment switches
|
|
115
|
+
4. Win32 helpers (code page, console font)
|
|
116
|
+
5. UTF-8 stream reconfiguration
|
|
117
|
+
6. Visual transformation engine (shaping + bidirectional reorder)
|
|
118
|
+
7. :class:`VisualStream` — transparent stdout/stderr wrapper
|
|
119
|
+
8. :class:`_ConhostLineEditor` — live-echo line input
|
|
120
|
+
9. Public API: :func:`setup_console`, :func:`fa_print`, :func:`fa_input`,
|
|
121
|
+
:func:`is_visual_mode`, :func:`get_console_info`
|
|
122
|
+
10. Import-time auto-setup and script-mode diagnostics
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
from __future__ import annotations
|
|
126
|
+
|
|
127
|
+
import io
|
|
128
|
+
import logging
|
|
129
|
+
import os
|
|
130
|
+
import sys
|
|
131
|
+
from dataclasses import dataclass
|
|
132
|
+
from typing import Any, Iterator
|
|
133
|
+
|
|
134
|
+
# --------------------------------------------------------------------------- #
|
|
135
|
+
# 1. Module metadata and logging
|
|
136
|
+
# --------------------------------------------------------------------------- #
|
|
137
|
+
|
|
138
|
+
__author__ = "Alireza Hosseini"
|
|
139
|
+
__email__ = "alireza.hosseini@hotmail.com"
|
|
140
|
+
__version__ = "2.0.0"
|
|
141
|
+
|
|
142
|
+
__all__ = [
|
|
143
|
+
"FaConsoleError",
|
|
144
|
+
"ConsoleSetupReport",
|
|
145
|
+
"VisualStream",
|
|
146
|
+
"setup_console",
|
|
147
|
+
"fa_print",
|
|
148
|
+
"fa_input",
|
|
149
|
+
"is_visual_mode",
|
|
150
|
+
"get_console_info",
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
#: Library-scoped logger. A ``NullHandler`` is pre-installed so the module
|
|
154
|
+
#: stays silent unless the host application configures logging.
|
|
155
|
+
logger = logging.getLogger("fa_console")
|
|
156
|
+
logger.addHandler(logging.NullHandler())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --------------------------------------------------------------------------- #
|
|
160
|
+
# 2. Public exceptions and result types
|
|
161
|
+
# --------------------------------------------------------------------------- #
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class FaConsoleError(Exception):
|
|
165
|
+
"""
|
|
166
|
+
Raised by the low-level Win32 helpers when a console API call fails.
|
|
167
|
+
|
|
168
|
+
The high-level API (:func:`setup_console` and friends) never lets this
|
|
169
|
+
escape: failures are caught, logged at ``WARNING`` level through the
|
|
170
|
+
``fa_console`` logger, and reflected in :class:`ConsoleSetupReport`
|
|
171
|
+
fields as ``False``. The module then continues with standard Python
|
|
172
|
+
behaviour (fail-open contract).
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class ConsoleSetupReport:
|
|
178
|
+
"""
|
|
179
|
+
Outcome of the last :func:`setup_console` call.
|
|
180
|
+
|
|
181
|
+
Attributes:
|
|
182
|
+
codepage_set: Console input/output code page switched to UTF-8.
|
|
183
|
+
font_set: Console font switched to a TrueType face with Arabic
|
|
184
|
+
glyphs (classic consoles only; irrelevant for Windows Terminal).
|
|
185
|
+
streams_reconfigured: All present standard streams reconfigured
|
|
186
|
+
to UTF-8.
|
|
187
|
+
visual_mode: Display-time logical→visual transformation active on
|
|
188
|
+
``sys.stdout`` / ``sys.stderr``.
|
|
189
|
+
bidi_backend: ``"python-bidi"`` when the optional high-fidelity
|
|
190
|
+
libraries are used, ``"builtin"`` for the dependency-free
|
|
191
|
+
engine, or ``"none"`` when no transformation is active.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
codepage_set: bool = False
|
|
195
|
+
font_set: bool = False
|
|
196
|
+
streams_reconfigured: bool = False
|
|
197
|
+
visual_mode: bool = False
|
|
198
|
+
bidi_backend: str = "none"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --------------------------------------------------------------------------- #
|
|
202
|
+
# 3. Constants and environment switches
|
|
203
|
+
# --------------------------------------------------------------------------- #
|
|
204
|
+
|
|
205
|
+
#: Win32 code page identifier for UTF-8.
|
|
206
|
+
UTF8_CODEPAGE = 65001
|
|
207
|
+
|
|
208
|
+
#: ``GetStdHandle`` selector for the standard output device: ``(DWORD)-11``.
|
|
209
|
+
_STD_OUTPUT_HANDLE = 0xFFFF_FFF5
|
|
210
|
+
|
|
211
|
+
#: ``CONSOLE_FONT_INFOEX.FontFamily`` flag marking a TrueType font.
|
|
212
|
+
_TT_FONT = 0x04
|
|
213
|
+
|
|
214
|
+
#: TrueType font shipped with Windows that contains Arabic glyphs.
|
|
215
|
+
#: The stock raster/Consolas faces render Persian as hollow boxes.
|
|
216
|
+
_DEFAULT_FONT = "Courier New"
|
|
217
|
+
|
|
218
|
+
#: Set to ``1`` to force the visual transform even on redirect/non-Windows
|
|
219
|
+
#: (debugging escape hatch).
|
|
220
|
+
ENV_FORCE_VISUAL = "FA_CONSOLE_FORCE_VISUAL"
|
|
221
|
+
|
|
222
|
+
#: Set to ``1`` to unconditionally disable the visual transform.
|
|
223
|
+
ENV_NO_VISUAL = "FA_CONSOLE_NO_VISUAL"
|
|
224
|
+
|
|
225
|
+
# --------------------------- Arabic shaping tables -------------------------- #
|
|
226
|
+
#
|
|
227
|
+
# Minimal shaping tables covering the Arabic block plus the extra letters
|
|
228
|
+
# required for Persian (پ چ ژ ک گ ی). Values are Unicode Arabic Presentation
|
|
229
|
+
# Forms code points: (isolated, final, initial, medial).
|
|
230
|
+
#
|
|
231
|
+
# ``_DUAL_FORMS`` — letters that connect on both sides.
|
|
232
|
+
# ``_RIGHT_FORMS`` — letters that only connect to the *preceding* letter.
|
|
233
|
+
|
|
234
|
+
_DUAL_FORMS: dict[int, tuple[int, int, int, int]] = {
|
|
235
|
+
0x0626: (0xFE89, 0xFE8A, 0xFE8B, 0xFE8C), # ئ
|
|
236
|
+
0x0628: (0xFE8F, 0xFE90, 0xFE91, 0xFE92), # ب
|
|
237
|
+
0x062A: (0xFE95, 0xFE96, 0xFE97, 0xFE98), # ت
|
|
238
|
+
0x062B: (0xFE99, 0xFE9A, 0xFE9B, 0xFE9C), # ث
|
|
239
|
+
0x062C: (0xFE9D, 0xFE9E, 0xFE9F, 0xFEA0), # ج
|
|
240
|
+
0x062D: (0xFEA1, 0xFEA2, 0xFEA3, 0xFEA4), # ح
|
|
241
|
+
0x062E: (0xFEA5, 0xFEA6, 0xFEA7, 0xFEA8), # خ
|
|
242
|
+
0x0633: (0xFEB1, 0xFEB2, 0xFEB3, 0xFEB4), # س
|
|
243
|
+
0x0634: (0xFEB5, 0xFEB6, 0xFEB7, 0xFEB8), # ش
|
|
244
|
+
0x0635: (0xFEB9, 0xFEBA, 0xFEBB, 0xFEBC), # ص
|
|
245
|
+
0x0636: (0xFEBD, 0xFEBE, 0xFEBF, 0xFEC0), # ض
|
|
246
|
+
0x0637: (0xFEC1, 0xFEC2, 0xFEC3, 0xFEC4), # ط
|
|
247
|
+
0x0638: (0xFEC5, 0xFEC6, 0xFEC7, 0xFEC8), # ظ
|
|
248
|
+
0x0639: (0xFEC9, 0xFECA, 0xFECB, 0xFECC), # ع
|
|
249
|
+
0x063A: (0xFECD, 0xFECE, 0xFECF, 0xFED0), # غ
|
|
250
|
+
0x0641: (0xFED1, 0xFED2, 0xFED3, 0xFED4), # ف
|
|
251
|
+
0x0642: (0xFED5, 0xFED6, 0xFED7, 0xFED8), # ق
|
|
252
|
+
0x0643: (0xFED9, 0xFEDA, 0xFEDB, 0xFEDC), # ك
|
|
253
|
+
0x0644: (0xFEDD, 0xFEDE, 0xFEDF, 0xFEE0), # ل
|
|
254
|
+
0x0645: (0xFEE1, 0xFEE2, 0xFEE3, 0xFEE4), # م
|
|
255
|
+
0x0646: (0xFEE5, 0xFEE6, 0xFEE7, 0xFEE8), # ن
|
|
256
|
+
0x0647: (0xFEE9, 0xFEEA, 0xFEEB, 0xFEEC), # ه
|
|
257
|
+
0x064A: (0xFEF1, 0xFEF2, 0xFEF3, 0xFEF4), # ي
|
|
258
|
+
0x067E: (0xFB56, 0xFB57, 0xFB58, 0xFB59), # پ
|
|
259
|
+
0x0686: (0xFB7A, 0xFB7B, 0xFB7C, 0xFB7D), # چ
|
|
260
|
+
0x06A9: (0xFB8E, 0xFB8F, 0xFB90, 0xFB91), # ک
|
|
261
|
+
0x06AF: (0xFB92, 0xFB93, 0xFB94, 0xFB95), # گ
|
|
262
|
+
0x06CC: (0xFBFC, 0xFBFD, 0xFBFE, 0xFBFF), # ی
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_RIGHT_FORMS: dict[int, tuple[int, int | None]] = {
|
|
266
|
+
0x0621: (0xFE80, None), # ء (joins on neither side)
|
|
267
|
+
0x0622: (0xFE81, 0xFE82), # آ
|
|
268
|
+
0x0623: (0xFE83, 0xFE84), # أ
|
|
269
|
+
0x0624: (0xFE85, 0xFE86), # ؤ
|
|
270
|
+
0x0625: (0xFE87, 0xFE88), # إ
|
|
271
|
+
0x0627: (0xFE8D, 0xFE8E), # ا
|
|
272
|
+
0x0629: (0xFE93, 0xFE94), # ة
|
|
273
|
+
0x062F: (0xFEA9, 0xFEAA), # د
|
|
274
|
+
0x0630: (0xFEAB, 0xFEAC), # ذ
|
|
275
|
+
0x0631: (0xFEAD, 0xFEAE), # ر
|
|
276
|
+
0x0632: (0xFEAF, 0xFEB0), # ز
|
|
277
|
+
0x0648: (0xFEED, 0xFEEE), # و
|
|
278
|
+
0x0649: (0xFEEF, 0xFEF0), # ى
|
|
279
|
+
0x0698: (0xFB8A, 0xFB8B), # ژ
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#: Arabic tatweel/kashida — a dual-joining letter whose forms are itself.
|
|
283
|
+
_TATWEEL = 0x0640
|
|
284
|
+
|
|
285
|
+
#: Zero-width non-joiner: breaks shaping across it, invisible in output.
|
|
286
|
+
_ZWNJ = 0x200C
|
|
287
|
+
|
|
288
|
+
#: Combining marks (diacritics) are shaping-transparent.
|
|
289
|
+
_TRANSPARENT = frozenset(range(0x064B, 0x0660)) | {0x0670}
|
|
290
|
+
|
|
291
|
+
#: Unicode ranges containing RTL letters and Arabic presentation forms.
|
|
292
|
+
_RTL_RANGES = (
|
|
293
|
+
(0x0600, 0x06FF),
|
|
294
|
+
(0x0750, 0x077F),
|
|
295
|
+
(0x08A0, 0x08FF),
|
|
296
|
+
(0xFB50, 0xFDFF),
|
|
297
|
+
(0xFE70, 0xFEFF),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
#: Arabic-Indic and Persian digits. They live inside an RTL block but flow
|
|
301
|
+
#: left-to-right as a number, so they are treated as LTR tokens.
|
|
302
|
+
_ARABIC_DIGITS = frozenset(range(0x0660, 0x066A)) | frozenset(range(0x06F0, 0x06FA))
|
|
303
|
+
|
|
304
|
+
#: Bracket mirroring applied during visual reordering (Unicode Bidi rule L4).
|
|
305
|
+
_MIRROR = {"(": ")", ")": "(", "[": "]", "]": "[", "{": "}", "}": "{"}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# --------------------------------------------------------------------------- #
|
|
309
|
+
# 4. Win32 helpers
|
|
310
|
+
# --------------------------------------------------------------------------- #
|
|
311
|
+
|
|
312
|
+
_kernel32_cache: Any = None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _get_kernel32() -> Any:
|
|
316
|
+
"""Return the loaded ``kernel32`` DLL, or ``None`` off-Windows."""
|
|
317
|
+
global _kernel32_cache
|
|
318
|
+
if _kernel32_cache is None and os.name == "nt":
|
|
319
|
+
import ctypes
|
|
320
|
+
from ctypes import wintypes # noqa: F401 (ensures syscall types ready)
|
|
321
|
+
|
|
322
|
+
_kernel32_cache = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
323
|
+
return _kernel32_cache
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _set_console_codepage() -> bool:
|
|
327
|
+
"""
|
|
328
|
+
Switch the attached console's input and output code page to UTF-8.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
``True`` on success, ``False`` when no console is attached.
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
FaConsoleError: If the console reports failure for the switch.
|
|
335
|
+
"""
|
|
336
|
+
import ctypes
|
|
337
|
+
|
|
338
|
+
k32 = _get_kernel32()
|
|
339
|
+
if k32 is None:
|
|
340
|
+
return False
|
|
341
|
+
k32.SetConsoleOutputCP.argtypes = [ctypes.c_uint]
|
|
342
|
+
k32.SetConsoleCP.argtypes = [ctypes.c_uint]
|
|
343
|
+
k32.SetConsoleOutputCP.restype = ctypes.c_int
|
|
344
|
+
k32.SetConsoleCP.restype = ctypes.c_int
|
|
345
|
+
|
|
346
|
+
out_ok = k32.SetConsoleOutputCP(UTF8_CODEPAGE)
|
|
347
|
+
in_ok = k32.SetConsoleCP(UTF8_CODEPAGE)
|
|
348
|
+
if not (out_ok and in_ok):
|
|
349
|
+
raise FaConsoleError(
|
|
350
|
+
f"SetConsoleCP/SetConsoleOutputCP failed "
|
|
351
|
+
f"(GetLastError={ctypes.get_last_error()})"
|
|
352
|
+
)
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _set_console_font(face: str = _DEFAULT_FONT) -> bool:
|
|
357
|
+
"""
|
|
358
|
+
Point the classic console at a TrueType font containing Arabic glyphs.
|
|
359
|
+
|
|
360
|
+
This is a best-effort cosmetic fix: raster fonts and Consolas render
|
|
361
|
+
Persian letters as hollow boxes. Skipped entirely on modern terminals
|
|
362
|
+
(Windows Terminal / VS Code), which manage their own fonts.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
``True`` if the font was changed, ``False`` when skipped or when no
|
|
366
|
+
console is attached.
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
FaConsoleError: If the API call fails on an attached console.
|
|
370
|
+
"""
|
|
371
|
+
import ctypes
|
|
372
|
+
from ctypes import wintypes
|
|
373
|
+
|
|
374
|
+
if _modern_terminal():
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
k32 = _get_kernel32()
|
|
378
|
+
if k32 is None:
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
k32.GetStdHandle.restype = ctypes.c_void_p
|
|
382
|
+
k32.GetStdHandle.argtypes = [wintypes.DWORD]
|
|
383
|
+
k32.GetConsoleMode.restype = wintypes.BOOL
|
|
384
|
+
k32.GetConsoleMode.argtypes = [ctypes.c_void_p, ctypes.POINTER(wintypes.DWORD)]
|
|
385
|
+
k32.SetCurrentConsoleFontEx.restype = wintypes.BOOL
|
|
386
|
+
k32.SetCurrentConsoleFontEx.argtypes = [
|
|
387
|
+
ctypes.c_void_p,
|
|
388
|
+
wintypes.BOOL,
|
|
389
|
+
ctypes.c_void_p,
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
handle = k32.GetStdHandle(_STD_OUTPUT_HANDLE)
|
|
393
|
+
mode = wintypes.DWORD()
|
|
394
|
+
if not handle or not k32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
395
|
+
return False # no interactive console attached (file/pipe output)
|
|
396
|
+
|
|
397
|
+
class COORD(ctypes.Structure):
|
|
398
|
+
_fields_ = [("X", wintypes.SHORT), ("Y", wintypes.SHORT)]
|
|
399
|
+
|
|
400
|
+
class CONSOLE_FONT_INFO_EX(ctypes.Structure):
|
|
401
|
+
_fields_ = [
|
|
402
|
+
("cbSize", wintypes.ULONG),
|
|
403
|
+
("nFont", wintypes.DWORD),
|
|
404
|
+
("dwFontSize", COORD),
|
|
405
|
+
("FontFamily", wintypes.UINT),
|
|
406
|
+
("FontWeight", wintypes.UINT),
|
|
407
|
+
("FaceName", wintypes.WCHAR * 32),
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
cfi = CONSOLE_FONT_INFO_EX()
|
|
411
|
+
cfi.cbSize = ctypes.sizeof(CONSOLE_FONT_INFO_EX)
|
|
412
|
+
cfi.dwFontSize.Y = 16
|
|
413
|
+
cfi.FontFamily = _TT_FONT
|
|
414
|
+
cfi.FontWeight = 400
|
|
415
|
+
cfi.FaceName = face
|
|
416
|
+
if not k32.SetCurrentConsoleFontEx(handle, False, ctypes.byref(cfi)):
|
|
417
|
+
raise FaConsoleError(
|
|
418
|
+
f"SetCurrentConsoleFontEx({face!r}) failed "
|
|
419
|
+
f"(GetLastError={ctypes.get_last_error()})"
|
|
420
|
+
)
|
|
421
|
+
return True
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _get_console_output_codepage() -> int | None:
|
|
425
|
+
"""Current console output code page, or ``None`` if unavailable."""
|
|
426
|
+
import ctypes
|
|
427
|
+
|
|
428
|
+
k32 = _get_kernel32()
|
|
429
|
+
if k32 is None:
|
|
430
|
+
return None
|
|
431
|
+
try:
|
|
432
|
+
k32.GetConsoleOutputCP.restype = ctypes.c_uint
|
|
433
|
+
return int(k32.GetConsoleOutputCP())
|
|
434
|
+
except Exception: # pragma: no cover - exotic systems
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# --------------------------------------------------------------------------- #
|
|
439
|
+
# 5. UTF-8 stream reconfiguration
|
|
440
|
+
# --------------------------------------------------------------------------- #
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _reconfigure_stream(name: str) -> bool:
|
|
444
|
+
"""
|
|
445
|
+
Force standard stream *name* (``"stdin"``/``"stdout"``/``"stderr"``)
|
|
446
|
+
to UTF-8 with lossless-for-display error handling.
|
|
447
|
+
|
|
448
|
+
Uses :meth:`io.TextIOWrapper.reconfigure` (Python ≥ 3.7) and falls back
|
|
449
|
+
to manual ``TextIOWrapper`` replacement on older interpreters.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
``True`` if the stream is present and now UTF-8.
|
|
453
|
+
"""
|
|
454
|
+
stream = getattr(sys, name, None)
|
|
455
|
+
if stream is None: # e.g. pythonw has no stdout
|
|
456
|
+
return False
|
|
457
|
+
try:
|
|
458
|
+
stream.flush()
|
|
459
|
+
except Exception: # unflushable stream — reconfigure still worth trying
|
|
460
|
+
pass
|
|
461
|
+
try:
|
|
462
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
463
|
+
return True
|
|
464
|
+
except Exception as exc:
|
|
465
|
+
logger.debug("reconfigure(%s) unavailable: %s", name, exc)
|
|
466
|
+
# Legacy fallback: replace the stream with a fresh UTF-8 TextIOWrapper.
|
|
467
|
+
try:
|
|
468
|
+
buffered = getattr(stream, "buffer", None) or getattr(stream, "raw", None)
|
|
469
|
+
if buffered is not None:
|
|
470
|
+
setattr(
|
|
471
|
+
sys, name,
|
|
472
|
+
io.TextIOWrapper(buffered, encoding="utf-8", errors="replace"),
|
|
473
|
+
)
|
|
474
|
+
return True
|
|
475
|
+
except Exception as exc:
|
|
476
|
+
logger.warning("could not reconfigure sys.%s to UTF-8: %s", name, exc)
|
|
477
|
+
return False
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _modern_terminal() -> bool:
|
|
481
|
+
"""
|
|
482
|
+
``True`` for terminals that shape and reorder Arabic text natively
|
|
483
|
+
(Windows Terminal, VS Code-style terminals). The visual transform
|
|
484
|
+
stays off there to avoid double processing.
|
|
485
|
+
"""
|
|
486
|
+
return bool(os.environ.get("WT_SESSION") or os.environ.get("TERM_PROGRAM"))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# --------------------------------------------------------------------------- #
|
|
490
|
+
# 6. Visual transformation engine
|
|
491
|
+
# --------------------------------------------------------------------------- #
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _is_rtl_letter(code: int) -> bool:
|
|
495
|
+
"""``True`` for RTL *letters* (digits excluded — they stay LTR)."""
|
|
496
|
+
return any(lo <= code <= hi for lo, hi in _RTL_RANGES) and code not in _ARABIC_DIGITS
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _has_rtl(text: str) -> bool:
|
|
500
|
+
"""Fast scan: does *text* contain at least one RTL letter?"""
|
|
501
|
+
return any(_is_rtl_letter(ord(ch)) for ch in text)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _is_ltr_token(ch: str) -> bool:
|
|
505
|
+
"""
|
|
506
|
+
``True`` for characters that keep left-to-right order inside a number
|
|
507
|
+
or Latin word: ASCII alphanumerics, Persian/Arabic digits, etc.
|
|
508
|
+
"""
|
|
509
|
+
if ord(ch) in _ARABIC_DIGITS:
|
|
510
|
+
return True
|
|
511
|
+
return ch.isalnum() and not _is_rtl_letter(ord(ch))
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class _VisualTransformer:
|
|
515
|
+
"""
|
|
516
|
+
Converts logical-order Persian/Arabic text into visually-ordered text
|
|
517
|
+
suitable for terminals with no bidi/shaping support.
|
|
518
|
+
|
|
519
|
+
Two backends, chosen lazily:
|
|
520
|
+
|
|
521
|
+
``"python-bidi"``
|
|
522
|
+
``arabic-reshaper`` + ``python-bidi`` (full UAX #9, ligatures such
|
|
523
|
+
as لا, Persian language rules). Used automatically when installed.
|
|
524
|
+
``"builtin"``
|
|
525
|
+
Dependency-free engine: joining-context letter shaping into
|
|
526
|
+
Arabic Presentation Forms plus a run-reversing line reorder that
|
|
527
|
+
keeps Latin words and numbers upright and mirrors brackets.
|
|
528
|
+
|
|
529
|
+
Both backends are wrapped in per-call exception guards; on failure the
|
|
530
|
+
input line is returned unchanged so output is never lost.
|
|
531
|
+
"""
|
|
532
|
+
|
|
533
|
+
def __init__(self) -> None:
|
|
534
|
+
self._lib_transform: Any = None
|
|
535
|
+
self._lib_checked: bool = False
|
|
536
|
+
|
|
537
|
+
# -- backend selection ---------------------------------------------------
|
|
538
|
+
|
|
539
|
+
def _load_lib(self) -> Any:
|
|
540
|
+
"""Load the optional high-fidelity backend once, if available."""
|
|
541
|
+
if not self._lib_checked:
|
|
542
|
+
self._lib_checked = True
|
|
543
|
+
try:
|
|
544
|
+
import arabic_reshaper
|
|
545
|
+
from bidi.algorithm import get_display
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
shaper = arabic_reshaper.ArabicReshaper(
|
|
549
|
+
configuration={"language": "Farsi"}
|
|
550
|
+
)
|
|
551
|
+
self._lib_transform = lambda text: get_display(shaper.reshape(text))
|
|
552
|
+
except Exception: # fall back to default reshaper config
|
|
553
|
+
self._lib_transform = lambda text: get_display(
|
|
554
|
+
arabic_reshaper.reshape(text)
|
|
555
|
+
)
|
|
556
|
+
logger.debug("visual backend: python-bidi + arabic-reshaper")
|
|
557
|
+
except Exception:
|
|
558
|
+
self._lib_transform = None
|
|
559
|
+
logger.debug("visual backend: builtin (optional libs not installed)")
|
|
560
|
+
return self._lib_transform
|
|
561
|
+
|
|
562
|
+
@property
|
|
563
|
+
def backend_name(self) -> str:
|
|
564
|
+
"""Currently selected backend: ``"python-bidi"``, ``"builtin"`` or
|
|
565
|
+
``"none"`` before first use."""
|
|
566
|
+
if not self._lib_checked:
|
|
567
|
+
self._load_lib()
|
|
568
|
+
return "python-bidi" if self._lib_transform else "builtin"
|
|
569
|
+
|
|
570
|
+
# -- shaping ---------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
@staticmethod
|
|
573
|
+
def _effective(chars: list[str], index: int, step: int) -> int | str | None:
|
|
574
|
+
"""
|
|
575
|
+
Walk to the previous (``step=-1``) or next (``step=+1``) shaping-
|
|
576
|
+
relevant character, skipping transparent marks. Returns the code
|
|
577
|
+
point, ``None`` at the boundary, or the string ``"BREAK"`` when a
|
|
578
|
+
ZWNJ terminates joining.
|
|
579
|
+
"""
|
|
580
|
+
j = index + step
|
|
581
|
+
while 0 <= j < len(chars):
|
|
582
|
+
code = ord(chars[j])
|
|
583
|
+
if code == _ZWNJ:
|
|
584
|
+
return "BREAK"
|
|
585
|
+
if code in _TRANSPARENT:
|
|
586
|
+
j += step
|
|
587
|
+
continue
|
|
588
|
+
return code
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
def shape(self, text: str) -> str:
|
|
592
|
+
"""
|
|
593
|
+
Replace each Arabic/Persian letter with the presentation form
|
|
594
|
+
(isolated / initial / medial / final) dictated by its joining
|
|
595
|
+
context. Non-Arabic characters and transparent marks pass through;
|
|
596
|
+
ZWNJ breaks joining and is removed from the output.
|
|
597
|
+
"""
|
|
598
|
+
if not _has_rtl(text):
|
|
599
|
+
return text
|
|
600
|
+
chars = list(text)
|
|
601
|
+
out: list[str] = []
|
|
602
|
+
for i, ch in enumerate(chars):
|
|
603
|
+
code = ord(ch)
|
|
604
|
+
if code == _ZWNJ:
|
|
605
|
+
continue
|
|
606
|
+
if code in _TRANSPARENT:
|
|
607
|
+
out.append(ch)
|
|
608
|
+
continue
|
|
609
|
+
if code == _TATWEEL:
|
|
610
|
+
iso = fin = ini = med = ch
|
|
611
|
+
joins_prev, joins_next = True, True
|
|
612
|
+
elif code in _DUAL_FORMS:
|
|
613
|
+
iso, fin, ini, med = _DUAL_FORMS[code]
|
|
614
|
+
joins_prev, joins_next = True, True
|
|
615
|
+
elif code in _RIGHT_FORMS:
|
|
616
|
+
iso, fin = _RIGHT_FORMS[code]
|
|
617
|
+
ini = med = None
|
|
618
|
+
joins_prev, joins_next = True, False
|
|
619
|
+
else:
|
|
620
|
+
out.append(ch)
|
|
621
|
+
continue
|
|
622
|
+
|
|
623
|
+
prev = self._effective(chars, i, -1)
|
|
624
|
+
nxt = self._effective(chars, i, +1)
|
|
625
|
+
prev_dual = isinstance(prev, int) and (
|
|
626
|
+
prev in _DUAL_FORMS or prev == _TATWEEL
|
|
627
|
+
)
|
|
628
|
+
nxt_joins = isinstance(nxt, int) and (
|
|
629
|
+
nxt in _DUAL_FORMS or nxt in _RIGHT_FORMS or nxt == _TATWEEL
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
if prev_dual and joins_next and nxt_joins and med is not None:
|
|
633
|
+
out.append(chr(med))
|
|
634
|
+
elif prev_dual and joins_prev and fin is not None:
|
|
635
|
+
out.append(chr(fin))
|
|
636
|
+
elif joins_next and nxt_joins and ini is not None:
|
|
637
|
+
out.append(chr(ini))
|
|
638
|
+
else:
|
|
639
|
+
out.append(chr(iso))
|
|
640
|
+
return "".join(out)
|
|
641
|
+
|
|
642
|
+
# -- bidirectional line reorder ---------------------------------------------
|
|
643
|
+
|
|
644
|
+
def reorder_line(self, line: str) -> str:
|
|
645
|
+
"""
|
|
646
|
+
Reorder a shaped logical line for an LTR-drawing terminal: the RTL
|
|
647
|
+
flow is reversed while maximal Latin/number runs stay upright and
|
|
648
|
+
brackets are mirrored. Control characters pass through untouched.
|
|
649
|
+
"""
|
|
650
|
+
if not _has_rtl(line):
|
|
651
|
+
return line
|
|
652
|
+
out: list[str] = []
|
|
653
|
+
cluster: list[str] = []
|
|
654
|
+
for ch in reversed(line):
|
|
655
|
+
if ord(ch) < 32: # control chars act as hard boundaries
|
|
656
|
+
if cluster:
|
|
657
|
+
out.extend(reversed(cluster))
|
|
658
|
+
cluster.clear()
|
|
659
|
+
out.append(ch)
|
|
660
|
+
elif _is_ltr_token(ch):
|
|
661
|
+
cluster.append(ch)
|
|
662
|
+
else:
|
|
663
|
+
if cluster:
|
|
664
|
+
out.extend(reversed(cluster))
|
|
665
|
+
cluster.clear()
|
|
666
|
+
out.append(_MIRROR.get(ch, ch))
|
|
667
|
+
if cluster:
|
|
668
|
+
out.extend(reversed(cluster))
|
|
669
|
+
return "".join(out)
|
|
670
|
+
|
|
671
|
+
# -- public entry points -------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
def transform_line(self, line: str) -> str:
|
|
674
|
+
"""
|
|
675
|
+
Fully transform one logical line (no ``\\n`` inside) into visual
|
|
676
|
+
order. Backend failures degrade to the input line unchanged.
|
|
677
|
+
"""
|
|
678
|
+
if not _has_rtl(line):
|
|
679
|
+
return line
|
|
680
|
+
lib = self._load_lib()
|
|
681
|
+
if lib is not None:
|
|
682
|
+
try:
|
|
683
|
+
return lib(line)
|
|
684
|
+
except Exception as exc:
|
|
685
|
+
logger.warning("python-bidi backend failed (%s); using builtin", exc)
|
|
686
|
+
try:
|
|
687
|
+
return self.reorder_line(self.shape(line))
|
|
688
|
+
except Exception as exc: # absolute last resort: never lose output
|
|
689
|
+
logger.warning("builtin visual transform failed (%s)", exc)
|
|
690
|
+
return line
|
|
691
|
+
|
|
692
|
+
def transform_text(self, text: str) -> str:
|
|
693
|
+
"""
|
|
694
|
+
Transform arbitrary text (may contain ``\\n``) line by line, so
|
|
695
|
+
newline structure survives intact.
|
|
696
|
+
"""
|
|
697
|
+
if not text:
|
|
698
|
+
return text
|
|
699
|
+
return "\n".join(self.transform_line(part) for part in text.split("\n"))
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
#: Module-wide transformer instance used by :func:`_to_visual`.
|
|
703
|
+
_transformer = _VisualTransformer()
|
|
704
|
+
|
|
705
|
+
#: Master switch: ``True`` once :func:`setup_console` decides the process is
|
|
706
|
+
#: attached to a classic console and installs :class:`VisualStream`.
|
|
707
|
+
_visual_mode = False
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _to_visual(text: str) -> str:
|
|
711
|
+
"""
|
|
712
|
+
Logical→visual conversion gated by :data:`_visual_mode`. Safe to call
|
|
713
|
+
from anywhere: returns *text* unchanged when the transform is off.
|
|
714
|
+
"""
|
|
715
|
+
if not _visual_mode or not text:
|
|
716
|
+
return text
|
|
717
|
+
try:
|
|
718
|
+
return _transformer.transform_text(text)
|
|
719
|
+
except Exception as exc: # pragma: no cover - transform_text is guarded
|
|
720
|
+
logger.warning("visual transform failed (%s); emitting logical text", exc)
|
|
721
|
+
return text
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
# --------------------------------------------------------------------------- #
|
|
725
|
+
# 7. VisualStream — transparent stdout/stderr wrapper
|
|
726
|
+
# --------------------------------------------------------------------------- #
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
class VisualStream:
|
|
730
|
+
"""
|
|
731
|
+
Write-through wrapper that converts logical-order Persian text to
|
|
732
|
+
visual order on its way to the underlying stream.
|
|
733
|
+
|
|
734
|
+
Installing this on ``sys.stdout`` is what makes *plain* ``print()``
|
|
735
|
+
(not just :func:`fa_print`) render correctly in classic consoles.
|
|
736
|
+
Everything except :meth:`write` / :meth:`writelines` is delegated to
|
|
737
|
+
the wrapped stream, so buffering, ``isatty()``, ``fileno()``,
|
|
738
|
+
encodings and context-manager usage keep working.
|
|
739
|
+
|
|
740
|
+
Write failures of the underlying stream propagate unchanged; the
|
|
741
|
+
transformation itself never raises (see :func:`_to_visual`).
|
|
742
|
+
"""
|
|
743
|
+
|
|
744
|
+
def __init__(self, stream: Any) -> None:
|
|
745
|
+
self._inner = stream
|
|
746
|
+
|
|
747
|
+
# -- transformed writes ----------------------------------------------------
|
|
748
|
+
|
|
749
|
+
def write(self, text: str) -> int:
|
|
750
|
+
"""Transform *text* (if the visual mode is on) and write it."""
|
|
751
|
+
return self._inner.write(_to_visual(text))
|
|
752
|
+
|
|
753
|
+
def writelines(self, lines: Any) -> None:
|
|
754
|
+
"""Transform and write each line; newlines must be embedded by caller."""
|
|
755
|
+
return self._inner.writelines(_to_visual(line) for line in lines)
|
|
756
|
+
|
|
757
|
+
# -- direct delegation ------------------------------------------------------
|
|
758
|
+
|
|
759
|
+
def flush(self) -> None:
|
|
760
|
+
self._inner.flush()
|
|
761
|
+
|
|
762
|
+
def isatty(self) -> bool:
|
|
763
|
+
return self._inner.isatty()
|
|
764
|
+
|
|
765
|
+
def fileno(self) -> int:
|
|
766
|
+
return self._inner.fileno()
|
|
767
|
+
|
|
768
|
+
def close(self) -> None:
|
|
769
|
+
self._inner.close()
|
|
770
|
+
|
|
771
|
+
def readable(self) -> bool:
|
|
772
|
+
return False
|
|
773
|
+
|
|
774
|
+
def writable(self) -> bool:
|
|
775
|
+
return True
|
|
776
|
+
|
|
777
|
+
def seekable(self) -> bool:
|
|
778
|
+
return False
|
|
779
|
+
|
|
780
|
+
@property
|
|
781
|
+
def encoding(self) -> str:
|
|
782
|
+
return self._inner.encoding
|
|
783
|
+
|
|
784
|
+
@property
|
|
785
|
+
def fa_inner(self) -> Any:
|
|
786
|
+
"""The wrapped stream — used by the line editor to bypass the
|
|
787
|
+
transform for already-visual content (avoids double conversion)."""
|
|
788
|
+
return self._inner
|
|
789
|
+
|
|
790
|
+
def __iter__(self) -> Iterator[str]:
|
|
791
|
+
return iter(self._inner)
|
|
792
|
+
|
|
793
|
+
def __enter__(self) -> "VisualStream":
|
|
794
|
+
return self
|
|
795
|
+
|
|
796
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
797
|
+
self._inner.close()
|
|
798
|
+
|
|
799
|
+
def __getattr__(self, name: str) -> Any:
|
|
800
|
+
# Only called for attributes not defined above; delegates the rest
|
|
801
|
+
# (detach, reconfigure, buffer, name, mode, …) to the inner stream.
|
|
802
|
+
return getattr(self._inner, name)
|
|
803
|
+
|
|
804
|
+
def __repr__(self) -> str:
|
|
805
|
+
return f"<VisualStream wrapping {self._inner!r}>"
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
# --------------------------------------------------------------------------- #
|
|
809
|
+
# 8. _ConhostLineEditor — live-echo line input for classic consoles
|
|
810
|
+
# --------------------------------------------------------------------------- #
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
class _ConhostLineEditor:
|
|
814
|
+
"""
|
|
815
|
+
Single-line editor with live *correct* Persian echo for classic
|
|
816
|
+
Windows consoles (``msvcrt.getwch()`` based).
|
|
817
|
+
|
|
818
|
+
The stock console echo draws each keystroke in logical order — i.e.
|
|
819
|
+
disconnected and backwards. This editor mutes the console echo, keeps
|
|
820
|
+
the logical buffer, and after every keystroke redraws the whole line
|
|
821
|
+
in visual order through the raw (non-transforming) stream.
|
|
822
|
+
|
|
823
|
+
Keystroke contract (mirrors built-in ``input()`` where it matters):
|
|
824
|
+
|
|
825
|
+
================ ======================================================
|
|
826
|
+
Key Behaviour
|
|
827
|
+
================ ======================================================
|
|
828
|
+
Enter / Ctrl+M Finish; return the logical text
|
|
829
|
+
Backspace (0x08) Delete last character; also accepts 0x7F
|
|
830
|
+
Ctrl+C (0x03) Raise :class:`KeyboardInterrupt`
|
|
831
|
+
Ctrl+Z (0x1A) Raise :class:`EOFError`
|
|
832
|
+
Arrows / F-keys Two-byte sequence consumed and ignored
|
|
833
|
+
Tab Ignored (avoids cursor/column misalignment)
|
|
834
|
+
anything else Appended when printable
|
|
835
|
+
================ ======================================================
|
|
836
|
+
|
|
837
|
+
Known limitation: inputs long enough to wrap across terminal rows
|
|
838
|
+
redraw only the final visual row (single-line ``\\r`` redraw).
|
|
839
|
+
"""
|
|
840
|
+
|
|
841
|
+
def __init__(self, prompt: str) -> None:
|
|
842
|
+
import msvcrt
|
|
843
|
+
|
|
844
|
+
self._msvcrt = msvcrt
|
|
845
|
+
self._prompt = prompt
|
|
846
|
+
self._prompt_visual = _to_visual(prompt)
|
|
847
|
+
self._buffer: list[str] = []
|
|
848
|
+
self._last_len = 0
|
|
849
|
+
out = sys.stdout
|
|
850
|
+
# Write pre-visualised content through the *inner* stream to bypass
|
|
851
|
+
# VisualStream and avoid a double transformation.
|
|
852
|
+
self._raw = getattr(out, "fa_inner", out)
|
|
853
|
+
|
|
854
|
+
# -- internals -----------------------------------------------------------
|
|
855
|
+
|
|
856
|
+
def _redraw(self) -> None:
|
|
857
|
+
"""Repaint ``prompt + visual(buffer)``, erasing any leftover cells."""
|
|
858
|
+
visual = _to_visual("".join(self._buffer))
|
|
859
|
+
line = self._prompt_visual + visual
|
|
860
|
+
pad = self._last_len - len(line)
|
|
861
|
+
suffix = " " * pad if pad > 0 else ""
|
|
862
|
+
self._raw.write("\r" + line + suffix + "\r" + line)
|
|
863
|
+
self._raw.flush()
|
|
864
|
+
self._last_len = len(line)
|
|
865
|
+
|
|
866
|
+
# -- public ----------------------------------------------------------------
|
|
867
|
+
|
|
868
|
+
def readline(self) -> str:
|
|
869
|
+
"""
|
|
870
|
+
Read one line. Returns the *logical-order* string typed by the user.
|
|
871
|
+
|
|
872
|
+
Raises:
|
|
873
|
+
KeyboardInterrupt: on Ctrl+C (matching built-in ``input()``).
|
|
874
|
+
EOFError: on Ctrl+Z (matching built-in ``input()``).
|
|
875
|
+
"""
|
|
876
|
+
self._raw.write(self._prompt_visual)
|
|
877
|
+
self._raw.flush()
|
|
878
|
+
while True:
|
|
879
|
+
ch = self._msvcrt.getwch()
|
|
880
|
+
if ch in ("\r", "\n"):
|
|
881
|
+
self._raw.write("\n")
|
|
882
|
+
self._raw.flush()
|
|
883
|
+
return "".join(self._buffer)
|
|
884
|
+
code = ord(ch)
|
|
885
|
+
if code in (0, 224): # arrow / function-key prefix byte
|
|
886
|
+
self._msvcrt.getwch()
|
|
887
|
+
elif ch in ("\x08", "\x7f"):
|
|
888
|
+
if self._buffer:
|
|
889
|
+
self._buffer.pop()
|
|
890
|
+
self._redraw()
|
|
891
|
+
elif ch == "\x03":
|
|
892
|
+
self._raw.write("\n")
|
|
893
|
+
raise KeyboardInterrupt
|
|
894
|
+
elif ch == "\x1a":
|
|
895
|
+
raise EOFError
|
|
896
|
+
elif ch == "\t" or not ch.isprintable():
|
|
897
|
+
continue
|
|
898
|
+
else:
|
|
899
|
+
self._buffer.append(ch)
|
|
900
|
+
self._redraw()
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
# --------------------------------------------------------------------------- #
|
|
904
|
+
# 9. Public API
|
|
905
|
+
# --------------------------------------------------------------------------- #
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def setup_console(force: bool = False) -> ConsoleSetupReport:
|
|
909
|
+
"""
|
|
910
|
+
Apply the full Persian/Unicode console configuration.
|
|
911
|
+
|
|
912
|
+
Steps (all guarded, see the fail-open contract in the module docs):
|
|
913
|
+
|
|
914
|
+
1. Pin ``PYTHONUTF8`` / ``PYTHONIOENCODING`` for child processes.
|
|
915
|
+
2. Windows only: switch the console code page to UTF-8 and select a
|
|
916
|
+
TrueType font with Arabic glyphs (classic consoles).
|
|
917
|
+
3. Reconfigure ``sys.stdin`` / ``sys.stdout`` / ``sys.stderr`` to UTF-8.
|
|
918
|
+
4. Classic-console only: install :class:`VisualStream` on stdout and
|
|
919
|
+
stderr so *every* write is displayed shaped and right-to-left.
|
|
920
|
+
|
|
921
|
+
Args:
|
|
922
|
+
force: Re-run the configuration even if it already ran (idempotent
|
|
923
|
+
by default). The visual stream wrapper is only installed once;
|
|
924
|
+
with ``force=True`` an existing wrapper is reused, never nested.
|
|
925
|
+
|
|
926
|
+
Returns:
|
|
927
|
+
:class:`ConsoleSetupReport` describing what was applied.
|
|
928
|
+
"""
|
|
929
|
+
global _setup_done, _visual_mode, _last_report
|
|
930
|
+
|
|
931
|
+
if _setup_done and not force:
|
|
932
|
+
return _last_report
|
|
933
|
+
|
|
934
|
+
# 1) Environment for child processes.
|
|
935
|
+
os.environ["PYTHONIOENCODING"] = "utf-8"
|
|
936
|
+
os.environ["PYTHONUTF8"] = "1"
|
|
937
|
+
|
|
938
|
+
codepage_set = font_set = streams_ok = False
|
|
939
|
+
|
|
940
|
+
# 2) Windows console specifics.
|
|
941
|
+
if _is_windows():
|
|
942
|
+
try:
|
|
943
|
+
codepage_set = _set_console_codepage()
|
|
944
|
+
except FaConsoleError as exc:
|
|
945
|
+
logger.warning("code page switch to UTF-8 skipped: %s", exc)
|
|
946
|
+
except Exception as exc: # unexpected — still non-fatal
|
|
947
|
+
logger.warning("code page switch raised unexpectedly: %s", exc)
|
|
948
|
+
try:
|
|
949
|
+
font_set = _set_console_font()
|
|
950
|
+
except FaConsoleError as exc:
|
|
951
|
+
logger.warning("console font switch skipped: %s", exc)
|
|
952
|
+
except Exception as exc:
|
|
953
|
+
logger.warning("console font switch raised unexpectedly: %s", exc)
|
|
954
|
+
|
|
955
|
+
# 3) UTF-8 standard streams.
|
|
956
|
+
streams_ok = all(_reconfigure_stream(name) for name in ("stdin", "stdout", "stderr"))
|
|
957
|
+
|
|
958
|
+
# 4) Display transform for consoles without Arabic shaping.
|
|
959
|
+
visual = False
|
|
960
|
+
if sys.stdout is not None:
|
|
961
|
+
if os.environ.get(ENV_NO_VISUAL):
|
|
962
|
+
visual = False
|
|
963
|
+
logger.debug("visual transform disabled via %s", ENV_NO_VISUAL)
|
|
964
|
+
elif os.environ.get(ENV_FORCE_VISUAL):
|
|
965
|
+
visual = True
|
|
966
|
+
logger.debug("visual transform forced via %s", ENV_FORCE_VISUAL)
|
|
967
|
+
elif _is_windows() and not _modern_terminal():
|
|
968
|
+
try:
|
|
969
|
+
visual = bool(sys.stdout.isatty())
|
|
970
|
+
except Exception:
|
|
971
|
+
visual = False
|
|
972
|
+
if visual and not isinstance(sys.stdout, VisualStream):
|
|
973
|
+
sys.stdout = VisualStream(sys.stdout)
|
|
974
|
+
if sys.stderr is not None and not isinstance(sys.stderr, VisualStream):
|
|
975
|
+
sys.stderr = VisualStream(sys.stderr)
|
|
976
|
+
|
|
977
|
+
_visual_mode = visual
|
|
978
|
+
_last_report = ConsoleSetupReport(
|
|
979
|
+
codepage_set=codepage_set,
|
|
980
|
+
font_set=font_set,
|
|
981
|
+
streams_reconfigured=streams_ok,
|
|
982
|
+
visual_mode=visual,
|
|
983
|
+
bidi_backend=_transformer.backend_name if visual else "none",
|
|
984
|
+
)
|
|
985
|
+
_setup_done = True
|
|
986
|
+
logger.info("setup complete: %s", _last_report)
|
|
987
|
+
return _last_report
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def fa_print(*values: Any, sep: str = " ", end: str = "\n",
|
|
991
|
+
file: Any = None, flush: bool = False) -> None:
|
|
992
|
+
"""
|
|
993
|
+
Persian-safe :func:`print` drop-in.
|
|
994
|
+
|
|
995
|
+
Transformation happens inside the installed :class:`VisualStream`, so
|
|
996
|
+
this is a pure pass-through to :func:`print`; it exists for API
|
|
997
|
+
symmetry and for explicitness in application code.
|
|
998
|
+
"""
|
|
999
|
+
print(*values, sep=sep, end=end, file=file, flush=flush)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def fa_input(prompt: str = "") -> str:
|
|
1003
|
+
"""
|
|
1004
|
+
Persian-safe :func:`input` replacement.
|
|
1005
|
+
|
|
1006
|
+
On classic consoles the prompt is written through the visual stream
|
|
1007
|
+
and a :class:`_ConhostLineEditor` provides live, correctly-rendered
|
|
1008
|
+
echo while typing. Everywhere else (Windows Terminal, IDEs, piped
|
|
1009
|
+
stdin) it degrades to plain ``print(prompt)`` + ``input()``.
|
|
1010
|
+
|
|
1011
|
+
Args:
|
|
1012
|
+
prompt: Prompt text in logical order; rendered correctly.
|
|
1013
|
+
|
|
1014
|
+
Returns:
|
|
1015
|
+
The *logical-order* string typed by the user.
|
|
1016
|
+
|
|
1017
|
+
Raises:
|
|
1018
|
+
KeyboardInterrupt: on Ctrl+C.
|
|
1019
|
+
EOFError: on Ctrl+Z (interactive) or closed stdin (piped).
|
|
1020
|
+
"""
|
|
1021
|
+
if _visual_mode and _is_windows() and sys.stdin is not None:
|
|
1022
|
+
try:
|
|
1023
|
+
if sys.stdin.isatty():
|
|
1024
|
+
try:
|
|
1025
|
+
return _ConhostLineEditor(prompt).readline()
|
|
1026
|
+
except (EOFError, KeyboardInterrupt):
|
|
1027
|
+
raise
|
|
1028
|
+
except Exception as exc:
|
|
1029
|
+
logger.warning("line editor unavailable (%s); using input()", exc)
|
|
1030
|
+
except Exception:
|
|
1031
|
+
pass # isatty probing failed — fall through to plain input()
|
|
1032
|
+
if prompt:
|
|
1033
|
+
# VisualStream (when installed) performs the display conversion.
|
|
1034
|
+
print(prompt, end="", flush=True)
|
|
1035
|
+
return input()
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def is_visual_mode() -> bool:
|
|
1039
|
+
"""``True`` when the display-time logical→visual transform is active."""
|
|
1040
|
+
return _visual_mode
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _stream_isatty(stream: Any) -> bool | None:
|
|
1044
|
+
"""``stream.isatty()`` safely; ``None`` when it cannot be probed."""
|
|
1045
|
+
try:
|
|
1046
|
+
return bool(stream.isatty()) if stream is not None else None
|
|
1047
|
+
except Exception:
|
|
1048
|
+
return None
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def get_console_info() -> dict[str, Any]:
|
|
1052
|
+
"""
|
|
1053
|
+
Return a snapshot of the detected/active console configuration.
|
|
1054
|
+
|
|
1055
|
+
Useful for bug reports and the script-mode diagnostics. Keys:
|
|
1056
|
+
``author``, ``version``, ``platform``, ``python``, ``os_name``,
|
|
1057
|
+
``stdin_encoding``, ``stdout_encoding``, ``stdout_isatty``,
|
|
1058
|
+
``console_codepage``, ``modern_terminal``, ``visual_mode``,
|
|
1059
|
+
``bidi_backend``, ``force_visual_env``, ``no_visual_env``,
|
|
1060
|
+
``last_setup_report``.
|
|
1061
|
+
"""
|
|
1062
|
+
stdout = sys.stdout
|
|
1063
|
+
return {
|
|
1064
|
+
"author": f"{__author__} <{__email__}>",
|
|
1065
|
+
"version": __version__,
|
|
1066
|
+
"platform": sys.platform,
|
|
1067
|
+
"python": sys.version.split()[0],
|
|
1068
|
+
"os_name": os.name,
|
|
1069
|
+
"stdin_encoding": getattr(sys.stdin, "encoding", None),
|
|
1070
|
+
"stdout_encoding": getattr(stdout, "encoding", None),
|
|
1071
|
+
"stdout_isatty": _stream_isatty(sys.stdout),
|
|
1072
|
+
"console_codepage": _get_console_output_codepage(),
|
|
1073
|
+
"modern_terminal": _modern_terminal(),
|
|
1074
|
+
"visual_mode": _visual_mode,
|
|
1075
|
+
"bidi_backend": _transformer.backend_name if _visual_mode else "none",
|
|
1076
|
+
"force_visual_env": bool(os.environ.get(ENV_FORCE_VISUAL)),
|
|
1077
|
+
"no_visual_env": bool(os.environ.get(ENV_NO_VISUAL)),
|
|
1078
|
+
"last_setup_report": _last_report,
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _is_windows() -> bool:
|
|
1083
|
+
"""``True`` on Microsoft Windows (``os.name == "nt"``)."""
|
|
1084
|
+
return os.name == "nt"
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
# --------------------------------------------------------------------------- #
|
|
1088
|
+
# 10. Import-time auto-setup and script-mode diagnostics
|
|
1089
|
+
# --------------------------------------------------------------------------- #
|
|
1090
|
+
|
|
1091
|
+
_setup_done = False
|
|
1092
|
+
_last_report = ConsoleSetupReport()
|
|
1093
|
+
|
|
1094
|
+
try:
|
|
1095
|
+
setup_console()
|
|
1096
|
+
except Exception: # absolute last resort — the import must never fail
|
|
1097
|
+
logger.exception("fa_console auto-setup failed; standard behaviour retained")
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _self_diagnostics() -> None:
|
|
1101
|
+
"""Print a human-readable report (entry point when run as a script)."""
|
|
1102
|
+
info = get_console_info()
|
|
1103
|
+
line = "=" * 64
|
|
1104
|
+
print(line)
|
|
1105
|
+
print(f"fa_console {__version__} — self diagnostics")
|
|
1106
|
+
print(line)
|
|
1107
|
+
for key, value in info.items():
|
|
1108
|
+
print(f" {key:<20}: {value}")
|
|
1109
|
+
print(line)
|
|
1110
|
+
sample = "سلام علی! سال ۱۴۰۵ — (test 123)"
|
|
1111
|
+
print(f" logical sample : {sample}")
|
|
1112
|
+
print(f" visual sample : {_to_visual(sample)}")
|
|
1113
|
+
print(line)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
if __name__ == "__main__":
|
|
1117
|
+
_self_diagnostics()
|