├── .github ├── dependabot.yml ├── scripts │ └── copy_winpty.sh └── workflows │ ├── linux_sdist.yml │ ├── windows_build.yml │ └── windows_release.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── RELEASE.md ├── pyproject.toml ├── runtests.py ├── src └── lib.rs └── winpty ├── __init__.py ├── enums.py ├── ptyprocess.py ├── tests ├── __init__.py ├── test_pty.py └── test_ptyprocess.py └── winpty.pyi /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | # Files stored in repository root 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | allow: 9 | - dependency-type: "all" 10 | 11 | - package-ecosystem: "github-actions" 12 | # Workflow files stored in the 13 | # default location of `.github/workflows` 14 | directory: "/" 15 | schedule: 16 | interval: "daily" 17 | allow: 18 | - dependency-type: "all" 19 | -------------------------------------------------------------------------------- /.github/scripts/copy_winpty.sh: -------------------------------------------------------------------------------- 1 | set -eoux 2 | 3 | python_exec=$(which python) 4 | bin_path=$(dirname $python_exec) 5 | 6 | # Patch gitignore in order to add binaries 7 | sed -i '/[.]exe/c\' .gitignore 8 | sed -i '/[.]dll/c\' .gitignore 9 | 10 | # Copy WinPTY binaries to the main library directory 11 | cp "$bin_path/Library/bin/winpty.dll" winpty 12 | cp "$bin_path/Library/bin/winpty-agent.exe" winpty 13 | -------------------------------------------------------------------------------- /.github/workflows/linux_sdist.yml: -------------------------------------------------------------------------------- 1 | name: Linux sdist 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | linux: 10 | name: Linux Py${{ matrix.PYTHON_VERSION }} 11 | runs-on: ubuntu-latest 12 | env: 13 | PYTHON_VERSION: ${{ matrix.PYTHON_VERSION }} 14 | RUNNER_OS: "linux" 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | PYTHON_VERSION: ["3.11"] 19 | steps: 20 | - name: Checkout branch 21 | uses: actions/checkout@v4 22 | - name: Install latest Rust stable 23 | uses: actions-rs/toolchain@v1 24 | with: 25 | toolchain: stable 26 | target: x86_64-pc-windows-msvc 27 | override: true 28 | components: rustfmt, clippy 29 | - name: Install miniconda 30 | uses: conda-incubator/setup-miniconda@v3 31 | with: 32 | auto-update-conda: true 33 | activate-environment: test 34 | channels: conda-forge,defaults 35 | python-version: ${{ matrix.PYTHON_VERSION }} 36 | - name: Install twine/maturin 37 | shell: bash -l {0} 38 | run: pip install twine maturin 39 | - name: Build sdist distribution 40 | shell: bash -l {0} 41 | run: maturin sdist 42 | - name: Upload to PyPi 43 | shell: bash -l {0} 44 | env: 45 | TWINE_PASSWORD: ${{secrets.MATURIN_PASSWORD}} 46 | TWINE_USERNAME: ${{secrets.MATURIN_USERNAME}} 47 | run: twine upload target/wheels/*.tar.gz 48 | -------------------------------------------------------------------------------- /.github/workflows/windows_build.yml: -------------------------------------------------------------------------------- 1 | name: Windows tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | jobs: 11 | windows: 12 | name: Windows Py${{ matrix.PYTHON_VERSION }} 13 | runs-on: windows-latest 14 | timeout-minutes: 10 15 | env: 16 | PYTHON_VERSION: ${{ matrix.PYTHON_VERSION }} 17 | RUNNER_OS: "windows" 18 | PYWINPTY_BLOCK: "1" 19 | CI_RUNNING: "1" 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | PYTHON_VERSION: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.13t"] 24 | steps: 25 | - name: Checkout branch 26 | uses: actions/checkout@v4 27 | - name: Install latest Rust stable 28 | uses: actions-rs/toolchain@v1 29 | with: 30 | toolchain: stable 31 | target: x86_64-pc-windows-msvc 32 | override: true 33 | components: rustfmt, clippy 34 | - name: Remove free-threaded suffix from version 35 | env: 36 | PYTHON_VERSION: ${{ matrix.PYTHON_VERSION }} 37 | shell: bash -l {0} 38 | run: | 39 | PYTHON_VERSION_NONSUFFIX=$PYTHON_VERSION 40 | if [[ $PYTHON_VERSION = *t ]]; then 41 | PYTHON_VERSION_NONSUFFIX=${PYTHON_VERSION//t} 42 | echo "PYTHON_VERSION_NONSUFFIX=$PYTHON_VERSION_NONSUFFIX" >> $GITHUB_ENV 43 | fi 44 | echo "PYTHON_VERSION_NONSUFFIX=$PYTHON_VERSION_NONSUFFIX" >> $GITHUB_ENV 45 | - name: Install miniconda 46 | uses: conda-incubator/setup-miniconda@v3 47 | with: 48 | auto-update-conda: true 49 | activate-environment: test 50 | channels: conda-forge,defaults 51 | python-version: ${{ env.PYTHON_VERSION_NONSUFFIX }} 52 | - name: Reinstall free-threaded Python 53 | if: ${{ endsWith(matrix.PYTHON_VERSION, 't') }} 54 | shell: bash -l {0} 55 | run: | 56 | conda install --override-channels -c conda-forge python-freethreading 57 | - name: Conda env info 58 | shell: bash -l {0} 59 | run: conda env list 60 | - name: Install winpty 61 | shell: bash -l {0} 62 | run: conda install -y winpty 63 | - name: Install build/test dependencies 64 | shell: bash -l {0} 65 | run: pip install maturin toml pytest flaky 66 | - name: Build pywinpty 67 | shell: bash -l {0} 68 | run: maturin develop 69 | - name: Run tests 70 | shell: pwsh 71 | run: python runtests.py 72 | # Enable this to get RDP access to the worker. 73 | # - name: Download 74 | # # if: ${{ failure() }} 75 | # run: Invoke-WebRequest https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-windows-amd64.zip -OutFile ngrok.zip 76 | # - name: Extract 77 | # # if: ${{ failure() }} 78 | # run: Expand-Archive ngrok.zip 79 | # - name: Auth 80 | # # if: ${{ failure() }} 81 | # run: .\ngrok\ngrok.exe authtoken 1raaG4z7gsaCRlLw8cRkUWW6ItF_2LWTUFxXwd6UeeJNAAAci 82 | # - name: Enable TS 83 | # # if: ${{ failure() }} 84 | # run: Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server'-name "fDenyTSConnections" -Value 0 85 | # - run: Enable-NetFirewallRule -DisplayGroup "Remote Desktop" 86 | # # if: ${{ failure() }} 87 | # - run: Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -name "UserAuthentication" -Value 1 88 | # # if: ${{ failure() }} 89 | # - run: Set-LocalUser -Name "runneradmin" -Password (ConvertTo-SecureString -AsPlainText "P@ssw0rd!" -Force) 90 | # # if: ${{ failure() }} 91 | # - name: Create Tunnel 92 | # # if: ${{ failure() }} 93 | # run: .\ngrok\ngrok.exe tcp 3389 94 | -------------------------------------------------------------------------------- /.github/workflows/windows_release.yml: -------------------------------------------------------------------------------- 1 | name: Windows release 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | windows: 10 | name: Windows Py${{ matrix.PYTHON_VERSION }} 11 | runs-on: windows-latest 12 | env: 13 | PYTHON_VERSION: ${{ matrix.PYTHON_VERSION }} 14 | RUNNER_OS: "windows" 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | PYTHON_VERSION: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.13t"] 19 | steps: 20 | - name: Checkout branch 21 | uses: actions/checkout@v4 22 | - name: Install latest Rust stable 23 | uses: actions-rs/toolchain@v1 24 | with: 25 | toolchain: stable 26 | target: x86_64-pc-windows-msvc 27 | override: true 28 | components: rustfmt, clippy 29 | - name: Remove free-threaded suffix from version 30 | env: 31 | PYTHON_VERSION: ${{ matrix.PYTHON_VERSION }} 32 | shell: bash -l {0} 33 | run: | 34 | PYTHON_VERSION_NONSUFFIX=$PYTHON_VERSION 35 | if [[ $PYTHON_VERSION = *t ]]; then 36 | PYTHON_VERSION_NONSUFFIX=${PYTHON_VERSION//t} 37 | echo "PYTHON_VERSION_NONSUFFIX=$PYTHON_VERSION_NONSUFFIX" >> $GITHUB_ENV 38 | fi 39 | echo "PYTHON_VERSION_NONSUFFIX=$PYTHON_VERSION_NONSUFFIX" >> $GITHUB_ENV 40 | - name: Install miniconda 41 | uses: conda-incubator/setup-miniconda@v3 42 | with: 43 | auto-update-conda: true 44 | activate-environment: test 45 | channels: conda-forge,defaults 46 | python-version: ${{ env.PYTHON_VERSION_NONSUFFIX }} 47 | - name: Reinstall free-threaded Python 48 | if: ${{ endsWith(matrix.PYTHON_VERSION, 't') }} 49 | shell: bash -l {0} 50 | run: | 51 | conda install --override-channels -c conda-forge python-freethreading 52 | - name: Conda env info 53 | shell: bash -l {0} 54 | run: conda env list 55 | - name: Install winpty 56 | shell: bash -l {0} 57 | run: conda install -y winpty 58 | - name: Install build/test dependencies 59 | shell: bash -l {0} 60 | run: pip install maturin toml 61 | - name: Copy winpty binaries to package 62 | shell: bash -l {0} 63 | run: bash -l .github/scripts/copy_winpty.sh 64 | - name: Build and publish wheels 65 | env: 66 | MATURIN_PASSWORD: ${{secrets.MATURIN_PASSWORD}} 67 | MATURIN_USERNAME: ${{secrets.MATURIN_USERNAME}} 68 | shell: bash -l {0} 69 | run: maturin publish -i $(which python) -u $MATURIN_USERNAME --no-sdist 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | *.installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | *.coverage 42 | .coverage.* 43 | *.cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | *.webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints/ 73 | 74 | # pyenv 75 | *.python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | *.venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | *.spyderproject 93 | .spyproject/ 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # Cython C generated files 105 | cywinpty.c 106 | 107 | # Winpty binaries 108 | *.exe 109 | *.dll 110 | 111 | # Git giles 112 | *.orig 113 | 114 | # Added by cargo 115 | /target 116 | # Cargo.lock 117 | 118 | # Visual Studio files 119 | .vs/ 120 | .vscode/ 121 | CppProperties.json 122 | 123 | # Debug VS files 124 | *.sln -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Version 2.0.15 (2025/02/03) 2 | 3 | 4 | ### Pull Requests Merged 5 | 6 | * [PR 492](https://github.com/andfoy/pywinpty/pull/492) - Add version to pyproject.toml, by [@finnagin](https://github.com/finnagin) 7 | * [PR 488](https://github.com/andfoy/pywinpty/pull/488) - Bump pyo3 from 0.22.5 to 0.23.4, by [@dependabot[bot]](https://github.com/apps/dependabot) ([491](https://github.com/andfoy/pywinpty/issues/491), [](https://github.com//issues/)) 8 | 9 | In this release 2 pull requests were closed. 10 | 11 | 12 | ## Version 2.0.14 (2024/10/17) 13 | 14 | 15 | ### Pull Requests Merged 16 | 17 | * [PR 453](https://github.com/andfoy/pywinpty/pull/453) - Update PyO3 to 0.22.3 and winpty-rs to 0.4.0, by [@andfoy](https://github.com/andfoy) 18 | * [PR 452](https://github.com/andfoy/pywinpty/pull/452) - Add support for Python 3.13, by [@andfoy](https://github.com/andfoy) ([451](https://github.com/andfoy/pywinpty/issues/451)) 19 | 20 | In this release 2 pull requests were closed. 21 | 22 | 23 | ## Version 2.0.13 (2024/02/26) 24 | 25 | 26 | ### Pull Requests Merged 27 | 28 | * [PR 399](https://github.com/andfoy/pywinpty/pull/399) - Bump target-lexicon from 0.12.13 to 0.12.14, by [@dependabot[bot]](https://github.com/apps/dependabot) 29 | * [PR 398](https://github.com/andfoy/pywinpty/pull/398) - Bump winpty-rs from 0.3.14 to 0.3.15, by [@dependabot[bot]](https://github.com/apps/dependabot) 30 | * [PR 397](https://github.com/andfoy/pywinpty/pull/397) - Bump syn from 2.0.43 to 2.0.50, by [@dependabot[bot]](https://github.com/apps/dependabot) 31 | * [PR 396](https://github.com/andfoy/pywinpty/pull/396) - Remove pytest-lazy-fixture from test dependencies, by [@andfoy](https://github.com/andfoy) 32 | * [PR 395](https://github.com/andfoy/pywinpty/pull/395) - Bump rustix from 0.38.30 to 0.38.31, by [@dependabot[bot]](https://github.com/apps/dependabot) 33 | * [PR 393](https://github.com/andfoy/pywinpty/pull/393) - Bump once_cell from 1.18.0 to 1.19.0, by [@dependabot[bot]](https://github.com/apps/dependabot) ([](https://github.com/CIto_string()"] 5 | description = "Pseudo terminal support for Windows from Python." 6 | repository = "https://github.com/spyder-ide/pywinpty" 7 | license = "MIT" 8 | keywords = ["pty", "pseudo-terminal", "conpty", "windows", "winpty"] 9 | readme = "README.md" 10 | edition = "2021" 11 | 12 | [lib] 13 | name = "winpty" 14 | crate-type = ["cdylib"] 15 | 16 | [dependencies] 17 | winpty-rs = "0.4" 18 | 19 | [dependencies.pyo3] 20 | version = "0.23.4" 21 | features = ["extension-module"] 22 | 23 | [package.metadata.docs.rs] 24 | default-target = "x86_64-pc-windows-msvc" 25 | targets = ["x86_64-pc-windows-msvc"] 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Spyder IDE 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. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | global-include *.pyx 3 | global-include *.pxd 4 | global-exclude *.exe 5 | global-exclude *.dll 6 | global-exclude *.pyd -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyWinpty: Pseudoterminals for Windows in Python 2 | 3 | [![Project License - MIT](https://img.shields.io/pypi/l/pywinpty.svg)](./LICENSE.txt) 4 | [![pypi version](https://img.shields.io/pypi/v/pywinpty.svg)](https://pypi.org/project/pywinpty/) 5 | [![conda version](https://img.shields.io/conda/vn/conda-forge/pywinpty.svg)](https://www.anaconda.com/download/) 6 | [![download count](https://img.shields.io/conda/dn/conda-forge/pywinpty.svg)](https://www.anaconda.com/download/) 7 | [![Downloads](https://pepy.tech/badge/pywinpty)](https://pepy.tech/project/pywinpty) 8 | [![PyPI status](https://img.shields.io/pypi/status/pywinpty.svg)](https://github.com/spyder-ide/pywinpty) 9 | [![Windows tests](https://github.com/andfoy/pywinpty/actions/workflows/windows_build.yml/badge.svg)](https://github.com/andfoy/pywinpty/actions/workflows/windows_build.yml) 10 | 11 | *Copyright © 2017–2022 Spyder Project Contributors* 12 | *Copyright © 2022– Edgar Andrés Margffoy Tuay* 13 | 14 | 15 | ## Overview 16 | 17 | PyWinpty allows creating and communicating with Windows processes that receive input and print outputs via console input and output pipes. PyWinpty supports both the native [ConPTY](https://devblogs.microsoft.com/commandline/windows-command-line-introducing-the-windows-pseudo-console-conpty/) interface and the previous, fallback [winpty](https://github.com/rprichard/winpty) library. 18 | 19 | 20 | ## Dependencies 21 | To compile pywinpty sources, you must have [Rust](https://rustup.rs/) installed. 22 | Optionally, you can also have Winpty's C header and library files available on your include path. 23 | 24 | 25 | ## Installation 26 | You can install this library by using conda or pip package managers, as it follows: 27 | 28 | Using conda (Recommended): 29 | ```bash 30 | conda install pywinpty 31 | ``` 32 | 33 | Using pip: 34 | ```bash 35 | pip install pywinpty 36 | ``` 37 | 38 | ## Building from source 39 | 40 | To build from sources, you will require both a working stable or nightly Rust toolchain with 41 | target `x86_64-pc-windows-msvc`, which can be installed using [rustup](https://rustup.rs/). 42 | 43 | Optionally, this library can be linked against winpty library, which you can install using conda-forge: 44 | 45 | ```batch 46 | conda install winpty -c conda-forge 47 | ``` 48 | 49 | If you don't want to use conda, you will need to have the winpty binaries and headers available on your PATH. 50 | 51 | Finally, pywinpty uses [Maturin](https://github.com/PyO3/maturin) as the build backend, which can be installed using `pip`: 52 | 53 | ```batch 54 | pip install maturin 55 | ``` 56 | 57 | To test your compilation environment settings, you can build pywinpty sources locally, by 58 | executing: 59 | 60 | ```bash 61 | maturin develop 62 | ``` 63 | 64 | This package depends on the following Rust crates: 65 | 66 | * [PyO3](https://github.com/PyO3/pyo3): Library used to produce Python bindings from Rust code. 67 | * [WinPTY-rs](https://github.com/andfoy/winpty-rs): Create and spawn processes inside a pseudoterminal in Windows from Rust. 68 | * [Maturin](https://github.com/PyO3/maturin): Build system to build and publish Rust-based Python packages. 69 | 70 | ## Package usage 71 | Pywinpty offers a single python wrapper around winpty library functions. 72 | This implies that using a single object (``winpty.PTY``) it is possible to access to all functionality, as it follows: 73 | 74 | ```python 75 | # High level usage using `spawn` 76 | from winpty import PtyProcess 77 | 78 | proc = PtyProcess.spawn('python') 79 | proc.write('print("hello, world!")\r\n') 80 | proc.write('exit()\r\n') 81 | while proc.isalive(): 82 | print(proc.readline()) 83 | 84 | # Low level usage using the raw `PTY` object 85 | from winpty import PTY 86 | 87 | # Start a new winpty-agent process of size (cols, rows) 88 | cols, rows = 80, 25 89 | process = PTY(cols, rows) 90 | 91 | # Spawn a new console process, e.g., CMD 92 | process.spawn(br'C:\windows\system32\cmd.exe') 93 | 94 | # Read console output (Unicode) 95 | process.read() 96 | 97 | # Write input to console (Unicode) 98 | process.write(b'Text') 99 | 100 | # Resize console size 101 | new_cols, new_rows = 90, 30 102 | process.set_size(new_cols, new_rows) 103 | 104 | # Know if the process is alive 105 | alive = process.isalive() 106 | 107 | # End winpty-agent process 108 | del process 109 | ``` 110 | 111 | ## Running tests 112 | We use pytest to run tests as it follows (after calling ``maturin develop``), the test suite depends 113 | on pytest-lazy-fixture, which can be installed via pip: 114 | 115 | ```batch 116 | pip install pytest pytest-lazy-fixture flaky 117 | ``` 118 | 119 | All the tests can be exceuted using the following command 120 | 121 | ```bash 122 | python runtests.py 123 | ``` 124 | 125 | 126 | ## Changelog 127 | Visit our [CHANGELOG](CHANGELOG.md) file to learn more about our new features and improvements. 128 | 129 | 130 | ## Contribution guidelines 131 | We follow PEP8 and PEP257 for pure python packages and Rust to compile extensions. We use MyPy type annotations for all functions and classes declared on this package. Feel free to send a PR or create an issue if you have any problem/question. 132 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | To release a new version of pywinpty: 2 | 3 | 1. git fetch upstream && git checkout upstream/master 4 | 2. Close milestone on GitHub 5 | 3. git clean -xfdi 6 | 4. Update CHANGELOG.md with loghub 7 | 5. git add -A && git commit -m "Update Changelog" 8 | 6. Update release version in ``Cargo.toml`` (set release version, remove 'dev0') 9 | 7. git add -A && git commit -m "Release vX.X.X" 10 | 10. git tag -a vX.X.X -m "Release vX.X.X" 11 | 11. Update development version in ``Cargo.toml`` (add '-dev0' and increment minor, see [1](#explanation)) 12 | 12. git add -A && git commit -m "Back to work" 13 | 13. git push upstream master 14 | 14. git push upstream --tags 15 | 15. Create release in GitHub 16 | 16. Wait for GitHub actions to publish the wheels and the sdist distribution 17 | 18 | [1] We need to append '-dev0', as Cargo does not support the '.dev0' 19 | syntax. 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pywinpty" 3 | requires-python = ">=3.9" 4 | dynamic = ["version"] 5 | 6 | [build-system] 7 | requires = ["maturin>=1.1,<2.0"] 8 | build-backend = "maturin" 9 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Script used to run pytest programatically.""" 4 | 5 | # Standard library imports 6 | import argparse 7 | import os 8 | 9 | # Standard library imports 10 | import pytest 11 | import traceback 12 | 13 | 14 | def run_pytest(extra_args=None): 15 | pytest_args = ['-v', '-x'] 16 | 17 | # Allow user to pass a custom test path to pytest to e.g. run just one test 18 | if extra_args: 19 | pytest_args += extra_args 20 | 21 | print("Pytest Arguments: " + str(pytest_args)) 22 | errno = pytest.main(pytest_args) 23 | 24 | # sys.exit doesn't work here because some things could be running in the 25 | # background (e.g. closing the main window) when this point is reached. 26 | # If that's the case, sys.exit doesn't stop the script as you would expect. 27 | if errno != 0: 28 | raise SystemExit(errno) 29 | 30 | 31 | def main(): 32 | """Parse args then run the pytest suite for pywinpty.""" 33 | test_parser = argparse.ArgumentParser( 34 | usage='python runtests.py [-h] [pytest_args]', 35 | description="Helper script to run pywinpty's test suite") 36 | test_parser.add_argument('--run-slow', action='store_true', default=False, 37 | help='Run the slow tests') 38 | _, pytest_args = test_parser.parse_known_args() 39 | run_pytest(extra_args=pytest_args) 40 | 41 | 42 | if __name__ == '__main__': 43 | main() 44 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | 2 | // use cxx::Exception; 3 | use std::ffi::OsString; 4 | 5 | use pyo3::create_exception; 6 | use pyo3::exceptions::PyException; 7 | use pyo3::prelude::*; 8 | // use pyo3::types::PyBytes; 9 | use winptyrs::{PTY, PTYArgs, PTYBackend, MouseMode, AgentConfig}; 10 | // use winptyrs::pty::PTYImpl; 11 | 12 | // Package version 13 | const VERSION: &'static str = env!("CARGO_PKG_VERSION"); 14 | 15 | 16 | fn string_to_static_str(s: String) -> &'static str { 17 | Box::leak(s.into_boxed_str()) 18 | } 19 | 20 | create_exception!(pywinpty, WinptyError, PyException); 21 | 22 | /// Create a pseudo terminal (PTY) of a given size. 23 | /// 24 | /// The pseudo-terminal must define a non-zero, positive size for both columns and rows. 25 | /// 26 | /// Arguments 27 | /// --------- 28 | /// cols: int 29 | /// Number of columns (width) that the pseudo-terminal should have in characters. 30 | /// rows: int 31 | /// Number of rows (height) that the pseudo-terminal should have in characters. 32 | /// backend: Optional[int] 33 | /// Pseudo-terminal backend to use, see `winpty.Backend`. If None, then the backend 34 | /// will be set automatically based on the available APIs. 35 | /// mouse_mode: Optional[int] 36 | /// Set the mouse mode to one of the WINPTY_MOUSE_MODE_xxx constants. 37 | /// See `winpty.MouseMode`. Default: 0. 38 | /// timeout: Optional[int] 39 | /// Amount of time to wait for the agent (in ms) to startup and to wait for any given 40 | /// agent RPC request. Must be greater than 0. Default: 30000. 41 | /// agent_config: Optional[int] 42 | /// A set of zero or more WINPTY_FLAG_xxx values. See `winpty.AgentConfig`. 43 | /// Default: WINPTY_FLAG_COLOR_ESCAPES 44 | /// 45 | /// Raises 46 | /// ------ 47 | /// WinptyError: 48 | /// If an error occurred whilist creating the pseudo terminal instance. 49 | /// 50 | /// Notes 51 | /// ----- 52 | /// 1. Optional argument values will take effect if and only if the backend is set to 53 | /// `winpty.Backend.Winpty`, either automatically or manually. 54 | /// 55 | /// 2. ConPTY backend will take precedence over WinPTY, as it is native to Windows 56 | /// and therefore is faster. 57 | /// 58 | /// 3. Automatic backend selection will be determined based on both the compilation 59 | /// flags used to compile pywinpty and the availability of the APIs on the runtime 60 | /// system. 61 | /// 62 | #[pyclass(name="PTY")] 63 | struct PyPTY { 64 | pty: PTY, 65 | } 66 | 67 | #[pymethods] 68 | impl PyPTY { 69 | #[new] 70 | #[pyo3(signature = ( 71 | cols, 72 | rows, 73 | backend = None, 74 | mouse_mode = 0, 75 | timeout = 30000, 76 | agent_config = 4 77 | ))] 78 | fn new( 79 | cols: i32, 80 | rows: i32, 81 | backend: Option, 82 | mouse_mode: i32, 83 | timeout: u32, 84 | agent_config: u64, 85 | ) -> PyResult { 86 | let config = PTYArgs { 87 | cols: cols, 88 | rows: rows, 89 | mouse_mode: MouseMode::try_from(mouse_mode).unwrap(), 90 | timeout: timeout, 91 | agent_config: AgentConfig::from_bits(agent_config).unwrap() 92 | }; 93 | 94 | let pty: Result; 95 | match backend { 96 | Some(backend_value) => { 97 | pty = PTY::new_with_backend( 98 | &config, PTYBackend::try_from(backend_value).unwrap() 99 | ); 100 | } 101 | None => { 102 | pty = PTY::new(&config); 103 | } 104 | } 105 | 106 | match pty { 107 | Ok(pty) => Ok(PyPTY { pty }), 108 | Err(error) => { 109 | let error_str: String = error.to_str().unwrap().to_owned(); 110 | Err(WinptyError::new_err(string_to_static_str(error_str))) 111 | } 112 | } 113 | } 114 | 115 | /// Start an application that will communicate through the pseudo-terminal. 116 | /// 117 | /// Arguments 118 | /// --------- 119 | /// appname: bytes 120 | /// Byte string that contains the path to the application that will 121 | /// be started. 122 | /// cmdline: Optional[bytes] 123 | /// Byte string that contains the parameters to start the application, 124 | /// separated by whitespace. 125 | /// cwd: Optional[bytes] 126 | /// Byte string that contains the working directory that the application 127 | /// should have. If None, the application will inherit the current working 128 | /// directory of the Python interpreter. 129 | /// env: Optional[bytes] 130 | /// Byte string that contains the name and values of the environment 131 | /// variables that the application should have. Each (name, value) pair 132 | /// should be declared as `name=value` and each pair must be separated 133 | /// by an empty byte `\0`. If None, then the application will inherit 134 | /// the environment variables of the Python interpreter. 135 | /// 136 | /// Returns 137 | /// ------- 138 | /// spawned: bool 139 | /// True if the application was started successfully and False otherwise. 140 | /// 141 | /// Raises 142 | /// ------ 143 | /// WinptyError 144 | /// If an error occurred when trying to start the application process. 145 | /// 146 | /// Notes 147 | /// ----- 148 | /// For a more detailed information about the values of the arguments, see: 149 | /// https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw 150 | /// 151 | #[pyo3(signature = (appname, cmdline = None, cwd = None, env = None))] 152 | fn spawn( 153 | &mut self, 154 | appname: OsString, 155 | cmdline: Option, 156 | cwd: Option, 157 | env: Option, 158 | ) -> PyResult { 159 | let result: Result = self.pty.spawn( 160 | appname, 161 | cmdline, 162 | cwd, 163 | env, 164 | ); 165 | 166 | match result { 167 | Ok(bool_result) => Ok(bool_result), 168 | Err(error) => { 169 | let error_str: String = error.to_str().unwrap().to_owned(); 170 | Err(WinptyError::new_err(string_to_static_str(error_str))) 171 | } 172 | } 173 | } 174 | 175 | /// Modify the size of the pseudo terminal. 176 | /// 177 | /// The value for the columns and rows should be non-zero and positive. 178 | /// 179 | /// Arguments 180 | /// --------- 181 | /// cols: int 182 | /// Size in characters that the pseudo terminal should have. 183 | /// rows: int 184 | /// Size in characters that the pseudo terminal should have. 185 | /// 186 | /// Raises 187 | /// ------ 188 | /// WinptyError 189 | /// If an error occurred whilist resizing the pseudo terminal. 190 | /// 191 | fn set_size(&self, cols: i32, rows: i32, py: Python) -> PyResult<()> { 192 | let result: Result<(), OsString> = 193 | py.allow_threads(|| self.pty.set_size(cols, rows)); 194 | match result { 195 | Ok(()) => Ok(()), 196 | Err(error) => { 197 | let error_str: String = error.to_str().unwrap().to_owned(); 198 | Err(WinptyError::new_err(string_to_static_str(error_str))) 199 | } 200 | } 201 | } 202 | 203 | /// Read a number of bytes from the pseudoterminal output stream. 204 | /// 205 | /// Arguments 206 | /// --------- 207 | /// length: int 208 | /// Maximum number of bytes to read from the pseudoterminal. 209 | /// blocking: bool 210 | /// If True, the call will be blocked until the requested number of bytes 211 | /// are available to read. Otherwise, it will return an empty byte string 212 | /// if there are no available bytes to read. 213 | /// 214 | /// Returns 215 | /// ------- 216 | /// output: bytes 217 | /// A byte string that contains the output of the pseudoterminal. 218 | /// 219 | /// Raises 220 | /// ------ 221 | /// WinptyError 222 | /// If there was an error whilst trying to read the requested number of bytes 223 | /// from the pseudoterminal. 224 | /// 225 | /// Notes 226 | /// ----- 227 | /// Use the `blocking=True` mode only if the process is awaiting on a thread, otherwise 228 | /// this call may block your application, which only can be interrupted by killing the 229 | /// process. 230 | /// 231 | #[pyo3(signature = (length = 1000, blocking = false))] 232 | fn read<'p>(&self, length: u32, blocking: bool, py: Python<'p>) -> PyResult { 233 | // let result = self.pty.read(length, blocking); 234 | let result: Result = 235 | py.allow_threads(move || self.pty.read(length, blocking)); 236 | 237 | match result { 238 | Ok(bytes) => Ok(bytes), 239 | Err(error) => { 240 | let error_str: String = error.to_str().unwrap().to_owned(); 241 | Err(WinptyError::new_err(string_to_static_str(error_str))) 242 | } 243 | } 244 | } 245 | 246 | /// Write a byte string into the pseudoterminal input stream. 247 | /// 248 | /// Arguments 249 | /// --------- 250 | /// to_write: bytes 251 | /// The byte sequence that is going to be sent to the pseudoterminal. 252 | /// 253 | /// Returns 254 | /// ------- 255 | /// num_bytes: int 256 | /// The number of bytes that were written successfully. 257 | /// 258 | /// Raises 259 | /// ------ 260 | /// WinptyError 261 | /// If there was an error whilst trying to write the requested number of bytes 262 | /// into the pseudoterminal. 263 | /// 264 | fn write(&self, to_write: OsString, py: Python) -> PyResult { 265 | //let utf16_str: Vec = to_write.encode_utf16().collect(); 266 | let result: Result = 267 | py.allow_threads(move || self.pty.write(to_write)); 268 | match result { 269 | Ok(bytes) => Ok(bytes), 270 | Err(error) => { 271 | let error_str: String = error.to_str().unwrap().to_owned(); 272 | Err(WinptyError::new_err(string_to_static_str(error_str))) 273 | } 274 | } 275 | } 276 | 277 | /// Determine if the application process that is running behind the pseudoterminal is alive. 278 | /// 279 | /// Returns 280 | /// ------- 281 | /// alive: bool 282 | /// True, the process is alive. False, otherwise. 283 | /// 284 | /// Raises 285 | /// ------ 286 | /// WinptyError 287 | /// If there was an error whilst trying to determine the status of the process. 288 | /// 289 | fn isalive(&self) -> PyResult { 290 | // let result: Result = py.allow_threads(move || self.pty.is_alive()); 291 | match self.pty.is_alive() { 292 | Ok(alive) => Ok(alive), 293 | Err(error) => { 294 | let error_str: String = error.to_str().unwrap().to_owned(); 295 | Err(WinptyError::new_err(string_to_static_str(error_str))) 296 | } 297 | } 298 | } 299 | 300 | /// Determine the exit status code of the process that is running behind the pseudoterminal. 301 | /// 302 | /// Returns 303 | /// ------- 304 | /// status: Optional[int] 305 | /// None if the process has not started nor finished, otherwise it corresponds to the 306 | /// status code at the time of exit. 307 | /// 308 | /// Raises 309 | /// ------ 310 | /// WinptyError 311 | /// If there was an error whilst trying to determine the exit status of the process. 312 | /// 313 | fn get_exitstatus(&self, py: Python) -> PyResult> { 314 | let result: Result, OsString> = 315 | py.allow_threads(|| self.pty.get_exitstatus()); 316 | match result { 317 | Ok(status) => Ok(status), 318 | Err(error) => { 319 | let error_str: String = error.to_str().unwrap().to_owned(); 320 | Err(WinptyError::new_err(string_to_static_str(error_str))) 321 | } 322 | } 323 | } 324 | 325 | /// Determine if the application process that is running behind the pseudoterminal reached EOF. 326 | /// 327 | /// Returns 328 | /// ------- 329 | /// eof: False 330 | /// True, if the process emitted the end-of-file escape sequence. False, otherwise. 331 | /// 332 | /// Raises 333 | /// ------ 334 | /// WinptyError 335 | /// If there was an error whilst trying to determine the EOF status of the process. 336 | /// 337 | fn iseof(&self, py: Python) -> PyResult { 338 | let result: Result = py.allow_threads(|| self.pty.is_eof()); 339 | match result { 340 | Ok(eof) => Ok(eof), 341 | Err(error) => { 342 | let error_str: String = error.to_str().unwrap().to_owned(); 343 | Err(WinptyError::new_err(string_to_static_str(error_str))) 344 | } 345 | } 346 | } 347 | 348 | /// Retrieve the process identifier (PID) of the running process. 349 | #[getter] 350 | fn pid(&self) -> PyResult> { 351 | let result = self.pty.get_pid(); 352 | match result { 353 | 0 => Ok(None), 354 | _ => Ok(Some(result)), 355 | } 356 | } 357 | 358 | /// Retrieve the process handle number. 359 | #[getter] 360 | fn fd(&self) -> PyResult> { 361 | match self.pty.get_fd() { 362 | -1 => Ok(None), 363 | result => Ok(Some(result)) 364 | } 365 | } 366 | } 367 | 368 | #[pymodule(gil_used = false)] 369 | fn winpty(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { 370 | m.add("__version__", VERSION)?; 371 | m.add("WinptyError", py.get_type::())?; 372 | m.add_class::()?; 373 | Ok(()) 374 | } 375 | -------------------------------------------------------------------------------- /winpty/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Pywinpty 4 | ======== 5 | This package provides low and high level APIs to create 6 | pseudo terminals in Windows. 7 | """ 8 | 9 | # Local imports 10 | from .winpty import PTY, WinptyError, __version__ 11 | from .ptyprocess import PtyProcess 12 | from .enums import Backend, Encoding, MouseMode, AgentConfig 13 | 14 | 15 | PTY 16 | PtyProcess 17 | Backend 18 | Encoding 19 | MouseMode 20 | AgentConfig 21 | WinptyError 22 | 23 | __version__ 24 | -------------------------------------------------------------------------------- /winpty/enums.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """General constants used to spawn a PTY.""" 4 | 5 | 6 | class Backend: 7 | """Available PTY backends.""" 8 | ConPTY = 0 9 | WinPTY = 1 10 | 11 | 12 | class Encoding: 13 | """Available byte encodings to communicate with a PTY.""" 14 | UTF8 = 'utf-8' 15 | UTF16 = 'utf-16' 16 | 17 | 18 | class MouseMode: 19 | """Mouse capture settings for the winpty backend.""" 20 | 21 | # QuickEdit mode is initially disabled, and the agent does not send mouse 22 | # mode sequences to the terminal. If it receives mouse input, though, it 23 | # still writes MOUSE_EVENT_RECORD values into CONIN. 24 | WINPTY_MOUSE_MODE_NONE = 0 25 | 26 | # QuickEdit mode is initially enabled. As CONIN enters or leaves mouse 27 | # input mode (i.e. where ENABLE_MOUSE_INPUT is on and 28 | # ENABLE_QUICK_EDIT_MODE is off), the agent enables or disables mouse 29 | # input on the terminal. 30 | WINPTY_MOUSE_MODE_AUTO = 1 31 | 32 | # QuickEdit mode is initially disabled, and the agent enables the 33 | # terminal's mouse input mode. It does not disable terminal 34 | # mouse mode (until exit). 35 | WINPTY_MOUSE_MODE_FORCE = 2 36 | 37 | 38 | class AgentConfig: 39 | """General configuration settings for the winpty backend.""" 40 | 41 | # Create a new screen buffer (connected to the "conerr" terminal pipe) and 42 | # pass it to child processes as the STDERR handle. This flag also prevents 43 | # the agent from reopening CONOUT$ when it polls -- regardless of whether 44 | # the active screen buffer changes, winpty continues to monitor the 45 | # original primary screen buffer. 46 | WINPTY_FLAG_CONERR = 0x1 47 | 48 | # Don't output escape sequences. 49 | WINPTY_FLAG_PLAIN_OUTPUT = 0x2 50 | 51 | # Do output color escape sequences. These escapes are output by default, 52 | # but are suppressed with WINPTY_FLAG_PLAIN_OUTPUT. 53 | # Use this flag to reenable them. 54 | WINPTY_FLAG_COLOR_ESCAPES = 0x4 55 | -------------------------------------------------------------------------------- /winpty/ptyprocess.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Standard library imports 4 | import codecs 5 | import os 6 | import shlex 7 | import signal 8 | import socket 9 | import subprocess 10 | import threading 11 | import time 12 | from shutil import which 13 | 14 | # Local imports 15 | from .winpty import PTY 16 | 17 | 18 | class PtyProcess(object): 19 | """This class represents a process running in a pseudoterminal. 20 | 21 | The main constructor is the :meth:`spawn` classmethod. 22 | """ 23 | 24 | def __init__(self, pty): 25 | assert isinstance(pty, PTY) 26 | self.pty = pty 27 | self.pid = pty.pid 28 | # self.fd = pty.fd 29 | 30 | self.read_blocking = bool(int(os.environ.get('PYWINPTY_BLOCK', 1))) 31 | self.closed = False 32 | self.flag_eof = False 33 | 34 | # Used by terminate() to give kernel time to update process status. 35 | # Time in seconds. 36 | self.delayafterterminate = 0.1 37 | # Used by close() to give kernel time to update process status. 38 | # Time in seconds. 39 | self.delayafterclose = 0.1 40 | 41 | # Set up our file reader sockets. 42 | self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 43 | self._server.bind(("127.0.0.1", 0)) 44 | address = self._server.getsockname() 45 | self._server.listen(1) 46 | 47 | # Read from the pty in a thread. 48 | self._thread = threading.Thread(target=_read_in_thread, 49 | args=(address, self.pty, self.read_blocking)) 50 | self._thread.daemon = True 51 | self._thread.start() 52 | 53 | self.fileobj, _ = self._server.accept() 54 | self.fd = self.fileobj.fileno() 55 | 56 | @classmethod 57 | def spawn(cls, argv, cwd=None, env=None, dimensions=(24, 80), 58 | backend=None): 59 | """Start the given command in a child process in a pseudo terminal. 60 | 61 | This does all the setting up the pty, and returns an instance of 62 | PtyProcess. 63 | 64 | Dimensions of the psuedoterminal used for the subprocess can be 65 | specified as a tuple (rows, cols), or the default (24, 80) will be 66 | used. 67 | """ 68 | if isinstance(argv, str): 69 | argv = shlex.split(argv, posix=False) 70 | 71 | if not isinstance(argv, (list, tuple)): 72 | raise TypeError("Expected a list or tuple for argv, got %r" % argv) 73 | 74 | # Shallow copy of argv so we can modify it 75 | argv = argv[:] 76 | command = argv[0] 77 | env = env or os.environ 78 | 79 | path = env.get('PATH', os.defpath) 80 | command_with_path = which(command, path=path) 81 | if command_with_path is None: 82 | raise FileNotFoundError( 83 | 'The command was not found or was not ' + 84 | 'executable: %s.' % command 85 | ) 86 | command = command_with_path 87 | argv[0] = command 88 | cmdline = ' ' + subprocess.list2cmdline(argv[1:]) 89 | cwd = cwd or os.getcwd() 90 | 91 | backend = backend or os.environ.get('PYWINPTY_BACKEND', None) 92 | backend = int(backend) if backend is not None else backend 93 | 94 | proc = PTY(dimensions[1], dimensions[0], 95 | backend=backend) 96 | 97 | # Create the environemnt string. 98 | envStrs = [] 99 | for (key, value) in env.items(): 100 | envStrs.append('%s=%s' % (key, value)) 101 | env = '\0'.join(envStrs) + '\0' 102 | 103 | # command = bytes(command, encoding) 104 | # cwd = bytes(cwd, encoding) 105 | # cmdline = bytes(cmdline, encoding) 106 | # env = bytes(env, encoding) 107 | 108 | if len(argv) == 1: 109 | proc.spawn(command, cwd=cwd, env=env) 110 | else: 111 | proc.spawn(command, cwd=cwd, env=env, cmdline=cmdline) 112 | 113 | inst = cls(proc) 114 | inst._winsize = dimensions 115 | 116 | # Set some informational attributes 117 | inst.argv = argv 118 | if env is not None: 119 | inst.env = env 120 | if cwd is not None: 121 | inst.launch_dir = cwd 122 | 123 | return inst 124 | 125 | @property 126 | def exitstatus(self): 127 | """The exit status of the process. 128 | """ 129 | return self.pty.get_exitstatus() 130 | 131 | def fileno(self): 132 | """This returns the file descriptor of the pty for the child. 133 | """ 134 | return self.fd 135 | 136 | def close(self, force=False): 137 | """This closes the connection with the child application. Note that 138 | calling close() more than once is valid. This emulates standard Python 139 | behavior with files. Set force to True if you want to make sure that 140 | the child is terminated (SIGKILL is sent if the child ignores 141 | SIGINT).""" 142 | if not self.closed: 143 | self.fileobj.close() 144 | self._server.close() 145 | # Give kernel time to update process status. 146 | time.sleep(self.delayafterclose) 147 | if self.isalive(): 148 | if not self.terminate(force): 149 | raise IOError('Could not terminate the child.') 150 | self.fd = -1 151 | self.closed = True 152 | # del self.pty 153 | 154 | def __del__(self): 155 | """This makes sure that no system resources are left open. Python only 156 | garbage collects Python objects. OS file descriptors are not Python 157 | objects, so they must be handled explicitly. If the child file 158 | descriptor was opened outside of this class (passed to the constructor) 159 | then this does not close it. 160 | """ 161 | # It is possible for __del__ methods to execute during the 162 | # teardown of the Python VM itself. Thus self.close() may 163 | # trigger an exception because os.close may be None. 164 | try: 165 | self.close() 166 | except Exception: 167 | pass 168 | 169 | def flush(self): 170 | """This does nothing. It is here to support the interface for a 171 | File-like object. """ 172 | pass 173 | 174 | def isatty(self): 175 | """This returns True if the file descriptor is open and connected to a 176 | tty(-like) device, else False.""" 177 | return self.isalive() 178 | 179 | def read(self, size=1024): 180 | """Read and return at most ``size`` characters from the pty. 181 | 182 | Can block if there is nothing to read. Raises :exc:`EOFError` if the 183 | terminal was closed. 184 | """ 185 | # try: 186 | # data = self.pty.read(size, blocking=self.read_blocking) 187 | # except Exception as e: 188 | # if "EOF" in str(e): 189 | # raise EOFError(e) from e 190 | # return data 191 | data = self.fileobj.recv(size) 192 | if not data: 193 | self.flag_eof = True 194 | raise EOFError('Pty is closed') 195 | 196 | if data == b'0011Ignore': 197 | data = '' 198 | 199 | err = True 200 | while err and data: 201 | try: 202 | data.decode('utf-8') 203 | err = False 204 | except UnicodeDecodeError: 205 | data += self.fileobj.recv(1) 206 | return data.decode('utf-8') 207 | 208 | def readline(self): 209 | """Read one line from the pseudoterminal as bytes. 210 | 211 | Can block if there is nothing to read. Raises :exc:`EOFError` if the 212 | terminal was closed. 213 | """ 214 | buf = [] 215 | while 1: 216 | try: 217 | ch = self.read(1) 218 | except EOFError: 219 | return ''.join(buf) 220 | buf.append(ch) 221 | if ch == '\n': 222 | return ''.join(buf) 223 | 224 | def write(self, s): 225 | """Write the string ``s`` to the pseudoterminal. 226 | 227 | Returns the number of bytes written. 228 | """ 229 | if not self.pty.isalive(): 230 | raise EOFError('Pty is closed') 231 | 232 | nbytes = self.pty.write(s) 233 | return nbytes 234 | 235 | def terminate(self, force=False): 236 | """This forces a child process to terminate.""" 237 | if not self.isalive(): 238 | return True 239 | self.kill(signal.SIGINT) 240 | time.sleep(self.delayafterterminate) 241 | if not self.isalive(): 242 | return True 243 | if force: 244 | self.kill(signal.SIGTERM) 245 | time.sleep(self.delayafterterminate) 246 | if not self.isalive(): 247 | return True 248 | else: 249 | return False 250 | 251 | def wait(self): 252 | """This waits until the child exits. This is a blocking call. This will 253 | not read any data from the child. 254 | """ 255 | while self.isalive(): 256 | time.sleep(0.1) 257 | return self.exitstatus 258 | 259 | def isalive(self): 260 | """This tests if the child process is running or not. This is 261 | non-blocking. If the child was terminated then this will read the 262 | exitstatus or signalstatus of the child. This returns True if the child 263 | process appears to be running or False if not. 264 | """ 265 | alive = self.pty.isalive() 266 | self.closed = not alive 267 | return alive 268 | 269 | def kill(self, sig=None): 270 | """Kill the process with the given signal. 271 | """ 272 | os.kill(self.pid, sig) 273 | 274 | def sendcontrol(self, char): 275 | '''Helper method that wraps send() with mnemonic access for sending control 276 | character to the child (such as Ctrl-C or Ctrl-D). For example, to send 277 | Ctrl-G (ASCII 7, bell, '\a'):: 278 | child.sendcontrol('g') 279 | See also, sendintr() and sendeof(). 280 | ''' 281 | char = char.lower() 282 | a = ord(char) 283 | if 97 <= a <= 122: 284 | a = a - ord('a') + 1 285 | byte = bytes([a]).decode("ascii") 286 | return self.pty.write(byte), byte 287 | d = {'@': 0, '`': 0, 288 | '[': 27, '{': 27, 289 | '\\': 28, '|': 28, 290 | ']': 29, '}': 29, 291 | '^': 30, '~': 30, 292 | '_': 31, 293 | '?': 127} 294 | if char not in d: 295 | return 0, '' 296 | 297 | byte = bytes([d[char]]).decode("ascii") 298 | return self.pty.write(byte), byte 299 | 300 | def sendeof(self): 301 | """This sends an EOF to the child. This sends a character which causes 302 | the pending parent output buffer to be sent to the waiting child 303 | program without waiting for end-of-line. If it is the first character 304 | of the line, the read() in the user program returns 0, which signifies 305 | end-of-file. This means to work as expected a sendeof() has to be 306 | called at the beginning of a line. This method does not send a newline. 307 | It is the responsibility of the caller to ensure the eof is sent at the 308 | beginning of a line.""" 309 | # Send control character 4 (Ctrl-D) 310 | self.pty.write('\x04'), '\x04' 311 | 312 | def sendintr(self): 313 | """This sends a SIGINT to the child. It does not require 314 | the SIGINT to be the first character on a line. """ 315 | # Send control character 3 (Ctrl-C) 316 | self.pty.write('\x03'), '\x03' 317 | 318 | def eof(self): 319 | """This returns True if the EOF exception was ever raised. 320 | """ 321 | return self.flag_eof 322 | 323 | def getwinsize(self): 324 | """Return the window size of the pseudoterminal as a tuple (rows, cols). 325 | """ 326 | return self._winsize 327 | 328 | def setwinsize(self, rows, cols): 329 | """Set the terminal window size of the child tty. 330 | """ 331 | self._winsize = (rows, cols) 332 | self.pty.set_size(cols, rows) 333 | 334 | 335 | def _read_in_thread(address, pty, blocking): 336 | """Read data from the pty in a thread. 337 | """ 338 | client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 339 | client.connect(address) 340 | 341 | call = 0 342 | 343 | while 1: 344 | try: 345 | data = pty.read(4096, blocking=blocking) or b'0011Ignore' 346 | try: 347 | client.send(bytes(data, 'utf-8')) 348 | except socket.error: 349 | break 350 | 351 | # Handle end of file. 352 | if pty.iseof(): 353 | try: 354 | client.send(b'') 355 | except socket.error: 356 | pass 357 | finally: 358 | break 359 | 360 | call += 1 361 | except Exception as e: 362 | break 363 | time.sleep(1e-3) 364 | 365 | client.close() 366 | -------------------------------------------------------------------------------- /winpty/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """winpty module tests.""" 3 | -------------------------------------------------------------------------------- /winpty/tests/test_pty.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """winpty wrapper tests.""" 3 | 4 | # Standard library imports 5 | import os 6 | import time 7 | 8 | # Third party imports 9 | from winpty import PTY, WinptyError 10 | from winpty.enums import Backend 11 | from winpty.ptyprocess import which 12 | import pytest 13 | 14 | 15 | CMD = which('cmd').lower() 16 | 17 | 18 | def pty_factory(backend): 19 | if os.environ.get('CI_RUNNING', None) == '1': 20 | if backend == Backend.ConPTY: 21 | os.environ['CONPTY_CI'] = '1' 22 | elif backend == Backend.WinPTY: 23 | os.environ.pop('CONPTY_CI', None) 24 | 25 | @pytest.fixture(scope='function') 26 | def pty_fixture(): 27 | pty = PTY(80, 20, backend=backend) 28 | # loc = bytes(os.getcwd(), 'utf8') 29 | assert pty.spawn(CMD) 30 | time.sleep(0.3) 31 | return pty 32 | return pty_fixture 33 | 34 | 35 | conpty_provider = pty_factory(Backend.ConPTY) 36 | winpty_provider = pty_factory(Backend.WinPTY) 37 | 38 | 39 | @pytest.fixture(scope='module', params=['WinPTY', 'ConPTY']) 40 | def pty_fixture(request): 41 | backend = request.param 42 | if os.environ.get('CI_RUNNING', None) == '1': 43 | if backend == 'ConPTY': 44 | os.environ['CI'] = '1' 45 | os.environ['CONPTY_CI'] = '1' 46 | if backend == 'WinPTY': 47 | os.environ.pop('CI', None) 48 | os.environ.pop('CONPTY_CI', None) 49 | 50 | backend = getattr(Backend, backend) 51 | def _pty_factory(): 52 | pty = PTY(80, 25, backend=backend) 53 | assert pty.spawn(CMD) 54 | time.sleep(0.3) 55 | return pty 56 | return _pty_factory 57 | 58 | 59 | 60 | # @pytest.fixture(scope='function', params=[ 61 | # pytest.lazy_fixture('conpty_provider'), 62 | # pytest.lazy_fixture('winpty_provider')]) 63 | # def pty_fixture(request): 64 | # pty = request.param 65 | # return pty 66 | 67 | 68 | def test_read(pty_fixture, capsys): 69 | pty = pty_fixture() 70 | loc = os.getcwd() 71 | readline = '' 72 | 73 | with capsys.disabled(): 74 | start_time = time.time() 75 | while loc not in readline: 76 | if time.time() - start_time > 5: 77 | break 78 | readline += pty.read() 79 | assert loc in readline or 'cmd' in readline 80 | del pty 81 | 82 | 83 | def test_write(pty_fixture): 84 | pty = pty_fixture() 85 | line = pty.read() 86 | 87 | str_text = 'Eggs, ham and spam ünicode' 88 | # text = bytes(str_text, 'utf-8') 89 | num_bytes = pty.write(str_text) 90 | 91 | line = '' 92 | start_time = time.time() 93 | while str_text not in line: 94 | if time.time() - start_time > 5: 95 | break 96 | line += pty.read() 97 | 98 | assert str_text in line 99 | del pty 100 | 101 | 102 | def test_isalive(pty_fixture): 103 | pty = pty_fixture() 104 | pty.write('exit\r\n') 105 | 106 | text = 'exit' 107 | line = '' 108 | while text not in line: 109 | try: 110 | line += pty.read() 111 | except Exception: 112 | break 113 | 114 | while pty.isalive(): 115 | try: 116 | pty.read() 117 | # continue 118 | except Exception: 119 | break 120 | 121 | assert not pty.isalive() 122 | del pty 123 | 124 | 125 | # def test_agent_spawn_fail(pty_fixture): 126 | # pty = pty_fixture 127 | # try: 128 | # pty.spawn(CMD) 129 | # assert False 130 | # except WinptyError: 131 | # pass 132 | 133 | 134 | @pytest.mark.parametrize( 135 | 'backend_name,backend', 136 | [("ConPTY", Backend.ConPTY), ('WinPTY', Backend.WinPTY)]) 137 | def test_pty_create_size_fail(backend_name, backend): 138 | try: 139 | PTY(80, -25, backend=backend) 140 | assert False 141 | except WinptyError: 142 | pass 143 | 144 | 145 | def test_agent_resize_fail(pty_fixture): 146 | pty = pty_fixture() 147 | try: 148 | pty.set_size(-80, 70) 149 | assert False 150 | except WinptyError: 151 | pass 152 | finally: 153 | del pty 154 | 155 | 156 | def test_agent_resize(pty_fixture): 157 | pty = pty_fixture() 158 | pty.set_size(80, 70) 159 | del pty 160 | -------------------------------------------------------------------------------- /winpty/tests/test_ptyprocess.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """winpty wrapper tests.""" 3 | 4 | # Standard library imports 5 | import asyncio 6 | import os 7 | import signal 8 | import time 9 | import sys 10 | import re 11 | 12 | # Third party imports 13 | import pytest 14 | from flaky import flaky 15 | 16 | # Local imports 17 | from winpty.enums import Backend 18 | from winpty.ptyprocess import PtyProcess, which 19 | 20 | 21 | 22 | @pytest.fixture(scope='module', params=['WinPTY', 'ConPTY']) 23 | def pty_fixture(request): 24 | backend = request.param 25 | if os.environ.get('CI_RUNNING', None) == '1': 26 | if backend == 'ConPTY': 27 | os.environ['CI'] = '1' 28 | os.environ['CONPTY_CI'] = '1' 29 | if backend == 'WinPTY': 30 | os.environ.pop('CI', None) 31 | os.environ.pop('CONPTY_CI', None) 32 | 33 | backend = getattr(Backend, backend) 34 | def _pty_factory(cmd=None, env=None): 35 | cmd = cmd or 'cmd' 36 | return PtyProcess.spawn(cmd, env=env, backend=backend) 37 | _pty_factory.backend = request.param 38 | return _pty_factory 39 | 40 | 41 | @flaky(max_runs=40, min_passes=1) 42 | def test_read(pty_fixture): 43 | pty = pty_fixture() 44 | loc = os.getcwd() 45 | data = '' 46 | tries = 0 47 | while loc not in data and tries < 10: 48 | try: 49 | data += pty.read() 50 | except EOFError: 51 | pass 52 | tries += 1 53 | assert loc in data 54 | pty.terminate() 55 | time.sleep(2) 56 | 57 | 58 | @flaky(max_runs=40, min_passes=1) 59 | def test_write(pty_fixture): 60 | pty = pty_fixture() 61 | 62 | text = 'Eggs, ham and spam ünicode' 63 | pty.write(text) 64 | 65 | data = '' 66 | tries = 0 67 | while text not in data and tries < 10: 68 | try: 69 | data += pty.read() 70 | except EOFError: 71 | pass 72 | tries += 1 73 | assert text in data 74 | pty.terminate() 75 | 76 | 77 | @pytest.mark.xfail(reason="It fails sometimes due to long strings") 78 | @flaky(max_runs=40, min_passes=1) 79 | def test_isalive(pty_fixture): 80 | pty = pty_fixture() 81 | 82 | pty.write('echo \"foo\"\r\nexit\r\n') 83 | data = '' 84 | while True: 85 | try: 86 | print('Stuck') 87 | data += pty.read() 88 | except EOFError: 89 | break 90 | 91 | regex = re.compile(".*foo.*") 92 | assert regex.findall(data) 93 | assert not pty.isalive() 94 | pty.terminate() 95 | 96 | 97 | @pytest.mark.xfail(reason="It fails sometimes due to long strings") 98 | @flaky(max_runs=40, min_passes=1) 99 | def test_readline(pty_fixture): 100 | env = os.environ.copy() 101 | env['foo'] = 'bar' 102 | pty = pty_fixture(env=env) 103 | 104 | # Ensure that the echo print has its own CRLF 105 | pty.write('cls\r\n') 106 | pty.write('echo %foo%\r\n') 107 | 108 | data = '' 109 | tries = 0 110 | while 'bar' not in data and tries < 10: 111 | data = pty.readline() 112 | tries += 1 113 | 114 | assert 'bar' in data 115 | 116 | pty.terminate() 117 | 118 | 119 | def test_close(pty_fixture): 120 | pty = pty_fixture() 121 | pty.close() 122 | assert not pty.isalive() 123 | 124 | 125 | def test_flush(pty_fixture): 126 | pty = pty_fixture() 127 | pty.flush() 128 | pty.terminate() 129 | 130 | 131 | def test_intr(pty_fixture): 132 | pty = pty_fixture(cmd=[sys.executable, 'import time; time.sleep(10)']) 133 | pty.sendintr() 134 | assert pty.wait() != 0 135 | 136 | 137 | def test_send_control(pty_fixture): 138 | pty = pty_fixture(cmd=[sys.executable, 'import time; time.sleep(10)']) 139 | pty.sendcontrol('d') 140 | assert pty.wait() != 0 141 | 142 | 143 | @pytest.mark.skipif(which('cat') is None, reason="Requires cat on the PATH") 144 | def test_send_eof(pty_fixture): 145 | cat = pty_fixture('cat') 146 | cat.sendeof() 147 | assert cat.wait() == 0 148 | 149 | 150 | def test_isatty(pty_fixture): 151 | pty = pty_fixture() 152 | assert pty.isatty() 153 | pty.terminate() 154 | assert not pty.isatty() 155 | 156 | 157 | def test_wait(pty_fixture): 158 | pty = pty_fixture(cmd=[sys.executable, '--version']) 159 | assert pty.wait() == 0 160 | 161 | 162 | def test_exit_status(pty_fixture): 163 | pty = pty_fixture(cmd=[sys.executable]) 164 | pty.write('import sys;sys.exit(1)\r\n') 165 | pty.wait() 166 | assert pty.exitstatus == 1 167 | 168 | 169 | @pytest.mark.timeout(30) 170 | def test_kill_sigterm(pty_fixture): 171 | pty = pty_fixture() 172 | pty.write('echo \"foo\"\r\nsleep 1000\r\n') 173 | pty.read() 174 | pty.kill(signal.SIGTERM) 175 | 176 | while True: 177 | try: 178 | pty.read() 179 | except EOFError: 180 | break 181 | 182 | assert not pty.isalive() 183 | assert pty.exitstatus == signal.SIGTERM 184 | 185 | 186 | @pytest.mark.timeout(30) 187 | def test_terminate(pty_fixture): 188 | pty = pty_fixture() 189 | pty.write('echo \"foo\"\r\nsleep 1000\r\n') 190 | pty.read() 191 | pty.terminate() 192 | 193 | while True: 194 | try: 195 | pty.read() 196 | except EOFError: 197 | break 198 | 199 | assert not pty.isalive() 200 | assert pty.closed 201 | 202 | 203 | @pytest.mark.timeout(30) 204 | def test_terminate_force(pty_fixture): 205 | pty = pty_fixture() 206 | pty.write('echo \"foo\"\r\nsleep 1000\r\n') 207 | pty.read() 208 | pty.terminate(force=True) 209 | 210 | while True: 211 | try: 212 | pty.read() 213 | except EOFError: 214 | break 215 | 216 | assert not pty.isalive() 217 | assert pty.closed 218 | 219 | 220 | def test_terminate_loop(pty_fixture): 221 | pty = pty_fixture() 222 | loop = asyncio.SelectorEventLoop() 223 | asyncio.set_event_loop(loop) 224 | 225 | def reader(): 226 | try: 227 | data = pty.read() 228 | except EOFError: 229 | loop.remove_reader(pty.fd) 230 | loop.stop() 231 | 232 | loop.add_reader(pty.fd, reader) 233 | loop.call_soon(pty.write, 'echo \"foo\"\r\nsleep 1000\r\n') 234 | loop.call_soon(pty.terminate, True) 235 | 236 | try: 237 | loop.run_forever() 238 | finally: 239 | loop.close() 240 | 241 | assert not pty.isalive() 242 | assert pty.closed 243 | 244 | 245 | def test_getwinsize(pty_fixture): 246 | pty = pty_fixture() 247 | assert pty.getwinsize() == (24, 80) 248 | pty.terminate() 249 | 250 | 251 | def test_setwinsize(pty_fixture): 252 | pty = pty_fixture() 253 | pty.setwinsize(50, 110) 254 | assert pty.getwinsize() == (50, 110) 255 | pty.terminate() 256 | 257 | pty = PtyProcess.spawn('cmd', dimensions=(60, 120)) 258 | assert pty.getwinsize() == (60, 120) 259 | pty.terminate() 260 | 261 | -------------------------------------------------------------------------------- /winpty/winpty.pyi: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Stub typing declarations for the native PTY object.""" 4 | 5 | # Standard library imports 6 | from typing import Optional 7 | 8 | # Local imports 9 | from .enums import Backend, Encoding, MouseMode, AgentConfig 10 | 11 | 12 | class PTY: 13 | def __init__(self, cols: int, rows: int, 14 | backend: Optional[int] = None, 15 | encoding: Optional[str] = Encoding.UTF8, 16 | mouse_mode: int = MouseMode.WINPTY_MOUSE_MODE_NONE, 17 | timeout: int = 30000, 18 | agent_config: int = AgentConfig.WINPTY_FLAG_COLOR_ESCAPES): 19 | ... 20 | 21 | def spawn(self, 22 | appname: bytes, 23 | cmdline: Optional[bytes] = None, 24 | cwd: Optional[bytes] = None, 25 | env: Optional[bytes] = None) -> bool: 26 | ... 27 | 28 | def set_size(self, cols: int, rows: int): ... 29 | 30 | def read(self, 31 | length: Optional[int] = 1000, 32 | blocking: bool = False) -> bytes: 33 | ... 34 | 35 | def read_stderr(self, 36 | length: Optional[int] = 1000, 37 | blocking: bool = False) -> bytes: 38 | ... 39 | 40 | def write(self, to_write: bytes) -> int: ... 41 | 42 | def isalive(self) -> bool: ... 43 | 44 | def get_exitstatus(self) -> Optional[int]: ... 45 | 46 | def iseof(self) -> bool: ... 47 | 48 | @property 49 | def pid(self) -> Optional[int]: ... 50 | 51 | @property 52 | def fd(self) -> Optional[int]: ... 53 | --------------------------------------------------------------------------------