diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index e158528cd..524cbd332 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -40,7 +40,7 @@ jobs: run: python -m twine check --strict dist/* - name: Test installed wheel run: | - python -m pip install dist/*.whl + python -m pip install "$(echo dist/*.whl)[tui]" python -I tests/test_qltool.py InstalledQltool_Test -v - uses: actions/upload-artifact@v4 with: diff --git a/ARCHITECTURE/modules/cli-build.md b/ARCHITECTURE/modules/cli-build.md index 89b5bd752..5b00911d4 100644 --- a/ARCHITECTURE/modules/cli-build.md +++ b/ARCHITECTURE/modules/cli-build.md @@ -31,8 +31,10 @@ examples/tests belongs to its runtime owner. Use the [root baseline](../../ARCHITECTURE.md#code-conventions). `argparse` actions convert enum/environment inputs and build `Qiling` keyword -arguments. Preserve option names and exit status. TUI uses questionary/fx/ -termcolor; testing its import is not testing its prompts. Keep build metadata +arguments. Preserve option names and exit status. TUI uses questionary/termcolor/fx, all +shipped in the `tui` extra and absent from a default install; `qiling.cli` +must degrade with an install hint rather than traceback when it is missing. +Testing its import is not testing its prompts. Keep build metadata in the manifest, resolve its lock with Poetry, and retain package license and resource attribution. Documentation here contains coding context only. diff --git a/ARCHITECTURE/modules/extensions.md b/ARCHITECTURE/modules/extensions.md index 7f31a30e1..1a450b8ac 100644 --- a/ARCHITECTURE/modules/extensions.md +++ b/ARCHITECTURE/modules/extensions.md @@ -30,7 +30,7 @@ paths are explicitly owned by hardware/bare-metal modules. ## Local Conventions Use [root conventions](../../ARCHITECTURE.md#code-conventions); optional -packages use manifest extras `fuzz` and `RE`. Format classes expose +packages use manifest extras `fuzz` and `RE` (the terminal UI uses `tui`). Format classes expose `FORMAT_NAME` and activate/deactivate/dump methods; imports register subclasses with factories. No common C standard is declared for fuzz harnesses; inspect the local `fuzz.c`/build instructions. Generated SDK stubs require review; diff --git a/README.md b/README.md index 668d335e0..93fcf5e18 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,8 @@ Qiling also provides a friendly tool named `qltool` to quickly emulate shellcode Installing Qiling with pip also installs the `qltool` command, including `qltool qltui` for the terminal UI. In a source checkout, use `./qltool` as shown below. Example binaries and rootfs files must be downloaded separately. +The `qltool qltui` terminal UI is an optional extra: install it with +`pip install qiling[tui]`. With qltool, easy execution can be performed: diff --git a/poetry.lock b/poetry.lock index 2d99b4731..341300c4d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,12 +1,13 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "antlr4-python3-runtime" version = "4.13.2" description = "ANTLR 4.13.2 runtime for Python 3" -optional = false +optional = true python-versions = "*" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "antlr4_python3_runtime-4.13.2-py3-none-any.whl", hash = "sha256:fe3835eb8d33daece0e799090eda89719dbccee7aa39ef94eed3818cafa5a7e8"}, {file = "antlr4_python3_runtime-4.13.2.tar.gz", hash = "sha256:909b647e1d2fc2b70180ac586df3933e38919c85f98ccc656a96cd3f25ef3916"}, @@ -16,9 +17,10 @@ files = [ name = "asciimatics" version = "1.15.0" description = "A cross-platform package to replace curses (mouse/keyboard input & text colours/positioning) and create ASCII animations" -optional = false +optional = true python-versions = ">= 3.8" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "asciimatics-1.15.0-py3-none-any.whl", hash = "sha256:0fe068a6bed522929bd04bb5b8a2fb6ebf0aef1b7a9b3843cf71030a34bc38d5"}, {file = "asciimatics-1.15.0.tar.gz", hash = "sha256:cfdd398042727519d8b73e62b8ef82c0becfed4eb420899c3b96c98d0b96821a"}, @@ -136,9 +138,10 @@ pycparser = "*" name = "click" version = "8.1.7" description = "Composable command line interface toolkit" -optional = false +optional = true python-versions = ">=3.7" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, @@ -151,10 +154,10 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -optional = false +optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main"] -markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" +markers = "extra == \"tui\" and sys_platform == \"win32\" or extra == \"tui\" and platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -164,9 +167,10 @@ files = [ name = "dacite" version = "1.8.1" description = "Simple creation of data classes from dictionaries." -optional = false +optional = true python-versions = ">=3.6" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "dacite-1.8.1-py3-none-any.whl", hash = "sha256:cc31ad6fdea1f49962ea42db9421772afe01ac5442380d9a99fcf3d188c61afe"}, ] @@ -206,9 +210,10 @@ files = [ name = "first" version = "2.0.2" description = "Return the first true value of an iterable." -optional = false +optional = true python-versions = "*" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "first-2.0.2-py2.py3-none-any.whl", hash = "sha256:8d8e46e115ea8ac652c76123c0865e3ff18372aef6f03c22809ceefcea9dec86"}, {file = "first-2.0.2.tar.gz", hash = "sha256:ff285b08c55f8c97ce4ea7012743af2495c9f1291785f163722bd36f6af6d3bf"}, @@ -369,9 +374,10 @@ test = ["objgraph", "psutil", "setuptools"] name = "jsonpath-ng" version = "1.6.1" description = "A final implementation of JSONPath for Python that aims to be standard compliant, including arithmetic and binary comparison operators and providing clear AST for metaprogramming." -optional = false +optional = true python-versions = "*" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "jsonpath-ng-1.6.1.tar.gz", hash = "sha256:086c37ba4917304850bd837aeab806670224d3f038fe2833ff593a672ef0a5fa"}, {file = "jsonpath_ng-1.6.1-py3-none-any.whl", hash = "sha256:8f22cd8273d7772eea9aaa84d922e0841aa36fdb8a2c6b7f6c3791a16a9bc0be"}, @@ -400,9 +406,10 @@ files = [ name = "loguru" version = "0.7.2" description = "Python logging made (stupidly) simple" -optional = false +optional = true python-versions = ">=3.5" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"}, {file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"}, @@ -413,7 +420,7 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} [package.extras] -dev = ["Sphinx (==7.2.5) ; python_version >= \"3.9\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.2.2) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "mypy (==v1.5.1) ; python_version >= \"3.8\"", "pre-commit (==3.4.0) ; python_version >= \"3.8\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==7.4.0) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==4.1.0) ; python_version >= \"3.8\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.0.0) ; python_version >= \"3.8\"", "sphinx-autobuild (==2021.3.14) ; python_version >= \"3.9\"", "sphinx-rtd-theme (==1.3.0) ; python_version >= \"3.9\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.11.0) ; python_version >= \"3.8\""] +dev = ["Sphinx (==7.2.5) ; python_version >= \"3.9\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.2.2) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "mypy (==1.5.1) ; python_version >= \"3.8\"", "pre-commit (==3.4.0) ; python_version >= \"3.8\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==7.4.0) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==4.1.0) ; python_version >= \"3.8\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.0.0) ; python_version >= \"3.8\"", "sphinx-autobuild (==2021.3.14) ; python_version >= \"3.9\"", "sphinx-rtd-theme (==1.3.0) ; python_version >= \"3.9\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.11.0) ; python_version >= \"3.8\""] [[package]] name = "multiprocess" @@ -448,9 +455,10 @@ dill = ">=0.3.9" name = "overrides" version = "7.7.0" description = "A decorator to automatically detect mismatch when overriding a method." -optional = false +optional = true python-versions = ">=3.6" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49"}, {file = "overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a"}, @@ -472,9 +480,10 @@ files = [ name = "pillow" version = "10.4.0" description = "Python Imaging Library (Fork)" -optional = false +optional = true python-versions = ">=3.8" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, @@ -570,9 +579,10 @@ xmp = ["defusedxml"] name = "ply" version = "3.11" description = "Python Lex & Yacc" -optional = false +optional = true python-versions = "*" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce"}, {file = "ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3"}, @@ -582,9 +592,10 @@ files = [ name = "prompt-toolkit" version = "3.0.50" description = "Library for building powerful interactive command lines in Python" -optional = false +optional = true python-versions = ">=3.8.0" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198"}, {file = "prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab"}, @@ -622,9 +633,10 @@ files = [ name = "pyfiglet" version = "1.0.2" description = "Pure-python FIGlet implementation" -optional = false +optional = true python-versions = ">=3.9" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "pyfiglet-1.0.2-py3-none-any.whl", hash = "sha256:889b351d79c99e50a3f619c8f8e6ffdb27fd8c939fc43ecbd7559bd57d5f93ea"}, {file = "pyfiglet-1.0.2.tar.gz", hash = "sha256:758788018ab8faaddc0984e1ea05ff330d3c64be663c513cc1f105f6a3066dab"}, @@ -634,9 +646,10 @@ files = [ name = "pyperclip" version = "1.9.0" description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" -optional = false +optional = true python-versions = "*" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "pyperclip-1.9.0.tar.gz", hash = "sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310"}, ] @@ -645,9 +658,10 @@ files = [ name = "python-fx" version = "0.3.2" description = "A python-native fx-alike terminal JSON viewer." -optional = false +optional = true python-versions = ">=3.8" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "python_fx-0.3.2-py3-none-any.whl", hash = "sha256:5498475b0f391b1649732328b58d188d9fc4b3f90f5bfb77d5c6e2ece2432c5f"}, {file = "python_fx-0.3.2.tar.gz", hash = "sha256:9646f58c716e2db6698bff3dfa55fa721b8b0cb741506287a87bc08055a96ceb"}, @@ -693,10 +707,10 @@ unicodecsv = "*" name = "pywin32" version = "308" description = "Python for Window Extensions" -optional = false +optional = true python-versions = "*" groups = ["main"] -markers = "sys_platform == \"win32\"" +markers = "extra == \"tui\" and sys_platform == \"win32\"" files = [ {file = "pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e"}, {file = "pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e"}, @@ -785,9 +799,10 @@ files = [ name = "questionary" version = "2.1.0" description = "Python library to build pretty command line user prompts ⭐️" -optional = false +optional = true python-versions = ">=3.8" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec"}, {file = "questionary-2.1.0.tar.gz", hash = "sha256:6302cdd645b19667d8f6e6634774e9538bfcd1aad9be287e743d96cacaf95587"}, @@ -835,9 +850,10 @@ type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.deve name = "termcolor" version = "2.4.0" description = "ANSI color formatting for output in terminal" -optional = false +optional = true python-versions = ">=3.8" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63"}, {file = "termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a"}, @@ -850,9 +866,10 @@ tests = ["pytest", "pytest-cov"] name = "typing-extensions" version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false +optional = true python-versions = ">=3.8" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -979,9 +996,10 @@ unicorn = ">=2.0.1" name = "urwid" version = "2.6.15" description = "A full-featured console (xterm et al.) user interface library" -optional = false +optional = true python-versions = ">3.7" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "urwid-2.6.15-py3-none-any.whl", hash = "sha256:71b3171cabaa0092902f556768756bd2f2ebb24c0da287ee08f081d235340cb7"}, {file = "urwid-2.6.15.tar.gz", hash = "sha256:9ecc57330d88c8d9663ffd7092a681674c03ff794b6330ccfef479af7aa9671b"}, @@ -1005,9 +1023,10 @@ zmq = ["zmq"] name = "wcwidth" version = "0.2.13" description = "Measures the displayed width of unicode strings in a terminal" -optional = false +optional = true python-versions = "*" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, @@ -1017,10 +1036,10 @@ files = [ name = "win32-setctime" version = "1.2.0" description = "A small Python utility to set file creation time on Windows" -optional = false +optional = true python-versions = ">=3.5" groups = ["main"] -markers = "sys_platform == \"win32\"" +markers = "extra == \"tui\" and sys_platform == \"win32\"" files = [ {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, @@ -1060,9 +1079,10 @@ files = [ name = "yamale" version = "5.2.1" description = "A schema and validator for YAML." -optional = false +optional = true python-versions = ">=3.8" groups = ["main"] +markers = "extra == \"tui\"" files = [ {file = "yamale-5.2.1-py3-none-any.whl", hash = "sha256:e44cd30cf3055ee4b34c1c71d6fe35490a127dcbd36f82f27859d105a9989922"}, {file = "yamale-5.2.1.tar.gz", hash = "sha256:19bbe713d588f07177bc519a46070c0793ed126ea37f425a76055b99703f835a"}, @@ -1148,8 +1168,9 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [extras] fuzz = ["fuzzercorn", "unicornafl"] re = ["r2libr"] +tui = ["python-fx", "questionary", "termcolor"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "b65631e55580e06f60d535d41d2c2d0c4ee786609c7232988ceda8ce6b586a6d" +content-hash = "bc9783619a4bf66de75def1766aee086720d95784c0fba2f6197a7067abec04e" diff --git a/pyproject.toml b/pyproject.toml index cc6a6252b..847811368 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,18 +45,20 @@ gevent = ">=24.10" multiprocess = ">=0.70.12.2" windows-curses = { version = "^2.1.0", markers = "platform_system == 'Windows'" } pyyaml = "^6.0.1" -python-fx = "*" -questionary = "*" -termcolor = "*" unicornafl = { version = "^2.0.0", markers = "platform_system != 'Windows'", optional = true } fuzzercorn = { version = "^0.0.1", markers = "platform_system == 'Linux'", optional = true } r2libr = { version = "^5.7.4", optional = true } +python-fx = { version = "*", optional = true } +questionary = { version = "*", optional = true } +termcolor = { version = "*", optional = true } + [tool.poetry.extras] fuzz = ["unicornafl", "fuzzercorn"] RE = ["r2libr"] +tui = ["python-fx", "questionary", "termcolor"] [build-system] requires = ["poetry-core>=2.0,<3.0"] diff --git a/qiling/cli.py b/qiling/cli.py index a8e0a0df9..a7a3fd0cc 100644 --- a/qiling/cli.py +++ b/qiling/cli.py @@ -246,7 +246,11 @@ def run(): ql_args = {} if options.subcommand == 'qltui': - import qltui + try: + import qltui + except ImportError: + parser.error('the qltui subcommand requires the "tui" extra; install it with: pip install qiling[tui]') + options = qltui.get_data() qltui_enabled = True diff --git a/qltui.py b/qltui.py index 917e15083..b6e1ef507 100644 --- a/qltui.py +++ b/qltui.py @@ -5,7 +5,6 @@ import argparse import json -from pyfx import PyfxApp from pprint import pprint from datetime import datetime @@ -473,7 +472,12 @@ def show_report(ql: Qiling, report, hook_dictionary): if command == 'report': pprint(report) elif command == 'interactive report': - PyfxApp(data=report).run() + try: + from pyfx import PyfxApp + except ImportError: + print('the interactive report viewer is not installed; get it with: pip install qiling[tui]') + else: + PyfxApp(data=report).run() elif command == 'save to json': time = datetime.now().strftime("%Y_%m_%d_%H-%M-%S") report_name = f"report_{ql.targetname.replace('.', '_')}_{os_name}_{arch_name}_{time}.json" diff --git a/tests/test_qltool.py b/tests/test_qltool.py index 9949b90f7..5bf45e3d7 100644 --- a/tests/test_qltool.py +++ b/tests/test_qltool.py @@ -83,12 +83,39 @@ def test_qltool_shellcode_exit_status(self): def test_qltui_import(self): result = subprocess.run( - [sys.executable, '-I', '-c', 'import qltui; assert callable(qltui.get_data)'], + [sys.executable, '-I', '-c', + 'import sys, qltui; assert callable(qltui.get_data); assert "pyfx" not in sys.modules'], cwd=self.cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30 ) self.assertEqual(result.returncode, 0, result.stdout) + def test_qltui_without_tui_extra(self): + """Without the "tui" extra, `qltool qltui` explains how to install it.""" + + # hide questionary from the interpreter to emulate an install that did + # not ask for the "tui" extra + script = ( + 'import sys\n' + 'class Hide:\n' + ' def find_spec(self, name, path=None, target=None):\n' + ' if name == "questionary":\n' + ' raise ModuleNotFoundError(name)\n' + 'sys.meta_path.insert(0, Hide())\n' + 'sys.argv = ["qltool", "qltui"]\n' + 'from qiling.cli import run\n' + 'run()\n' + ) + + result = subprocess.run( + [sys.executable, '-I', '-c', script], + cwd=self.cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, timeout=30 + ) + + self.assertEqual(result.returncode, 2, result.stdout) + self.assertIn('pip install qiling[tui]', result.stdout) + if __name__ == "__main__": unittest.main()