ttyscan 0.0.2__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.
- ttyscan-0.0.2.dist-info/METADATA +263 -0
- ttyscan-0.0.2.dist-info/RECORD +6 -0
- ttyscan-0.0.2.dist-info/WHEEL +4 -0
- ttyscan-0.0.2.dist-info/entry_points.txt +2 -0
- ttyscan-0.0.2.dist-info/licenses/LICENSE +21 -0
- ttyscan.py +805 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ttyscan
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Queries a terminal emulator for its type, size and capabilities
|
|
5
|
+
Project-URL: Homepage, https://github.com/jquast/ttyscan
|
|
6
|
+
Author-email: Jeff Quast <contact@jeffquast.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: capability,colorterm,console,curses,query,serial,setupterm,ssh,telnet,terminal,terminfo,tty,xtgettcap
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
24
|
+
Classifier: Topic :: Terminals
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Description-Content-Type: text/x-rst
|
|
27
|
+
|
|
28
|
+
| |pypi_downloads| |codecov| |windows| |linux| |mac| |bsd|
|
|
29
|
+
|
|
30
|
+
ttyscan
|
|
31
|
+
=======
|
|
32
|
+
|
|
33
|
+
*ttyscan* queries a terminal emulator for its type, size and capabilities, creating a
|
|
34
|
+
curses-compatible `terminfo(5)`_ file entry if necessary, and exports any corrected values of
|
|
35
|
+
``TERM``, ``COLORTERM``, ``LINES``, ``COLUMNS``, and optionally ``TERMCAP`` environment variables.
|
|
36
|
+
|
|
37
|
+
Problem
|
|
38
|
+
-------
|
|
39
|
+
|
|
40
|
+
ncurses_ does not support XTGETTCAP, and so `terminfo(5)`_ files for some terminals must be
|
|
41
|
+
deployed to remote systems by system operators in some circumstances.
|
|
42
|
+
|
|
43
|
+
Curses programs fail to start until those files are deployed or an alternate ``TERM`` is exported.
|
|
44
|
+
All kinds of errors and warnings may be raised until this is done, some examples::
|
|
45
|
+
|
|
46
|
+
$ tmux attach
|
|
47
|
+
missing or unsuitable terminal: xterm-kitty
|
|
48
|
+
|
|
49
|
+
$ irssi
|
|
50
|
+
setupterm() failed for TERM=xterm-kitty: 0
|
|
51
|
+
Can't initialize screen handling.
|
|
52
|
+
|
|
53
|
+
$ htop
|
|
54
|
+
Error opening terminal: xterm-kitty.
|
|
55
|
+
|
|
56
|
+
$ python -c 'import curses;curses.setupterm()'
|
|
57
|
+
Traceback (most recent call last):
|
|
58
|
+
File "<string>", line 1, in <module>
|
|
59
|
+
_curses.error: setupterm: could not find terminal
|
|
60
|
+
|
|
61
|
+
$ git log -p
|
|
62
|
+
WARNING: terminal is not fully functional
|
|
63
|
+
Press RETURN to continue
|
|
64
|
+
|
|
65
|
+
Solution
|
|
66
|
+
--------
|
|
67
|
+
|
|
68
|
+
*ttyscan* creates the missing `terminfo(5)` file using the ``XTGETTCAP`` (``DCS +q``) terminal query
|
|
69
|
+
protocol when supported, and suggests a new ``TERMINFO`` environment value for export, allowing
|
|
70
|
+
legacy calls to curses of `setupterm(3)`_ to succeed::
|
|
71
|
+
|
|
72
|
+
$ ttyscan -vft
|
|
73
|
+
ttyscan: probing terminal via XTGETTCAP ...
|
|
74
|
+
ttyscan: size via dual CPR: 28x100
|
|
75
|
+
ttyscan: XTGETTCAP supported, terminal: rio; received 204 caps (10 bool, 5 num, 185 str) in 1523ms
|
|
76
|
+
ttyscan: writing /home/jq/.config/ttyscan/terminfo/r/rio
|
|
77
|
+
export COLORTERM=truecolor
|
|
78
|
+
export TERM=rio
|
|
79
|
+
export LINES=28
|
|
80
|
+
export COLUMNS=100
|
|
81
|
+
export TERMINFO=/home/jq/.config/ttyscan/terminfo
|
|
82
|
+
export TERMCAP='rio|XTGETTCAP-discovered terminal::am::ut::hs::km::5i::mi::ms::NP::xn::Co#256::co#80::it#8::li#24::pa#32767::ac=``aaffggiijjkkllmmnnooppqqrrssttuuvvwwxxyyzz{{||}}~~::bl=^G::mb=\E[5m::md=\E[1m::vi=\E[?25l::cl=\E[H\E[2J::ve=\E[?12l\E[?25h::cr=\r::cs=\E[%i%p1%d;%p2%dr::le=\b::DO=\E[%p1%dB::do=\n::nd=\E[C::cm=\E[%i%p1%d;%p2%dH::up=\E[A::vs=\E[?12;25h::dC=\E[P::mh=\E[2m::dl=\E[M::ds=\E]2;^G::ec=\E[%p1%dX::cd=\E[J::ce=\E[K::cb=\E[1K::vb=\E[?5h$<100/>\E[?5l::fs=^G::ho=\E[H::ch=\E[%i%p1%dG::ta=\t::st=\EH::al=\E[L::sf=\n::mk=\E[8m::is=\E[!p\E[?3;4l\E[4l\E>::K2=\EOE::kb=^?::kB=\E[Z::kl=\EOD::kd=\EOB::kr=\EOC::ku=\EOA::kD=\E[3~::@7=\EOF::@8=\EOM::k1=\EOP::k0=\E[21~::F1=\E[23~::F2=\E[24~::k2=\EOQ::k3=\EOR::k4=\EOS::k5=\E[15~::k6=\E[17~::k7=\E[18~::k8=\E[19~::k9=\E[20~::kh=\EOH::kI=\E[2~::kH=\E[1;2B::kN=\E[6~::kP=\E[5~::op=\E[39;49m::rp=%p1%c\E[%p2%{1}%-%db::mr=\E[7m::sr=\EM::ZR=\E[23m::ae=\E(B::te=\E[?1049l\E[23;0;0t::ei=\E[4l::ke=\E[?1l\E>::se=\E[27m::ue=\E[24m::r1=\Ec\E]104^G::r2=\E[!p\E[?3;4l\E[4l\E>::sa=%?%p9%t\E(0%e\E(B%;\E[0%?%p6%t;1%;%?%p5%t;2%;%?%p2%t;4%;%?%p1%p3%|%t;7%;%?%p4%t;5%;%?%p7%t;8%;m::me=\E(B\E[m::ZH=\E[3m::as=\E(0::ti=\E[?1049h\E[22;0;0t::im=\E[4h::ks=\E[?1h\E=::so=\E[7m::us=\E[4m::ct=\E[3g::ts=\E]2;::cv=\E[%i%p1%dd:'
|
|
83
|
+
|
|
84
|
+
$ eval `ttyscan`
|
|
85
|
+
|
|
86
|
+
$ echo $TERM, $TERMINFO
|
|
87
|
+
rio, /home/jquast/.config/ttyscan/terminfo
|
|
88
|
+
|
|
89
|
+
$ file -b /home/jquast/.config/ttyscan/terminfo/r/rio
|
|
90
|
+
Compiled terminfo entry "rio"
|
|
91
|
+
|
|
92
|
+
$ python -c 'import curses;curses.setupterm()'; echo $?
|
|
93
|
+
0
|
|
94
|
+
|
|
95
|
+
CLI Arguments
|
|
96
|
+
-------------
|
|
97
|
+
|
|
98
|
+
::
|
|
99
|
+
|
|
100
|
+
usage: ttyscan [-h] [-v] [-f] [-t]
|
|
101
|
+
|
|
102
|
+
Export terminal capabilities discovered via XTGETTCAP
|
|
103
|
+
|
|
104
|
+
options:
|
|
105
|
+
-h, --help show this help message and exit
|
|
106
|
+
-v, --verbose Print diagnostic information to stderr
|
|
107
|
+
-f, --force Force export of all values even if unchanged
|
|
108
|
+
-t, --termcap Also export TERMCAP value
|
|
109
|
+
|
|
110
|
+
*ttyscan* saves a `terminfo(5)`_ file to $XDG_CONFIG_HOME/ttyscan/terminfo or
|
|
111
|
+
~/.config/ttyscan/terminfo, and does not re-query or re-create it when it already exists from
|
|
112
|
+
previous executions, unless ``--force`` argument is used.
|
|
113
|
+
|
|
114
|
+
Typical total execution time is 200ms. The default timeout query can be changed by environment
|
|
115
|
+
value TTYSCAN_QUERY_TIMEOUT=1.0 (float), in seconds. If timeout is reached, terminal response may
|
|
116
|
+
"bleed" into subsequent programs, (like a shell prompt).
|
|
117
|
+
|
|
118
|
+
Installation
|
|
119
|
+
------------
|
|
120
|
+
|
|
121
|
+
.. code-block:: shell
|
|
122
|
+
|
|
123
|
+
pip install ttyscan
|
|
124
|
+
|
|
125
|
+
*ttyscan* requires Python3.8+.
|
|
126
|
+
|
|
127
|
+
ttyscan.py_ is a stand-alone python file, it does not require pip to install, you can copy this
|
|
128
|
+
single file directly from source and execute it from source, eg. ``python ~/bin/ttyscan.py``
|
|
129
|
+
|
|
130
|
+
Motive
|
|
131
|
+
------
|
|
132
|
+
|
|
133
|
+
Naturally, transferring your ``$HOME/.terminfo`` folder to a remote machine is the best solution.
|
|
134
|
+
|
|
135
|
+
However, enterprise systems, bastion hosts, cloud systems, web consoles, hypervisors, radio, and
|
|
136
|
+
serial ports can be challenging places or protocol layers through which to deploy terminfo files.
|
|
137
|
+
|
|
138
|
+
Some workarounds include exporting a generally-compatible ``TERM=xterm-256color`` or ``xterm`` with
|
|
139
|
+
sometimes minor corruption of screen output, missed interpretation of keyboard input such as
|
|
140
|
+
backspace or delete, or a small reduction of features such as italic or underlined text or number of
|
|
141
|
+
colors.
|
|
142
|
+
|
|
143
|
+
**And so this tool is not often needed** except for the **very serious** terminal connoisseur.
|
|
144
|
+
|
|
145
|
+
It serves as a demonstration: that a full terminal capability database *can* be transferred using
|
|
146
|
+
XTGETTCAP_ for many modern terminals, supporting modern `terminfo(5)`_ and legacy `termcap(5)`_. It
|
|
147
|
+
|
|
148
|
+
My hope is that `setupterm(3)`_ may negotiate with full XTGETTCAP_ support at some point in the
|
|
149
|
+
future, and that this utility is not commonly used or required!
|
|
150
|
+
|
|
151
|
+
Scope
|
|
152
|
+
-----
|
|
153
|
+
|
|
154
|
+
At time of this writing (May 2026), the ucs-detect_ dataset of 42 terminals shows three categories
|
|
155
|
+
of support for ``XTGETTCAP``:
|
|
156
|
+
|
|
157
|
+
- **Full** ``XTGETTCAP`` capability support: contour_, foot_, ghostty_, kitty_, rio, and wezterm
|
|
158
|
+
transmit their complete terminfo boolean, numeric, and string capabilities via XTGETTCAP_.
|
|
159
|
+
|
|
160
|
+
*ttyscan* produces terminfo files for only these terminals. *ttyscan* may also discover a
|
|
161
|
+
preferred ``TERM`` from ``TN``, and ``COLORTERM=truecolor`` from ``RGB``.
|
|
162
|
+
|
|
163
|
+
- **Partial** ``XTGETTCAP`` capability support: XTerm_, iterm2, mlterm, AbsoluteTelnet/SSH, GNOME
|
|
164
|
+
Terminal, LXTerminal, terminator, termit, and xfce4-terminal report only ``TN``, ``Co``, and
|
|
165
|
+
``RGB``.
|
|
166
|
+
|
|
167
|
+
*ttyscan* may only discover preferred ``TERM`` from ``TN``, and ``COLORTERM=truecolor`` from
|
|
168
|
+
``RGB``.
|
|
169
|
+
|
|
170
|
+
XTerm_ supports only keyboard sequences in addition to ``TN``, ``Co``, and ``RGB``. It does not
|
|
171
|
+
report *all* capabilities, and so a `terminfo(5)`_ entry cannot be built. However,
|
|
172
|
+
``TERM=xterm-256color`` and ``TERM=xterm`` are the most ubiquitous terminal name, you should be
|
|
173
|
+
just fine.
|
|
174
|
+
|
|
175
|
+
- **None**: alacritty (refuses: `alacritty/vte#98`_), bobcat, cmd.exe, ConEmu, cool-retro-term,
|
|
176
|
+
Extraterm, Hyper, Konsole (requested: `KDE#507017`_), linux fbdev, mintty, PuTTY, QTerminal,
|
|
177
|
+
rxvt-unicode, screen, securecrt, st, Tabby, Apple Terminal, Terminal.exe (planned:
|
|
178
|
+
`microsoft/terminal#17735`_), terminology, tmux (passthrough: `tmux/tmux#3755`_), libvterm, VS
|
|
179
|
+
Code (xterm.js, proposal: `xtermjs/xterm.js#4107`_), weston-terminal, and zutty.
|
|
180
|
+
|
|
181
|
+
Architecture
|
|
182
|
+
------------
|
|
183
|
+
|
|
184
|
+
*ttyscan* uses the following,
|
|
185
|
+
|
|
186
|
+
- ``XTGETTCAP`` field ``TN`` is used to correct ``TERM`` when unmatched.
|
|
187
|
+
- ``XTGETTCAP`` field ``RGB`` is used to correct ``COLORTERM`` when unmatched.
|
|
188
|
+
- all capability strings, keyboard and screen, when provided by ``XTGETTCAP``.
|
|
189
|
+
- DEC Private Mode 2048 (In-Band Resize) to determine the window size
|
|
190
|
+
- Or failing that, using Cursor Position Report sequence like done in XTerm's `resize.c
|
|
191
|
+
<https://github.com/joejulian/xterm/blob/master/resize.c>`_
|
|
192
|
+
|
|
193
|
+
Details
|
|
194
|
+
-------
|
|
195
|
+
|
|
196
|
+
The difference of *Full* and *Partial* ``XTGETTCAP`` support is best described by foot_:
|
|
197
|
+
|
|
198
|
+
``XTGETTCAP`` is an escape sequence initially introduced by XTerm_, and also implemented (and extended,
|
|
199
|
+
to some degree) by Kitty.
|
|
200
|
+
|
|
201
|
+
Applications using this feature do not need to use the classic, file-based, terminfo definition.
|
|
202
|
+
|
|
203
|
+
XTerm's implementation (as of XTerm-370) only supports querying key (as in keyboard keys)
|
|
204
|
+
capabilities, and three custom capabilities:
|
|
205
|
+
|
|
206
|
+
``TN`` - terminal name
|
|
207
|
+
``Co`` - number of colors (alias for the colors capability)
|
|
208
|
+
``RGB`` - number of bits per color channel (different semantics from the RGB capability in file-based
|
|
209
|
+
terminfo definitions!).
|
|
210
|
+
|
|
211
|
+
Kitty has extended this, and also supports querying all integer and string capabilities.
|
|
212
|
+
|
|
213
|
+
Foot supports this, and extends it even further, to also include boolean capabilities.
|
|
214
|
+
This means foot's entire terminfo can be queried via ``XTGETTCAP``.
|
|
215
|
+
|
|
216
|
+
Further, all of ``TERM``, ``COLORTERM``, ``LINES``, or ``COLUMNS`` may not be transmitted by all
|
|
217
|
+
software or protocols, some examples:
|
|
218
|
+
|
|
219
|
+
- ssh does not forward ``COLORTERM`` unless configured using ``SendEnv`` in `ssh_config(5)`_ and
|
|
220
|
+
``AcceptEnv`` in `sshd_config(5)`_.
|
|
221
|
+
- serial does not forward any; ``TERM`` is defined by host `agetty(8)`_ configuration, for example.
|
|
222
|
+
- rlogin can forward all but ``COLORTERM``.
|
|
223
|
+
- telnet can forward all, but IAC NAWS and NEW-ENVIRON capability varies by software.
|
|
224
|
+
- websocket cannot forward any without customization, often used in-browser.
|
|
225
|
+
|
|
226
|
+
*ttyscan* detects when these variables are unset or do not match values and re-exports the corrected
|
|
227
|
+
values when they differ.
|
|
228
|
+
|
|
229
|
+
.. _`agetty(8)`: https://linux.die.net/man/8/agetty
|
|
230
|
+
.. _`alacritty/vte#98`: https://github.com/alacritty/vte/issues/98
|
|
231
|
+
.. _contour: https://github.com/contour-terminal/contour
|
|
232
|
+
.. _foot: https://codeberg.org/dnkl/foot#xtgettcap
|
|
233
|
+
.. _ghostty: https://mitchellh.com/writing/ghostty-devlog-004
|
|
234
|
+
.. _`KDE#507017`: https://bugs.kde.org/show_bug.cgi?id=507017
|
|
235
|
+
.. _kitty: https://sw.kovidgoyal.net/kitty/kittens/query_terminal/
|
|
236
|
+
.. _`microsoft/terminal#17735`: https://github.com/microsoft/terminal/issues/17735
|
|
237
|
+
.. _ncurses: https://invisible-island.net/ncurses/
|
|
238
|
+
.. _`setupterm(3)`: https://linux.die.net/man/3/setupterm
|
|
239
|
+
.. _`ssh_config(5)`: https://linux.die.net/man/5/ssh_config
|
|
240
|
+
.. _`sshd_config(5)`: https://linux.die.net/man/5/sshd_config
|
|
241
|
+
.. _`termcap(5)`: https://linux.die.net/man/5/termcap
|
|
242
|
+
.. _`terminfo(5)`: https://linux.die.net/man/5/terminfo
|
|
243
|
+
.. _`tmux/tmux#3755`: https://github.com/tmux/tmux/issues/3755
|
|
244
|
+
.. _`ttyscan.py`: https://github.com/jquast/ttyscan/blob/master/ttyscan.py
|
|
245
|
+
.. _ucs-detect: https://ucs-detect.readthedocs.io/results.html#terminal-capabilities
|
|
246
|
+
.. _XTerm: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Device-Control-functions:DCS-plus-q-Pt-ST.F95
|
|
247
|
+
.. _`xtermjs/xterm.js#4107`: https://github.com/xtermjs/xterm.js/issues/4107
|
|
248
|
+
.. _XTGETTCAP: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Device-Control-functions:DCS-plus-q-Pt-ST.F95
|
|
249
|
+
|
|
250
|
+
.. |pypi_downloads| image:: https://img.shields.io/pypi/dm/ttyscan.svg?logo=pypi
|
|
251
|
+
:alt: Downloads
|
|
252
|
+
:target: https://pypi.org/project/ttyscan/
|
|
253
|
+
.. |codecov| image:: https://codecov.io/gh/jquast/ttyscan/branch/master/graph/badge.svg
|
|
254
|
+
:alt: codecov.io Code Coverage
|
|
255
|
+
:target: https://codecov.io/gh/jquast/ttyscan/
|
|
256
|
+
.. |linux| image:: https://img.shields.io/badge/Linux-yes-success?logo=linux
|
|
257
|
+
:alt: Linux supported
|
|
258
|
+
.. |windows| image:: https://img.shields.io/badge/Windows-yes-success?logo=windows
|
|
259
|
+
:alt: Windows supported
|
|
260
|
+
.. |mac| image:: https://img.shields.io/badge/MacOS-yes-success?logo=apple
|
|
261
|
+
:alt: MacOS supported
|
|
262
|
+
.. |bsd| image:: https://img.shields.io/badge/BSD-yes-success?logo=freebsd
|
|
263
|
+
:alt: BSD supported
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
ttyscan.py,sha256=YAtuy0ARA1_KCmm9WjAdbnouUaATqOjPy0xyTIDRVRE,27884
|
|
2
|
+
ttyscan-0.0.2.dist-info/METADATA,sha256=YeDQyD7YfzjhSMP_GoOEE9LZu6Tkswr254fRxpPRqjc,12768
|
|
3
|
+
ttyscan-0.0.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
4
|
+
ttyscan-0.0.2.dist-info/entry_points.txt,sha256=D3_mvwKz7kJJ4cTivulSnFf_njTLHNuoAEFyUeIOTs8,41
|
|
5
|
+
ttyscan-0.0.2.dist-info/licenses/LICENSE,sha256=s66usgtPL2otKLK6u_6HLrUKkIz4mEMUNkNf3-uItds,1067
|
|
6
|
+
ttyscan-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeff Quast
|
|
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.
|
ttyscan.py
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import array
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import select
|
|
8
|
+
import struct
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import fcntl
|
|
15
|
+
import termios
|
|
16
|
+
import tty
|
|
17
|
+
except ImportError as exc:
|
|
18
|
+
sys.exit(f"ttyscan: unsupported platform (missing required module: {exc})")
|
|
19
|
+
|
|
20
|
+
__version__ = "0.0.2"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def warn(msg):
|
|
24
|
+
print(f"ttyscan: {msg}", file=sys.stderr)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
_TTYSCAN_QUERY_TIMEOUT = float(os.environ.get('TTYSCAN_QUERY_TIMEOUT', '1.0'))
|
|
29
|
+
except ValueError:
|
|
30
|
+
warn(f"TTYSCAN_QUERY_TIMEOUT value "
|
|
31
|
+
f"{os.environ['TTYSCAN_QUERY_TIMEOUT']!r} "
|
|
32
|
+
f"is not a valid float, using default 1.0")
|
|
33
|
+
_TTYSCAN_QUERY_TIMEOUT = 1.0
|
|
34
|
+
|
|
35
|
+
_CANONICAL_BOOL_CAPS = [
|
|
36
|
+
"bw", "am", "xsb", "xhp", "xenl", "eo", "gn", "hc", "km", "hs",
|
|
37
|
+
"in", "da", "db", "mir", "msgr", "os", "eslok", "xt", "hz", "ul",
|
|
38
|
+
"xon", "nxon", "mc5i", "chts", "nrrmc", "npc", "ndscr", "ccc", "bce",
|
|
39
|
+
"hls", "xhpa", "crxm", "daisy", "xvpa", "sam", "cpix", "lpix",
|
|
40
|
+
"OTbs", "OTns", "OTnc", "OTMT", "OTNL", "OTpt", "OTxr",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
_CANONICAL_NUM_CAPS = [
|
|
44
|
+
"cols", "it", "lines", "lm", "xmc", "pb", "vt", "wsl", "nlab",
|
|
45
|
+
"lh", "lw", "ma", "wnum", "colors", "pairs", "ncv", "bufsz",
|
|
46
|
+
"spinv", "spinh", "maddr", "mjump", "mcs", "mls", "npins",
|
|
47
|
+
"orc", "orl", "orhi", "orvi", "cps", "widcs", "btns",
|
|
48
|
+
"bitwin", "bitype", "OTug", "OTdC", "OTdN", "OTdB", "OTdT", "OTkn",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
_CANONICAL_STR_CAPS = [
|
|
52
|
+
"cbt", "bel", "cr", "csr", "tbc", "clear", "el", "ed", "hpa",
|
|
53
|
+
"cmdch", "cup", "cud1", "home", "civis", "cub1", "mrcup", "cnorm",
|
|
54
|
+
"cuf1", "ll", "cuu1", "cvvis", "dch1", "dl1", "dsl", "hd", "smacs",
|
|
55
|
+
"blink", "bold", "smcup", "smdc", "dim", "smir", "invis", "prot",
|
|
56
|
+
"rev", "smso", "smul", "ech", "rmacs", "sgr0", "rmcup", "rmdc",
|
|
57
|
+
"rmir", "rmso", "rmul", "flash", "ff", "fsl", "is1", "is2", "is3",
|
|
58
|
+
"if", "ich1", "il1", "ip", "kbs", "ktbc", "kclr", "kctab", "kdch1",
|
|
59
|
+
"kdl1", "kcud1", "krmir", "kel", "ked", "kf0", "kf1", "kf10", "kf2",
|
|
60
|
+
"kf3", "kf4", "kf5", "kf6", "kf7", "kf8", "kf9", "khome", "kich1",
|
|
61
|
+
"kil1", "kcub1", "kll", "knp", "kpp", "kcuf1", "kind", "kri", "khts",
|
|
62
|
+
"kcuu1", "rmkx", "smkx", "lf0", "lf1", "lf10", "lf2", "lf3", "lf4",
|
|
63
|
+
"lf5", "lf6", "lf7", "lf8", "lf9", "rmm", "smm", "nel", "pad", "dch",
|
|
64
|
+
"dl", "cud", "ich", "indn", "il", "cub", "cuf", "rin", "cuu", "pfkey",
|
|
65
|
+
"pfloc", "pfx", "mc0", "mc4", "mc5", "rep", "rs1", "rs2", "rs3", "rf",
|
|
66
|
+
"rc", "vpa", "sc", "ind", "ri", "sgr", "hts", "wind", "ht", "tsl",
|
|
67
|
+
"uc", "hu", "iprog", "ka1", "ka3", "kb2", "kc1", "kc3", "mc5p", "rmp",
|
|
68
|
+
"acsc", "pln", "kcbt", "smxon", "rmxon", "smam", "rmam", "xonc",
|
|
69
|
+
"xoffc", "enacs", "smln", "rmln", "kbeg", "kcan", "kclo", "kcmd",
|
|
70
|
+
"kcpy", "kcrt", "kend", "kent", "kext", "kfnd", "khlp", "kmrk",
|
|
71
|
+
"kmsg", "kmov", "knxt", "kopn", "kopt", "kprv", "kprt", "krdo",
|
|
72
|
+
"kref", "krfr", "krpl", "krst", "kres", "ksav", "kspd", "kund",
|
|
73
|
+
"kBEG", "kCAN", "kCMD", "kCPY", "kCRT", "kDC", "kDL", "kslt", "kEND",
|
|
74
|
+
"kEOL", "kEXT", "kFND", "kHLP", "kHOM", "kIC", "kLFT", "kMSG", "kMOV",
|
|
75
|
+
"kNXT", "kOPT", "kPRV", "kPRT", "kRDO", "kRPL", "kRIT", "kRES",
|
|
76
|
+
"kSAV", "kSPD", "kUND", "rfi", "kf11", "kf12", "kf13", "kf14", "kf15",
|
|
77
|
+
"kf16", "kf17", "kf18", "kf19", "kf20", "kf21", "kf22", "kf23",
|
|
78
|
+
"kf24", "kf25", "kf26", "kf27", "kf28", "kf29", "kf30", "kf31",
|
|
79
|
+
"kf32", "kf33", "kf34", "kf35", "kf36", "kf37", "kf38", "kf39",
|
|
80
|
+
"kf40", "kf41", "kf42", "kf43", "kf44", "kf45", "kf46", "kf47",
|
|
81
|
+
"kf48", "kf49", "kf50", "kf51", "kf52", "kf53", "kf54", "kf55",
|
|
82
|
+
"kf56", "kf57", "kf58", "kf59", "kf60", "kf61", "kf62", "kf63",
|
|
83
|
+
"el1", "mgc", "smgl", "smgr", "fln", "sclk", "dclk", "rmclk", "cwin",
|
|
84
|
+
"wingo", "hup", "dial", "qdial", "tone", "pulse", "hook", "pause",
|
|
85
|
+
"wait", "u0", "u1", "u2", "u3", "u4", "u5", "u6", "u7", "u8", "u9",
|
|
86
|
+
"op", "oc", "initc", "initp", "scp", "setf", "setb", "cpi", "lpi",
|
|
87
|
+
"chr", "cvr", "defc", "swidm", "sdrfq", "sitm", "slm", "smicm",
|
|
88
|
+
"snlq", "snrmq", "sshm", "ssubm", "ssupm", "sum", "rwidm", "ritm",
|
|
89
|
+
"rlm", "rmicm", "rshm", "rsubm", "rsupm", "rum", "mhpa", "mcud1",
|
|
90
|
+
"mcub1", "mcuf1", "mvpa", "mcuu1", "porder", "mcud", "mcub", "mcuf",
|
|
91
|
+
"mcuu", "scs", "smgb", "smgbp", "smglp", "smgrp", "smgt", "smgtp",
|
|
92
|
+
"sbim", "scsd", "rbim", "rcsd", "subcs", "supcs", "docr", "zerom",
|
|
93
|
+
"csnm", "kmous", "minfo", "reqmp", "getm", "setaf", "setab", "pfxl",
|
|
94
|
+
"devt", "csin", "s0ds", "s1ds", "s2ds", "s3ds", "smglr", "smgtb",
|
|
95
|
+
"birep", "binel", "bicr", "colornm", "defbi", "endbi", "setcolor",
|
|
96
|
+
"slines", "dispc", "smpch", "rmpch", "smsc", "rmsc", "pctrm", "scesc",
|
|
97
|
+
"scesa", "ehhlm", "elhlm", "elohlm", "erhlm", "ethlm", "evhlm",
|
|
98
|
+
"sgr1", "slength", "OTi2", "OTrs", "OTnl", "OTbc", "OTko", "OTma",
|
|
99
|
+
"OTG2", "OTG3", "OTG1", "OTG4", "OTGR", "OTGL", "OTGU", "OTGD",
|
|
100
|
+
"OTGH", "OTGV", "OTGC", "meml", "memu", "box1",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
_INIT_XTGETTCAP_CAPS = frozenset((
|
|
104
|
+
'TN', 'RGB', 'colors', 'blink', 'sitm', 'ritm', 'cvvis', 'Smulx', 'Setulc', 'Ms'
|
|
105
|
+
))
|
|
106
|
+
|
|
107
|
+
_FULL_XTGETTCAP_CAPS = tuple(
|
|
108
|
+
sorted(set(_CANONICAL_BOOL_CAPS) | set(_CANONICAL_NUM_CAPS) | set(_CANONICAL_STR_CAPS))
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
_BOOL_SET = frozenset(_CANONICAL_BOOL_CAPS)
|
|
112
|
+
_NUM_SET = frozenset(_CANONICAL_NUM_CAPS)
|
|
113
|
+
_STR_SET = frozenset(_CANONICAL_STR_CAPS)
|
|
114
|
+
|
|
115
|
+
_RE_XTGETTCAP_RESPONSE = re.compile(
|
|
116
|
+
r'\x1bP([01])\+r([0-9a-fA-F]+)(?:=([0-9a-fA-F]*))?\x1b\\')
|
|
117
|
+
_RE_CPR = re.compile(r'\x1b\[(\d+);(\d+)R')
|
|
118
|
+
_RE_CPR_BOUNDARY = re.compile(r'\x1b\[[0-9]+;[0-9]+R')
|
|
119
|
+
_RE_DECRPM = re.compile(r'\x1b\[\?(\d+);([0-4])\$y')
|
|
120
|
+
_RE_RESIZE = re.compile(r'\x1b\[48;(\d+);(\d+);(\d+);(\d+)t')
|
|
121
|
+
|
|
122
|
+
_TERMINFO_MAGIC = 0o432
|
|
123
|
+
_TERMINFO_MAGIC2 = 0o1036
|
|
124
|
+
_SENTINEL_ABSENT = -1
|
|
125
|
+
|
|
126
|
+
_BOOL_INDEX = {name: idx for idx, name in enumerate(_CANONICAL_BOOL_CAPS)}
|
|
127
|
+
_NUM_INDEX = {name: idx for idx, name in enumerate(_CANONICAL_NUM_CAPS)}
|
|
128
|
+
_STR_INDEX = {name: idx for idx, name in enumerate(_CANONICAL_STR_CAPS)}
|
|
129
|
+
|
|
130
|
+
_TERMINFO_ESCAPE = {
|
|
131
|
+
'E': '\x1b', 'e': '\x1b',
|
|
132
|
+
'n': '\n', 't': '\t', 'r': '\r',
|
|
133
|
+
'b': '\b', 'f': '\f', 's': ' ',
|
|
134
|
+
'\\': '\\', '^': '^', ':': ':',
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_TERMINFO_TO_TERMCAP_BOOL = {
|
|
138
|
+
"am": "am", "bce": "ut", "km": "km", "mc5i": "5i",
|
|
139
|
+
"mir": "mi", "msgr": "ms", "npc": "NP", "xenl": "xn",
|
|
140
|
+
"hs": "hs", "in": "in", "da": "da", "db": "db",
|
|
141
|
+
"eo": "eo", "eslok": "es", "gn": "gn", "hc": "hc",
|
|
142
|
+
"hz": "hz", "lpix": "YB", "ndscr": "ND", "nrrmc": "NR",
|
|
143
|
+
"os": "os", "sam": "SA", "ul": "ul", "xb": "xb",
|
|
144
|
+
"xn": "xn", "xt": "xt",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_TERMINFO_TO_TERMCAP_NUM = {
|
|
148
|
+
"colors": "Co", "cols": "co", "lines": "li", "it": "it",
|
|
149
|
+
"pairs": "pa", "btns": "BT", "bufsz": "Ya", "lm": "lm",
|
|
150
|
+
"lh": "lh", "lw": "lw", "ma": "ma", "mw": "mw",
|
|
151
|
+
"ncv": "NC", "nlab": "Nl", "pb": "pb", "sg": "sg",
|
|
152
|
+
"ug": "ug", "vt": "vt", "ws": "ws", "wnum": "dw",
|
|
153
|
+
"wsl": "ws",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_TERMINFO_TO_TERMCAP_STR = {
|
|
157
|
+
"acsc": "ac", "bel": "bl", "blink": "mb", "bold": "md",
|
|
158
|
+
"cbt": "bt", "civis": "vi", "clear": "cl", "cnorm": "ve",
|
|
159
|
+
"cr": "cr", "csr": "cs", "cub1": "le", "cud": "DO",
|
|
160
|
+
"cud1": "do", "cuf1": "nd", "cup": "cm", "cuu1": "up",
|
|
161
|
+
"cvvis": "vs", "dch1": "dC", "dim": "mh", "dl1": "dl",
|
|
162
|
+
"dsl": "ds", "ech": "ec", "ed": "cd", "el": "ce",
|
|
163
|
+
"el1": "cb", "flash": "vb", "fsl": "fs", "home": "ho",
|
|
164
|
+
"hpa": "ch", "hts": "st", "ht": "ta", "ich1": "ic",
|
|
165
|
+
"il1": "al", "ind": "sf", "invis": "mk", "iprog": "iP",
|
|
166
|
+
"is1": "i1", "is2": "is", "is3": "i3", "kb2": "K2",
|
|
167
|
+
"kbs": "kb", "kcbt": "kB", "kcub1": "kl", "kcud1": "kd",
|
|
168
|
+
"kcuf1": "kr", "kcuu1": "ku", "kdch1": "kD", "kend": "@7",
|
|
169
|
+
"kent": "@8", "kf1": "k1", "kf2": "k2", "kf3": "k3",
|
|
170
|
+
"kf4": "k4", "kf5": "k5", "kf6": "k6", "kf7": "k7",
|
|
171
|
+
"kf8": "k8", "kf9": "k9", "kf10": "k0", "kf11": "F1",
|
|
172
|
+
"kf12": "F2", "kfnd": "kF", "khlp": "%1", "khome": "kh",
|
|
173
|
+
"kich1": "kI", "kind": "kH", "knp": "kN", "kpp": "kP",
|
|
174
|
+
"kprv": "kR", "kspd": "@9", "ktbc": "ka", "mc0": "ps",
|
|
175
|
+
"mc4": "pf", "mc5": "po", "mc5p": "pO", "nel": "nw",
|
|
176
|
+
"op": "op", "pad": "pc", "pln": "pn", "prot": "mp",
|
|
177
|
+
"rep": "rp", "rev": "mr", "ri": "sr", "rmacs": "ae",
|
|
178
|
+
"rmcup": "te", "rmdc": "ed", "rmir": "ei", "rmkx": "ke",
|
|
179
|
+
"rmso": "se", "rmul": "ue", "rs1": "r1", "rs2": "r2",
|
|
180
|
+
"rs3": "r3", "sgr": "sa", "sgr0": "me", "sitm": "ZH",
|
|
181
|
+
"ritm": "ZR", "smacs": "as", "smcup": "ti", "smdc": "dm",
|
|
182
|
+
"smir": "im", "smkx": "ks", "smso": "so", "smul": "us",
|
|
183
|
+
"tbc": "ct", "tsl": "ts", "vpa": "cv", "wind": "wi",
|
|
184
|
+
"wingo": "WS",
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def verbose(msg, enabled):
|
|
189
|
+
if enabled:
|
|
190
|
+
print(f"ttyscan: {msg}", file=sys.stderr)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def hex_encode(s):
|
|
194
|
+
return s.encode('ascii').hex()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def hex_decode(h):
|
|
198
|
+
try:
|
|
199
|
+
return bytes.fromhex(h).decode('ascii', errors='strict')
|
|
200
|
+
except ValueError:
|
|
201
|
+
return ''
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def unescape_terminfo(value):
|
|
205
|
+
result = []
|
|
206
|
+
idx = 0
|
|
207
|
+
while idx < len(value):
|
|
208
|
+
cur = value[idx]
|
|
209
|
+
if cur == '\\' and idx + 1 < len(value):
|
|
210
|
+
nxt = value[idx + 1]
|
|
211
|
+
esc = _TERMINFO_ESCAPE.get(nxt)
|
|
212
|
+
if esc is not None:
|
|
213
|
+
result.append(esc)
|
|
214
|
+
idx += 2
|
|
215
|
+
continue
|
|
216
|
+
if nxt in '01234567':
|
|
217
|
+
end = idx + 1
|
|
218
|
+
while end < len(value) and value[end] in '01234567':
|
|
219
|
+
end += 1
|
|
220
|
+
result.append(chr(int(value[idx + 1:end], 8)))
|
|
221
|
+
idx = end
|
|
222
|
+
continue
|
|
223
|
+
elif cur == '^' and idx + 1 < len(value):
|
|
224
|
+
nxt = value[idx + 1]
|
|
225
|
+
if 'A' <= nxt <= '_':
|
|
226
|
+
result.append(chr(ord(nxt) - ord('A') + 1))
|
|
227
|
+
idx += 2
|
|
228
|
+
continue
|
|
229
|
+
if nxt == '?':
|
|
230
|
+
result.append('\x7f')
|
|
231
|
+
idx += 2
|
|
232
|
+
continue
|
|
233
|
+
result.append(cur)
|
|
234
|
+
idx += 1
|
|
235
|
+
return ''.join(result)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def open_tty():
|
|
239
|
+
try:
|
|
240
|
+
r_fd = os.open('/dev/tty', os.O_RDONLY | os.O_NOCTTY)
|
|
241
|
+
w_fd = os.open('/dev/tty', os.O_WRONLY | os.O_NOCTTY)
|
|
242
|
+
return r_fd, w_fd
|
|
243
|
+
except OSError as exc:
|
|
244
|
+
warn(f"cannot open /dev/tty: {exc}")
|
|
245
|
+
return None, None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def get_winsize(fd):
|
|
249
|
+
try:
|
|
250
|
+
buf = array.array('H', [0, 0, 0, 0])
|
|
251
|
+
fcntl.ioctl(fd, termios.TIOCGWINSZ, buf)
|
|
252
|
+
return buf[1] or 80, buf[0] or 24
|
|
253
|
+
except OSError:
|
|
254
|
+
return 80, 24
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def set_cbreak(fd):
|
|
258
|
+
if fd is None:
|
|
259
|
+
return None
|
|
260
|
+
try:
|
|
261
|
+
saved = termios.tcgetattr(fd)
|
|
262
|
+
tty.setcbreak(fd)
|
|
263
|
+
return saved
|
|
264
|
+
except termios.error as exc:
|
|
265
|
+
warn(f"cannot set cbreak mode: {exc}")
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def restore_termios(fd, saved):
|
|
270
|
+
if fd is None or saved is None:
|
|
271
|
+
return
|
|
272
|
+
try:
|
|
273
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
274
|
+
except termios.error as exc:
|
|
275
|
+
warn(f"cannot restore terminal mode: {exc}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def write_all(fd, data):
|
|
279
|
+
if fd is None:
|
|
280
|
+
return
|
|
281
|
+
if isinstance(data, str):
|
|
282
|
+
data = data.encode('ascii', errors='replace')
|
|
283
|
+
remaining = data
|
|
284
|
+
while remaining:
|
|
285
|
+
try:
|
|
286
|
+
n = os.write(fd, remaining)
|
|
287
|
+
remaining = remaining[n:]
|
|
288
|
+
except OSError as exc:
|
|
289
|
+
warn(f"write error: {exc}")
|
|
290
|
+
break
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def read_available(r_fd, timeout):
|
|
294
|
+
buf = bytearray()
|
|
295
|
+
stime = time.monotonic()
|
|
296
|
+
max_size = 131072
|
|
297
|
+
while True:
|
|
298
|
+
timeleft = 0.0 if buf else timeout - (time.monotonic() - stime)
|
|
299
|
+
if not buf and timeleft <= 0:
|
|
300
|
+
break
|
|
301
|
+
try:
|
|
302
|
+
ready, _, _ = select.select([r_fd], [], [], timeleft)
|
|
303
|
+
except OSError as exc:
|
|
304
|
+
warn(f"select error: {exc}")
|
|
305
|
+
break
|
|
306
|
+
if not ready:
|
|
307
|
+
break
|
|
308
|
+
try:
|
|
309
|
+
chunk = os.read(r_fd, 4096)
|
|
310
|
+
except OSError as exc:
|
|
311
|
+
warn(f"read error: {exc}")
|
|
312
|
+
break
|
|
313
|
+
if not chunk:
|
|
314
|
+
break
|
|
315
|
+
buf.extend(chunk)
|
|
316
|
+
if len(buf) > max_size:
|
|
317
|
+
break
|
|
318
|
+
return bytes(buf)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def read_until(r_fd, w_fd, queries, pattern, timeout):
|
|
322
|
+
for q in queries:
|
|
323
|
+
write_all(w_fd, q)
|
|
324
|
+
write_all(w_fd, b'\x1b[6n')
|
|
325
|
+
stime = time.monotonic()
|
|
326
|
+
buf = ''
|
|
327
|
+
max_size = 131072
|
|
328
|
+
while True:
|
|
329
|
+
timeleft = timeout - (time.monotonic() - stime)
|
|
330
|
+
if timeleft <= 0:
|
|
331
|
+
break
|
|
332
|
+
raw = read_available(r_fd, timeleft)
|
|
333
|
+
if not raw:
|
|
334
|
+
break
|
|
335
|
+
buf += raw.decode('latin-1', errors='replace')
|
|
336
|
+
if (match := re.search(pattern, buf)) is not None:
|
|
337
|
+
return match, buf
|
|
338
|
+
if len(buf) > max_size:
|
|
339
|
+
break
|
|
340
|
+
return None, buf
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def xtgettcap_query(r_fd, w_fd, caps, timeout):
|
|
344
|
+
if not caps:
|
|
345
|
+
return {}
|
|
346
|
+
queries = [f'\x1bP+q{hex_encode(c)}\x1b\\' for c in caps]
|
|
347
|
+
match, data = read_until(r_fd, w_fd, queries, _RE_CPR_BOUNDARY.pattern, timeout)
|
|
348
|
+
if match is None:
|
|
349
|
+
return {}
|
|
350
|
+
data = data[:match.start()] + data[match.end():]
|
|
351
|
+
capabilities = {}
|
|
352
|
+
for m in _RE_XTGETTCAP_RESPONSE.finditer(data):
|
|
353
|
+
if m.group(1) == '1':
|
|
354
|
+
name = hex_decode(m.group(2))
|
|
355
|
+
val_hex = m.group(3)
|
|
356
|
+
if val_hex is not None:
|
|
357
|
+
value = unescape_terminfo(hex_decode(val_hex))
|
|
358
|
+
else:
|
|
359
|
+
value = ''
|
|
360
|
+
capabilities[name] = value
|
|
361
|
+
return capabilities
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def decrqm_query(r_fd, w_fd, mode, timeout):
|
|
365
|
+
result = read_until(r_fd, w_fd,
|
|
366
|
+
[f'\x1b[?{mode}$p'],
|
|
367
|
+
_RE_CPR_BOUNDARY.pattern, timeout)
|
|
368
|
+
if (match := result[0]) is None:
|
|
369
|
+
return None
|
|
370
|
+
data = result[1][:match.start()] + result[1][match.end():]
|
|
371
|
+
for m in _RE_DECRPM.finditer(data):
|
|
372
|
+
if int(m.group(1)) == mode:
|
|
373
|
+
return int(m.group(2))
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def detect_size(r_fd, w_fd, verbose_enabled):
|
|
378
|
+
has_inband = (decrpm := decrqm_query(r_fd, w_fd, 2048, 0.25)) is not None and decrpm == 2
|
|
379
|
+
|
|
380
|
+
if has_inband:
|
|
381
|
+
write_all(w_fd, '\x1b[?2048h')
|
|
382
|
+
raw = read_available(r_fd, 0.25).decode('latin-1', errors='replace')
|
|
383
|
+
if (resize_match := _RE_RESIZE.search(raw)):
|
|
384
|
+
rows = int(resize_match.group(1))
|
|
385
|
+
cols = int(resize_match.group(2))
|
|
386
|
+
verbose(f"size via Mode 2048 in-band: {rows}x{cols}", verbose_enabled)
|
|
387
|
+
write_all(w_fd, '\x1b[?2048l')
|
|
388
|
+
return rows, cols, 'inband'
|
|
389
|
+
|
|
390
|
+
cpr1_match, data = read_until(r_fd, w_fd, [], _RE_CPR.pattern, 0.25)
|
|
391
|
+
orig_y = orig_x = None
|
|
392
|
+
if cpr1_match:
|
|
393
|
+
orig_y = int(cpr1_match.group(1))
|
|
394
|
+
orig_x = int(cpr1_match.group(2))
|
|
395
|
+
|
|
396
|
+
write_all(w_fd, '\x1b[1000;1000H')
|
|
397
|
+
cpr2_match, _ = read_until(r_fd, w_fd, [], _RE_CPR.pattern, 0.25)
|
|
398
|
+
|
|
399
|
+
if has_inband:
|
|
400
|
+
write_all(w_fd, '\x1b[?2048l')
|
|
401
|
+
|
|
402
|
+
if orig_y is not None:
|
|
403
|
+
write_all(w_fd, f'\x1b[{orig_y};{orig_x}H')
|
|
404
|
+
|
|
405
|
+
if cpr2_match:
|
|
406
|
+
rows = int(cpr2_match.group(1))
|
|
407
|
+
cols = int(cpr2_match.group(2))
|
|
408
|
+
if rows == 999 or cols == 999:
|
|
409
|
+
verbose(f"rejecting bogus CPR size {rows}x{cols}", verbose_enabled)
|
|
410
|
+
return None
|
|
411
|
+
verbose(f"size via dual CPR: {rows}x{cols}", verbose_enabled)
|
|
412
|
+
return rows, cols, 'cpr'
|
|
413
|
+
|
|
414
|
+
verbose("CPR size detection", verbose_enabled)
|
|
415
|
+
write_all(w_fd, '\x1b[1000;1000H')
|
|
416
|
+
fb_match, _ = read_until(r_fd, w_fd, [], _RE_CPR.pattern, 0.25)
|
|
417
|
+
if fb_match:
|
|
418
|
+
rows = int(fb_match.group(1))
|
|
419
|
+
cols = int(fb_match.group(2))
|
|
420
|
+
if rows != 999 and cols != 999:
|
|
421
|
+
verbose(f"size via CPR: {rows}x{cols}", verbose_enabled)
|
|
422
|
+
return rows, cols, 'fallback_cpr'
|
|
423
|
+
|
|
424
|
+
verbose("size detection failed", verbose_enabled)
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def classify_caps(capabilities):
|
|
429
|
+
bool_caps = set()
|
|
430
|
+
num_caps = {}
|
|
431
|
+
str_caps = {}
|
|
432
|
+
for name, value in capabilities.items():
|
|
433
|
+
if name == 'RGB':
|
|
434
|
+
continue
|
|
435
|
+
if not value:
|
|
436
|
+
if name in _BOOL_SET:
|
|
437
|
+
bool_caps.add(name)
|
|
438
|
+
elif name in _NUM_SET:
|
|
439
|
+
if value.isdigit():
|
|
440
|
+
num_caps[name] = int(value)
|
|
441
|
+
elif name in _STR_SET:
|
|
442
|
+
str_caps[name] = value
|
|
443
|
+
return {'bool_caps': bool_caps, 'num_caps': num_caps, 'str_caps': str_caps}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def pack_short_le(buf, offset, value):
|
|
447
|
+
packed = struct.pack("<h", value)
|
|
448
|
+
buf[offset:offset + 2] = packed
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def build_terminfo_binary(term, str_caps, num_caps, bool_caps):
|
|
452
|
+
names = f"{term}|XTGETTCAP-discovered terminal"
|
|
453
|
+
names_bytes = names.encode("ascii", errors="replace") + b"\x00"
|
|
454
|
+
name_size = len(names_bytes)
|
|
455
|
+
bool_indices = [_BOOL_INDEX[cap] for cap in bool_caps if cap in _BOOL_INDEX]
|
|
456
|
+
bool_max = max(bool_indices) + 1 if bool_indices else 0
|
|
457
|
+
bool_data = bytearray(bool_max)
|
|
458
|
+
for idx in bool_indices:
|
|
459
|
+
bool_data[idx] = 1
|
|
460
|
+
num_entries = [(_NUM_INDEX[cap], val) for cap, val in num_caps.items()
|
|
461
|
+
if cap in _NUM_INDEX]
|
|
462
|
+
num_max = max(idx for idx, _ in num_entries) + 1 if num_entries else 0
|
|
463
|
+
use_32bit = any(val > 32767 or val < -32768 for _, val in num_entries)
|
|
464
|
+
num_entry_size = 4 if use_32bit else 2
|
|
465
|
+
magic = _TERMINFO_MAGIC2 if use_32bit else _TERMINFO_MAGIC
|
|
466
|
+
num_data = bytearray(num_max * num_entry_size)
|
|
467
|
+
for idx, val in num_entries:
|
|
468
|
+
if use_32bit:
|
|
469
|
+
struct.pack_into("<i", num_data, idx * 4, val)
|
|
470
|
+
else:
|
|
471
|
+
pack_short_le(num_data, idx * 2, val)
|
|
472
|
+
num_used = set(idx for idx, _ in num_entries)
|
|
473
|
+
for i in range(num_max):
|
|
474
|
+
if i not in num_used:
|
|
475
|
+
if use_32bit:
|
|
476
|
+
struct.pack_into("<i", num_data, i * 4, _SENTINEL_ABSENT)
|
|
477
|
+
else:
|
|
478
|
+
pack_short_le(num_data, i * 2, _SENTINEL_ABSENT)
|
|
479
|
+
str_entries = [(_STR_INDEX[cap], val.encode("utf-8", errors="replace"))
|
|
480
|
+
for cap, val in str_caps.items() if cap in _STR_INDEX]
|
|
481
|
+
str_max = max(idx for idx, _ in str_entries) + 1 if str_entries else 0
|
|
482
|
+
str_table_parts = []
|
|
483
|
+
offsets = {}
|
|
484
|
+
for idx, raw in sorted(str_entries):
|
|
485
|
+
offsets[idx] = len(b"".join(str_table_parts))
|
|
486
|
+
str_table_parts.append(raw + b"\x00")
|
|
487
|
+
str_table = b"".join(str_table_parts)
|
|
488
|
+
offset_data = bytearray(str_max * 2)
|
|
489
|
+
for i in range(str_max):
|
|
490
|
+
if i in offsets:
|
|
491
|
+
pack_short_le(offset_data, i * 2, offsets[i])
|
|
492
|
+
else:
|
|
493
|
+
pack_short_le(offset_data, i * 2, _SENTINEL_ABSENT)
|
|
494
|
+
header = bytearray(12)
|
|
495
|
+
pack_short_le(header, 0, magic)
|
|
496
|
+
pack_short_le(header, 2, name_size)
|
|
497
|
+
pack_short_le(header, 4, bool_max)
|
|
498
|
+
pack_short_le(header, 6, num_max)
|
|
499
|
+
pack_short_le(header, 8, str_max)
|
|
500
|
+
pack_short_le(header, 10, len(str_table))
|
|
501
|
+
parts = [bytes(header), names_bytes, bytes(bool_data)]
|
|
502
|
+
if (name_size + bool_max) % 2 != 0:
|
|
503
|
+
parts.append(b"\x00")
|
|
504
|
+
parts.append(bytes(num_data))
|
|
505
|
+
parts.append(bytes(offset_data))
|
|
506
|
+
parts.append(str_table)
|
|
507
|
+
return b"".join(parts)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def sanitize_term_name(name):
|
|
511
|
+
cleaned = re.sub(r'[^a-zA-Z0-9._-]', '', name)
|
|
512
|
+
cleaned = cleaned.lstrip('.')
|
|
513
|
+
return cleaned if cleaned else 'unknown'
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def terminfo_file_path(term, base_dir):
|
|
517
|
+
return base_dir / term[0] / term
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def terminfo_installed_at(term, base_dir):
|
|
521
|
+
return terminfo_file_path(term, base_dir).exists()
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def ttyscan_terminfo_dir():
|
|
525
|
+
xdg = os.environ.get(
|
|
526
|
+
"XDG_CONFIG_HOME",
|
|
527
|
+
os.path.join(os.path.expanduser("~"), ".config"),
|
|
528
|
+
)
|
|
529
|
+
return Path(xdg) / "ttyscan" / "terminfo"
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def has_terminfo(term):
|
|
533
|
+
try:
|
|
534
|
+
import curses
|
|
535
|
+
curses.setupterm(term)
|
|
536
|
+
return bool(curses.tigetstr("clear"))
|
|
537
|
+
except Exception:
|
|
538
|
+
return False
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def write_terminfo(term, str_caps, num_caps, bool_caps, dest_dir):
|
|
542
|
+
safe_term = sanitize_term_name(term)
|
|
543
|
+
data = build_terminfo_binary(safe_term, str_caps, num_caps, bool_caps)
|
|
544
|
+
file_path = terminfo_file_path(safe_term, dest_dir)
|
|
545
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
546
|
+
file_path.write_bytes(data)
|
|
547
|
+
return True
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def escape_value(value, terminfo=False):
|
|
551
|
+
simple = {
|
|
552
|
+
"\x1b": "\\E", "\n": "\\n", "\t": "\\t", "\r": "\\r",
|
|
553
|
+
"\b": "\\b", "\f": "\\f", "\\": "\\\\", "^": "\\^",
|
|
554
|
+
}
|
|
555
|
+
if terminfo:
|
|
556
|
+
simple[" "] = "\\s"
|
|
557
|
+
simple[":"] = "\\:"
|
|
558
|
+
result = []
|
|
559
|
+
for ch in value:
|
|
560
|
+
if ch in simple:
|
|
561
|
+
result.append(simple[ch])
|
|
562
|
+
continue
|
|
563
|
+
code = ord(ch)
|
|
564
|
+
if code < 32:
|
|
565
|
+
result.append(f"^{chr(code + 64)}")
|
|
566
|
+
elif code == 127:
|
|
567
|
+
result.append("^?")
|
|
568
|
+
else:
|
|
569
|
+
result.append(ch)
|
|
570
|
+
return "".join(result)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def shell_escape(value):
|
|
574
|
+
if not value:
|
|
575
|
+
return "''"
|
|
576
|
+
escaped = value.replace("'", "'\\''")
|
|
577
|
+
return f"'{escaped}'"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def build_termcap_entry(term, str_caps, num_caps, bool_caps):
|
|
581
|
+
parts = [f"{term}|XTGETTCAP-discovered terminal:"]
|
|
582
|
+
for cap in sorted(bool_caps):
|
|
583
|
+
tc_name = _TERMINFO_TO_TERMCAP_BOOL.get(cap)
|
|
584
|
+
if tc_name:
|
|
585
|
+
parts.append(f":{tc_name}:")
|
|
586
|
+
for cap in sorted(num_caps):
|
|
587
|
+
tc_name = _TERMINFO_TO_TERMCAP_NUM.get(cap)
|
|
588
|
+
if tc_name:
|
|
589
|
+
parts.append(f":{tc_name}#{num_caps[cap]}:")
|
|
590
|
+
for cap in sorted(str_caps):
|
|
591
|
+
tc_name = _TERMINFO_TO_TERMCAP_STR.get(cap)
|
|
592
|
+
if tc_name:
|
|
593
|
+
value = escape_value(str_caps[cap])
|
|
594
|
+
parts.append(f":{tc_name}={value}:")
|
|
595
|
+
return "".join(parts)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def normalize_terminal_name(raw):
|
|
599
|
+
if raw == "WezTerm":
|
|
600
|
+
return "wezterm"
|
|
601
|
+
return raw
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def has_meaningful_caps(str_caps, num_caps, bool_caps):
|
|
605
|
+
screen_str_caps = {k for k in str_caps if not k.startswith('k')}
|
|
606
|
+
if screen_str_caps:
|
|
607
|
+
return True
|
|
608
|
+
if bool_caps:
|
|
609
|
+
return True
|
|
610
|
+
meaningful_nums = {k for k in num_caps if k not in ('colors', 'Co')}
|
|
611
|
+
if meaningful_nums:
|
|
612
|
+
return True
|
|
613
|
+
return False
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def check_colorterm(caps, force):
|
|
617
|
+
rgb = caps.get("RGB", "")
|
|
618
|
+
try:
|
|
619
|
+
if rgb and int(rgb.split("/", 1)[0]) == 8:
|
|
620
|
+
if force or os.environ.get("COLORTERM") != "truecolor":
|
|
621
|
+
return "export COLORTERM=truecolor"
|
|
622
|
+
except ValueError:
|
|
623
|
+
pass
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def check_term(caps, force):
|
|
628
|
+
tn = caps.get("TN")
|
|
629
|
+
if tn:
|
|
630
|
+
tn = normalize_terminal_name(tn)
|
|
631
|
+
if force or tn != os.environ.get("TERM"):
|
|
632
|
+
return f"export TERM={tn}"
|
|
633
|
+
return None
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def check_lines_columns(rows, cols, winsize, force):
|
|
637
|
+
term_cols, term_rows = winsize
|
|
638
|
+
if not force and (rows, cols) == (term_rows, term_cols):
|
|
639
|
+
if ((env_lines := os.environ.get("LINES")) is None or env_lines == str(rows)) and \
|
|
640
|
+
((env_cols := os.environ.get("COLUMNS")) is None or env_cols == str(cols)):
|
|
641
|
+
return None
|
|
642
|
+
exports = []
|
|
643
|
+
env_lines = os.environ.get("LINES")
|
|
644
|
+
env_cols = os.environ.get("COLUMNS")
|
|
645
|
+
if force or env_lines != str(rows):
|
|
646
|
+
exports.append(f"export LINES={rows}")
|
|
647
|
+
if force or env_cols != str(cols):
|
|
648
|
+
exports.append(f"export COLUMNS={cols}")
|
|
649
|
+
return exports or None
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def generate_exports(verbose_enabled=False, force=False, termcap=False):
|
|
653
|
+
verbose("probing terminal via XTGETTCAP ...", verbose_enabled)
|
|
654
|
+
|
|
655
|
+
r_fd, w_fd = open_tty()
|
|
656
|
+
if r_fd is None or w_fd is None:
|
|
657
|
+
verbose("no terminal available", verbose_enabled)
|
|
658
|
+
return []
|
|
659
|
+
|
|
660
|
+
saved = set_cbreak(r_fd)
|
|
661
|
+
try:
|
|
662
|
+
winsize = get_winsize(r_fd)
|
|
663
|
+
|
|
664
|
+
init_caps = xtgettcap_query(r_fd, w_fd, _INIT_XTGETTCAP_CAPS,
|
|
665
|
+
timeout=_TTYSCAN_QUERY_TIMEOUT)
|
|
666
|
+
if not init_caps:
|
|
667
|
+
verbose("XTGETTCAP not supported by this terminal", verbose_enabled)
|
|
668
|
+
return []
|
|
669
|
+
|
|
670
|
+
tn_raw = init_caps.get("TN")
|
|
671
|
+
if not tn_raw:
|
|
672
|
+
verbose("no TN (terminal name) capability, cannot export further",
|
|
673
|
+
verbose_enabled)
|
|
674
|
+
return []
|
|
675
|
+
|
|
676
|
+
tn = normalize_terminal_name(tn_raw)
|
|
677
|
+
|
|
678
|
+
exports = []
|
|
679
|
+
|
|
680
|
+
ct_export = check_colorterm(init_caps, force)
|
|
681
|
+
if ct_export:
|
|
682
|
+
exports.append(ct_export)
|
|
683
|
+
|
|
684
|
+
term_export = check_term(init_caps, force)
|
|
685
|
+
if term_export:
|
|
686
|
+
exports.append(term_export)
|
|
687
|
+
|
|
688
|
+
size = detect_size(r_fd, w_fd, verbose_enabled)
|
|
689
|
+
if size:
|
|
690
|
+
rows, cols, source = size
|
|
691
|
+
term_cols, term_rows = winsize
|
|
692
|
+
if source != 'inband' and (
|
|
693
|
+
rows == 999 or cols == 999
|
|
694
|
+
or rows < term_rows or cols < term_cols
|
|
695
|
+
):
|
|
696
|
+
verbose(
|
|
697
|
+
f"rejecting CPR size {rows}x{cols} "
|
|
698
|
+
f"(term reports {term_rows}x{term_cols})",
|
|
699
|
+
verbose_enabled,
|
|
700
|
+
)
|
|
701
|
+
else:
|
|
702
|
+
lc = check_lines_columns(rows, cols, winsize, force)
|
|
703
|
+
if lc:
|
|
704
|
+
exports.extend(lc)
|
|
705
|
+
|
|
706
|
+
if not force and not termcap and has_terminfo(tn):
|
|
707
|
+
verbose(f"XTGETTCAP supported, terminal: {tn}", verbose_enabled)
|
|
708
|
+
verbose("terminfo already available in system database", verbose_enabled)
|
|
709
|
+
return exports
|
|
710
|
+
|
|
711
|
+
remaining_caps = [c for c in _FULL_XTGETTCAP_CAPS
|
|
712
|
+
if c not in _INIT_XTGETTCAP_CAPS]
|
|
713
|
+
t0 = time.monotonic()
|
|
714
|
+
full_caps = xtgettcap_query(r_fd, w_fd, remaining_caps,
|
|
715
|
+
timeout=_TTYSCAN_QUERY_TIMEOUT)
|
|
716
|
+
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
|
717
|
+
|
|
718
|
+
full_caps.update(init_caps)
|
|
719
|
+
classified = classify_caps(full_caps)
|
|
720
|
+
str_caps = classified['str_caps']
|
|
721
|
+
num_caps = classified['num_caps']
|
|
722
|
+
bool_caps = classified['bool_caps']
|
|
723
|
+
|
|
724
|
+
n_caps = len(full_caps)
|
|
725
|
+
verbose(
|
|
726
|
+
f"XTGETTCAP supported, terminal: {tn}; "
|
|
727
|
+
f"received {n_caps} caps "
|
|
728
|
+
f"({len(bool_caps)} bool, {len(num_caps)} num, {len(str_caps)} str) "
|
|
729
|
+
f"in {elapsed_ms}ms",
|
|
730
|
+
verbose_enabled,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
if not has_meaningful_caps(str_caps, num_caps, bool_caps):
|
|
734
|
+
verbose(
|
|
735
|
+
"only bare-minimum capabilities (TN/colors/RGB), "
|
|
736
|
+
"skipping TERMINFO/TERMCAP export",
|
|
737
|
+
verbose_enabled,
|
|
738
|
+
)
|
|
739
|
+
return exports
|
|
740
|
+
|
|
741
|
+
terminfo_dir = ttyscan_terminfo_dir()
|
|
742
|
+
safe_term = sanitize_term_name(tn)
|
|
743
|
+
|
|
744
|
+
if not has_terminfo(tn) or force:
|
|
745
|
+
write_terminfo(tn, str_caps, num_caps, bool_caps, terminfo_dir)
|
|
746
|
+
verbose(f"writing {terminfo_file_path(safe_term, terminfo_dir)}",
|
|
747
|
+
verbose_enabled)
|
|
748
|
+
terminfo_value = str(terminfo_dir)
|
|
749
|
+
current_terminfo = os.environ.get("TERMINFO")
|
|
750
|
+
if force or current_terminfo != terminfo_value:
|
|
751
|
+
exports.append(f"export TERMINFO={terminfo_value}")
|
|
752
|
+
else:
|
|
753
|
+
verbose("terminfo already available in system database", verbose_enabled)
|
|
754
|
+
|
|
755
|
+
if termcap:
|
|
756
|
+
entry = build_termcap_entry(tn, str_caps, num_caps, bool_caps)
|
|
757
|
+
if entry and (force or os.environ.get("TERMCAP") != entry):
|
|
758
|
+
exports.append(f"export TERMCAP={shell_escape(entry)}")
|
|
759
|
+
|
|
760
|
+
return exports
|
|
761
|
+
|
|
762
|
+
finally:
|
|
763
|
+
restore_termios(r_fd, saved)
|
|
764
|
+
if r_fd is not None:
|
|
765
|
+
try:
|
|
766
|
+
os.close(r_fd)
|
|
767
|
+
except OSError:
|
|
768
|
+
pass
|
|
769
|
+
if w_fd is not None:
|
|
770
|
+
try:
|
|
771
|
+
os.close(w_fd)
|
|
772
|
+
except OSError:
|
|
773
|
+
pass
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def main(argv=None):
|
|
777
|
+
parser = argparse.ArgumentParser(
|
|
778
|
+
prog="ttyscan",
|
|
779
|
+
description="Export terminal capabilities discovered via XTGETTCAP",
|
|
780
|
+
)
|
|
781
|
+
parser.add_argument(
|
|
782
|
+
"-v", "--verbose", action="store_true",
|
|
783
|
+
help="Print diagnostic information to stderr",
|
|
784
|
+
)
|
|
785
|
+
parser.add_argument(
|
|
786
|
+
"-f", "--force", action="store_true",
|
|
787
|
+
help="Force export of all values even if unchanged",
|
|
788
|
+
)
|
|
789
|
+
parser.add_argument(
|
|
790
|
+
"-t", "--termcap", action="store_true",
|
|
791
|
+
help="Also export TERMCAP value",
|
|
792
|
+
)
|
|
793
|
+
args = parser.parse_args(argv)
|
|
794
|
+
|
|
795
|
+
exports = generate_exports(
|
|
796
|
+
verbose_enabled=args.verbose,
|
|
797
|
+
force=args.force,
|
|
798
|
+
termcap=args.termcap,
|
|
799
|
+
)
|
|
800
|
+
for line in exports:
|
|
801
|
+
print(line)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
if __name__ == '__main__':
|
|
805
|
+
main()
|