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
+ [![PyPI version](https://img.shields.io/pypi/v/fa-console.svg)](https://pypi.org/project/fa-console/)
45
+ [![Python](https://img.shields.io/pypi/pyversions/fa-console.svg)](https://pypi.org/project/fa-console/)
46
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
47
+ [![OS](https://img.shields.io/badge/platform-Windows-blue.svg)](#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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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()