├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── README.rst ├── conda.recipe ├── .gitignore ├── README.mkd ├── bld.bat ├── build.sh └── meta.yaml ├── docs ├── .gitignore ├── Makefile ├── _cheatsheet.rst ├── _color_list.html ├── _news.rst ├── _static │ ├── fish-text-black.png │ ├── github-logo.png │ ├── logo.png │ ├── logo2.png │ ├── logo3.png │ ├── logo4.png │ ├── logo6.png │ ├── logo7.png │ ├── logo8.png │ └── placeholder ├── _templates │ └── placeholder ├── api │ ├── cli.rst │ ├── colors.rst │ ├── commands.rst │ ├── fs.rst │ ├── machines.rst │ └── path.rst ├── changelog.rst ├── cli.rst ├── colorlib.rst ├── colors.rst ├── conf.py ├── index.rst ├── local_commands.rst ├── local_machine.rst ├── make.bat ├── paths.rst ├── quickref.rst ├── remote.rst ├── typed_env.rst └── utils.rst ├── examples ├── .gitignore ├── PHSP.png ├── SimpleColorCLI.py ├── alignment.py ├── color.py ├── filecopy.py ├── fullcolor.py ├── geet.py ├── make_figures.py ├── simple_cli.py └── testfigure.tex ├── experiments ├── parallel.py └── test_parallel.py ├── noxfile.py ├── plumbum ├── __init__.py ├── _testtools.py ├── cli │ ├── __init__.py │ ├── application.py │ ├── config.py │ ├── i18n.py │ ├── i18n │ │ ├── de.po │ │ ├── de │ │ │ └── LC_MESSAGES │ │ │ │ └── plumbum.cli.mo │ │ ├── fr.po │ │ ├── fr │ │ │ └── LC_MESSAGES │ │ │ │ └── plumbum.cli.mo │ │ ├── nl.po │ │ ├── nl │ │ │ └── LC_MESSAGES │ │ │ │ └── plumbum.cli.mo │ │ ├── ru.po │ │ └── ru │ │ │ └── LC_MESSAGES │ │ │ └── plumbum.cli.mo │ ├── image.py │ ├── progress.py │ ├── switches.py │ ├── terminal.py │ └── termsize.py ├── cmd.py ├── colorlib │ ├── __init__.py │ ├── __main__.py │ ├── _ipython_ext.py │ ├── factories.py │ ├── names.py │ └── styles.py ├── colors.py ├── commands │ ├── __init__.py │ ├── base.py │ ├── daemons.py │ ├── modifiers.py │ └── processes.py ├── fs │ ├── __init__.py │ ├── atomic.py │ └── mounts.py ├── lib.py ├── machines │ ├── __init__.py │ ├── _windows.py │ ├── base.py │ ├── env.py │ ├── local.py │ ├── paramiko_machine.py │ ├── remote.py │ ├── session.py │ └── ssh_machine.py ├── path │ ├── __init__.py │ ├── base.py │ ├── local.py │ ├── remote.py │ └── utils.py └── typed_env.py ├── pyproject.toml ├── tests ├── _test_paramiko.py ├── conftest.py ├── env.py ├── file with space.txt ├── not-in-path │ └── dummy-executable ├── slow_process.bash ├── test_3_cli.py ├── test_cli.py ├── test_clicolor.py ├── test_color.py ├── test_config.py ├── test_env.py ├── test_factories.py ├── test_local.py ├── test_nohup.py ├── test_pipelines.py ├── test_putty.py ├── test_remote.py ├── test_sudo.py ├── test_terminal.py ├── test_translate.py ├── test_typed_env.py ├── test_utils.py ├── test_validate.py └── test_visual_color.py └── translations.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.py] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | insert_final_newline = true 8 | end_of_line = lf 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.py text eol=lf 2 | *.rst text eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | groups: 9 | actions: 10 | patterns: 11 | - "*" 12 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: 7 | - published 8 | 9 | env: 10 | FORCE_COLOR: 3 11 | 12 | jobs: 13 | dist: 14 | name: Dist 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - uses: hynek/build-and-inspect-python-package@v2 22 | 23 | deploy: 24 | name: Deploy 25 | runs-on: ubuntu-latest 26 | needs: [dist] 27 | if: github.event_name == 'release' && github.event.action == 'published' 28 | environment: 29 | name: pypi 30 | url: https://pypi.org/p/plumbum 31 | permissions: 32 | id-token: write 33 | attestations: write 34 | 35 | steps: 36 | - uses: actions/download-artifact@v4 37 | with: 38 | name: Packages 39 | path: dist 40 | 41 | - name: Generate artifact attestation for sdist and wheel 42 | uses: actions/attest-build-provenance@v2 43 | with: 44 | subject-path: "dist/*" 45 | 46 | - uses: pypa/gh-action-pypi-publish@release/v1 47 | with: 48 | attestations: true 49 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | - main 9 | pull_request: 10 | branches: 11 | - master 12 | - main 13 | 14 | env: 15 | FORCE_COLOR: 3 16 | 17 | jobs: 18 | 19 | pre-commit: 20 | name: Format 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | - uses: actions/setup-python@v5 27 | with: 28 | python-version: "3.x" 29 | - uses: pre-commit/action@v3.0.1 30 | - name: Setup uv 31 | uses: astral-sh/setup-uv@v6 32 | - name: pylint 33 | run: uvx nox -s pylint -- --output-format=github 34 | 35 | tests: 36 | name: Tests on 🐍 ${{ matrix.python-version }} ${{ matrix.os }} 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | python-version: ["3.9", "3.11", "3.14"] 41 | os: [ubuntu-22.04, windows-latest, macos-13] 42 | include: 43 | - python-version: 'pypy-3.10' 44 | os: ubuntu-22.04 45 | - python-version: '3.10' 46 | os: ubuntu-22.04 47 | - python-version: '3.13' 48 | os: ubuntu-22.04 49 | exclude: 50 | - python-version: '3.14' 51 | os: windows-latest 52 | runs-on: ${{ matrix.os }} 53 | 54 | steps: 55 | - uses: actions/checkout@v4 56 | with: 57 | fetch-depth: 0 58 | 59 | - name: Setup uv (cached) 60 | uses: astral-sh/setup-uv@v6 61 | with: 62 | enable-cache: true 63 | cache-suffix: ${{ matrix.os }}-${{ matrix.python-version }} 64 | cache-dependency-glob: "**/pyproject.toml" 65 | 66 | - name: Set up Python ${{ matrix.python-version }} 67 | uses: actions/setup-python@v5 68 | with: 69 | python-version: ${{ matrix.python-version }} 70 | allow-prereleases: true 71 | 72 | - name: Add locale for locale test 73 | if: runner.os == 'Linux' && matrix.python-version != '3.11' 74 | run: sudo locale-gen fr_FR.UTF-8 75 | 76 | - name: Install 77 | run: | 78 | uv venv --python ${{ matrix.python-version }} 79 | uv pip install -e.[test] pytest-github-actions-annotate-failures 80 | 81 | - name: Setup SSH tests 82 | if: runner.os != 'Windows' 83 | run: | 84 | chmod 755 ~ 85 | mkdir -p ~/.ssh 86 | chmod 755 ~/.ssh 87 | echo "NoHostAuthenticationForLocalhost yes" >> ~/.ssh/config 88 | echo "StrictHostKeyChecking no" >> ~/.ssh/config 89 | ssh-keygen -q -f ~/.ssh/id_rsa -N '' 90 | cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys 91 | chmod 644 ~/.ssh/authorized_keys 92 | ls -la ~ 93 | ssh localhost -vvv "echo 'Worked!'" 94 | 95 | - name: Test with pytest 96 | run: uv run pytest --cov --run-optional-tests=ssh,sudo 97 | 98 | - name: Upload coverage 99 | run: uvx coveralls --service=github 100 | env: 101 | COVERALLS_PARALLEL: true 102 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 103 | COVERALLS_FLAG_NAME: test-${{ matrix.os }}-${{ matrix.python-version }} 104 | 105 | coverage: 106 | needs: [tests] 107 | runs-on: ubuntu-latest 108 | steps: 109 | - uses: actions/setup-python@v5 110 | with: 111 | python-version: "3.x" 112 | - name: Install coveralls 113 | run: pip install coveralls 114 | - name: Coveralls Finished 115 | run: coveralls --service=github --finish 116 | env: 117 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 118 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | # *.mo - plubmum includes this 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # Plumbum specifics 163 | *.po.new 164 | 165 | /tests/nohup.out 166 | /plumbum/version.py 167 | 168 | # jetbrains 169 | .idea 170 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_commit_msg: "chore: update pre-commit hooks" 3 | autofix_commit_msg: "style: pre-commit fixes" 4 | 5 | repos: 6 | 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: "v5.0.0" 9 | hooks: 10 | - id: check-added-large-files 11 | - id: check-case-conflict 12 | - id: check-merge-conflict 13 | - id: check-symlinks 14 | - id: check-yaml 15 | exclude: ^conda.recipe/meta.yaml$ 16 | - id: debug-statements 17 | - id: end-of-file-fixer 18 | - id: mixed-line-ending 19 | - id: requirements-txt-fixer 20 | - id: trailing-whitespace 21 | 22 | - repo: https://github.com/astral-sh/ruff-pre-commit 23 | rev: "v0.11.0" 24 | hooks: 25 | - id: ruff 26 | args: ["--fix", "--show-fixes"] 27 | - id: ruff-format 28 | 29 | - repo: https://github.com/pre-commit/mirrors-mypy 30 | rev: "v1.15.0" 31 | hooks: 32 | - id: mypy 33 | files: plumbum 34 | args: [] 35 | additional_dependencies: [types-paramiko, types-setuptools, pytest, importlib-resources] 36 | 37 | - repo: https://github.com/abravalheri/validate-pyproject 38 | rev: "v0.24" 39 | hooks: 40 | - id: validate-pyproject 41 | 42 | - repo: https://github.com/codespell-project/codespell 43 | rev: "v2.4.1" 44 | hooks: 45 | - id: codespell 46 | args: ["-w"] 47 | additional_dependencies: [tomli] 48 | exclude: "(^pyproject.toml|.po)$" 49 | 50 | - repo: https://github.com/pre-commit/pygrep-hooks 51 | rev: "v1.10.0" 52 | hooks: 53 | - id: rst-backticks 54 | - id: rst-directive-colons 55 | - id: rst-inline-touching-normal 56 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | build: 9 | os: "ubuntu-22.04" 10 | tools: 11 | python: "3.12" 12 | commands: 13 | - asdf plugin add uv 14 | - asdf install uv latest 15 | - asdf global uv latest 16 | - uv run --group docs sphinx-build -T -b html -d docs/_build/doctrees -D language=en docs $READTHEDOCS_OUTPUT/html 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing to Plumbum 2 | ======================= 3 | 4 | General comments 5 | ---------------- 6 | 7 | Pull requests welcome! Please make sure you add tests (in an easy ``pytest`` format) to the tests folder for your fix or features. Make sure you add documentation covering a new feature. 8 | 9 | Adding a language 10 | ----------------- 11 | 12 | Plumbum.cli prints various messages for the user. These can be localized into your local language; pull requests adding languages are welcome. 13 | 14 | To add a language, run ``./translations.py`` from the main github directory, and then copy the file ``plumbum/cli/i18n/messages.pot`` to ``plumbum/cli/i18n/.po``, and add your language. Run ``./translations.py`` again to update the file you made (save first) and also create the needed files binary file. 15 | 16 | See `gettext: PMOTW3 `_ for more info. 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Tomer Filiba (tomerfiliba@gmail.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://readthedocs.org/projects/plumbum/badge/ 2 | :target: https://plumbum.readthedocs.io/en/latest/ 3 | :alt: Documentation Status 4 | .. image:: https://github.com/tomerfiliba/plumbum/workflows/CI/badge.svg 5 | :target: https://github.com/tomerfiliba/plumbum/actions 6 | :alt: Build Status 7 | .. image:: https://coveralls.io/repos/tomerfiliba/plumbum/badge.svg?branch=master&service=github 8 | :target: https://coveralls.io/github/tomerfiliba/plumbum?branch=master 9 | :alt: Coverage Status 10 | .. image:: https://img.shields.io/pypi/v/plumbum.svg 11 | :target: https://pypi.python.org/pypi/plumbum/ 12 | :alt: PyPI Status 13 | .. image:: https://img.shields.io/pypi/pyversions/plumbum.svg 14 | :target: https://pypi.python.org/pypi/plumbum/ 15 | :alt: PyPI Versions 16 | .. image:: https://img.shields.io/conda/vn/conda-forge/plumbum.svg 17 | :target: https://github.com/conda-forge/plumbum-feedstock 18 | :alt: Conda-Forge Badge 19 | .. image:: https://img.shields.io/pypi/l/plumbum.svg 20 | :target: https://pypi.python.org/pypi/plumbum/ 21 | :alt: PyPI License 22 | .. image:: https://badges.gitter.im/plumbumpy/Lobby.svg 23 | :alt: Join the chat at https://gitter.im/plumbumpy/Lobby 24 | :target: https://gitter.im/plumbumpy/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge 25 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 26 | :alt: Code styled with Black 27 | :target: https://github.com/psf/black 28 | 29 | 30 | Plumbum: Shell Combinators 31 | ========================== 32 | 33 | Ever wished the compactness of shell scripts be put into a **real** programming language? 34 | Say hello to *Plumbum Shell Combinators*. Plumbum (Latin for *lead*, which was used to create 35 | pipes back in the day) is a small yet feature-rich library for shell script-like programs in Python. 36 | The motto of the library is **"Never write shell scripts again"**, and thus it attempts to mimic 37 | the **shell syntax** ("shell combinators") where it makes sense, while keeping it all **Pythonic 38 | and cross-platform**. 39 | 40 | Apart from shell-like syntax and handy shortcuts, the library provides local and remote command 41 | execution (over SSH), local and remote file-system paths, easy working-directory and environment 42 | manipulation, and a programmatic Command-Line Interface (CLI) application toolkit. 43 | Now let's see some code! 44 | 45 | *This is only a teaser; the full documentation can be found at* 46 | `Read the Docs `_ 47 | 48 | Cheat Sheet 49 | ----------- 50 | 51 | Basics 52 | ****** 53 | 54 | .. code-block:: python 55 | 56 | >>> from plumbum import local 57 | >>> local.cmd.ls 58 | LocalCommand(/bin/ls) 59 | >>> local.cmd.ls() 60 | 'build.py\nCHANGELOG.rst\nconda.recipe\nCONTRIBUTING.rst\ndocs\nexamples\nexperiments\nLICENSE\nMANIFEST.in\nPipfile\nplumbum\nplumbum.egg-info\npytest.ini\nREADME.rst\nsetup.cfg\nsetup.py\ntests\ntranslations.py\n' 61 | >>> notepad = local["c:\\windows\\notepad.exe"] 62 | >>> notepad() # Notepad window pops up 63 | '' # Notepad window is closed by user, command returns 64 | 65 | In the example above, you can use ``local["ls"]`` if you have an unusually named executable or a full path to an executable. The ``local`` object represents your local machine. As you'll see, Plumbum also provides remote machines that use the same API! 66 | You can also use ``from plumbum.cmd import ls`` as well for accessing programs in the ``PATH``. 67 | 68 | Piping 69 | ****** 70 | 71 | .. code-block:: python 72 | 73 | >>> from plumbum.cmd import ls, grep, wc 74 | >>> chain = ls["-a"] | grep["-v", r"\.py"] | wc["-l"] 75 | >>> print(chain) 76 | /bin/ls -a | /bin/grep -v '\.py' | /usr/bin/wc -l 77 | >>> chain() 78 | '27\n' 79 | 80 | Redirection 81 | *********** 82 | 83 | .. code-block:: python 84 | 85 | >>> from plumbum.cmd import cat, head 86 | >>> ((cat < "setup.py") | head["-n", 4])() 87 | '#!/usr/bin/env python3\nimport os\n\ntry:\n' 88 | >>> (ls["-a"] > "file.list")() 89 | '' 90 | >>> (cat["file.list"] | wc["-l"])() 91 | '31\n' 92 | 93 | Working-directory manipulation 94 | ****************************** 95 | 96 | .. code-block:: python 97 | 98 | >>> local.cwd 99 | 100 | >>> with local.cwd(local.cwd / "docs"): 101 | ... chain() 102 | ... 103 | '22\n' 104 | 105 | Foreground and background execution 106 | *********************************** 107 | 108 | .. code-block:: python 109 | 110 | >>> from plumbum import FG, BG 111 | >>> (ls["-a"] | grep[r"\.py"]) & FG # The output is printed to stdout directly 112 | build.py 113 | setup.py 114 | translations.py 115 | >>> (ls["-a"] | grep[r"\.py"]) & BG # The process runs "in the background" 116 | 117 | 118 | Command nesting 119 | *************** 120 | 121 | .. code-block:: python 122 | 123 | >>> from plumbum.cmd import sudo, ifconfig 124 | >>> print(sudo[ifconfig["-a"]]) 125 | /usr/bin/sudo /sbin/ifconfig -a 126 | >>> (sudo[ifconfig["-a"]] | grep["-i", "loop"]) & FG 127 | lo Link encap:Local Loopback 128 | UP LOOPBACK RUNNING MTU:16436 Metric:1 129 | 130 | Remote commands (over SSH) 131 | ************************** 132 | 133 | Supports `openSSH `_-compatible clients, 134 | `PuTTY `_ (on Windows) 135 | and `Paramiko `_ (a pure-Python implementation of SSH2) 136 | 137 | .. code-block:: python 138 | 139 | >>> from plumbum import SshMachine 140 | >>> remote = SshMachine("somehost", user = "john", keyfile = "/path/to/idrsa") 141 | >>> r_ls = remote["ls"] 142 | >>> with remote.cwd("/lib"): 143 | ... (r_ls | grep["0.so.0"])() 144 | ... 145 | 'libusb-1.0.so.0\nlibusb-1.0.so.0.0.0\n' 146 | 147 | CLI applications 148 | **************** 149 | 150 | .. code-block:: python 151 | 152 | import logging 153 | from plumbum import cli 154 | 155 | class MyCompiler(cli.Application): 156 | verbose = cli.Flag(["-v", "--verbose"], help = "Enable verbose mode") 157 | include_dirs = cli.SwitchAttr("-I", list = True, help = "Specify include directories") 158 | 159 | @cli.switch("--loglevel", int) 160 | def set_log_level(self, level): 161 | """Sets the log-level of the logger""" 162 | logging.root.setLevel(level) 163 | 164 | def main(self, *srcfiles): 165 | print("Verbose:", self.verbose) 166 | print("Include dirs:", self.include_dirs) 167 | print("Compiling:", srcfiles) 168 | 169 | if __name__ == "__main__": 170 | MyCompiler.run() 171 | 172 | Sample output 173 | +++++++++++++ 174 | 175 | :: 176 | 177 | $ python3 simple_cli.py -v -I foo/bar -Ispam/eggs x.cpp y.cpp z.cpp 178 | Verbose: True 179 | Include dirs: ['foo/bar', 'spam/eggs'] 180 | Compiling: ('x.cpp', 'y.cpp', 'z.cpp') 181 | 182 | Colors and Styles 183 | ----------------- 184 | 185 | .. code-block:: python 186 | 187 | from plumbum import colors 188 | with colors.red: 189 | print("This library provides safe, flexible color access.") 190 | print(colors.bold | "(and styles in general)", "are easy!") 191 | print("The simple 16 colors or", 192 | colors.orchid & colors.underline | '256 named colors,', 193 | colors.rgb(18, 146, 64) | "or full rgb colors", 194 | 'can be used.') 195 | print("Unsafe " + colors.bg.dark_khaki + "color access" + colors.bg.reset + " is available too.") 196 | -------------------------------------------------------------------------------- /conda.recipe/.gitignore: -------------------------------------------------------------------------------- 1 | /linux-32/* 2 | /linux-64/* 3 | /osx-64/* 4 | /win-32/* 5 | /win-64/* 6 | /outputdir/* 7 | -------------------------------------------------------------------------------- /conda.recipe/README.mkd: -------------------------------------------------------------------------------- 1 | # Building instructions 2 | 3 | 4 | Change to the `conda.recipes` directory. Run 5 | ```bash 6 | $ conda install conda-build 7 | ``` 8 | to acquire the conda build tools. Then you can build with 9 | ```bash 10 | conda build --python 3.5 . 11 | ``` 12 | and pay attention to the output directory. You should see something that looks like 13 | ``` 14 | anaconda upload //anaconda/conda-bld/osx-64/plumbum-v1.6.3-py35_0.tar.bz2 15 | ``` 16 | 17 | Now, you will need to convert to other architectures. On non-Windows systems: 18 | ``` 19 | conda convert --platform all //anaconda/conda-bld/osx-64/plumbum-v1.6.3-py35_0.tar.bz2 -o outputdir\ 20 | 21 | ``` 22 | and Windows users will need to add a `-f`. 23 | 24 | Rerun the following steps for all python versions. 25 | 26 | 27 | To upload packages, 28 | ```bash 29 | conda install anaconda-client 30 | anaconda login 31 | for f in `ls outputdir/*/*.tar.bz2`; do anaconda upload $f; done 32 | anaconda logout 33 | ``` 34 | -------------------------------------------------------------------------------- /conda.recipe/bld.bat: -------------------------------------------------------------------------------- 1 | "%PYTHON%" setup.py install 2 | if errorlevel 1 exit 1 3 | -------------------------------------------------------------------------------- /conda.recipe/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | $PYTHON setup.py install 4 | -------------------------------------------------------------------------------- /conda.recipe/meta.yaml: -------------------------------------------------------------------------------- 1 | package: 2 | name: plumbum 3 | version: {{ environ.get('GIT_DESCRIBE_TAG', '').replace('v','') }} 4 | 5 | source: 6 | path: ../ 7 | 8 | requirements: 9 | build: 10 | - python 11 | - setuptools 12 | 13 | run: 14 | - python 15 | - paramiko 16 | 17 | build: 18 | number: {{ environ.get('GIT_DESCRIBE_NUMBER', 0) }} 19 | {% if environ.get('GIT_DESCRIBE_NUMBER', '0') == '0' %}string: py{{ environ.get('PY_VER').replace('.', '') }}_0 20 | {% else %}string: py{{ environ.get('PY_VER').replace('.', '') }}_{{ environ.get('GIT_BUILD_STR', 'GIT_STUB') }}{% endif %} 21 | 22 | test: 23 | # Python imports 24 | imports: 25 | - plumbum 26 | - plumbum.cli 27 | - plumbum.colorlib 28 | - plumbum.commands 29 | - plumbum.fs 30 | - plumbum.machines 31 | - plumbum.path 32 | 33 | 34 | requires: 35 | # Put any additional test requirements here. For example 36 | - pytest 37 | - paramiko 38 | 39 | about: 40 | home: https://plumbum.readthedocs.io 41 | license: MIT License 42 | summary: 'Plumbum: shell combinators library' 43 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PlumbumShellCombinators.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PlumbumShellCombinators.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/PlumbumShellCombinators" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PlumbumShellCombinators" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/_cheatsheet.rst: -------------------------------------------------------------------------------- 1 | 2 | Basics 3 | ------ 4 | 5 | .. code-block:: python 6 | 7 | >>> from plumbum import local 8 | >>> ls = local["ls"] 9 | >>> ls 10 | LocalCommand() 11 | >>> ls() 12 | 'build.py\ndist\ndocs\nLICENSE\nplumbum\nREADME.rst\nsetup.py\ntests\ntodo.txt\n' 13 | >>> notepad = local["c:\\windows\\notepad.exe"] 14 | >>> notepad() # Notepad window pops up 15 | '' # Notepad window is closed by user, command returns 16 | 17 | Instead of writing ``xxx = local["xxx"]`` for every program you wish to use, you can 18 | also :ref:`import commands `: 19 | 20 | >>> from plumbum.cmd import grep, wc, cat, head 21 | >>> grep 22 | LocalCommand() 23 | 24 | Or, use the ``local.cmd`` syntactic-sugar: 25 | 26 | .. code-block:: python 27 | 28 | >>> local.cmd.ls 29 | LocalCommand() 30 | >>> local.cmd.ls() 31 | 'build.py\ndist\ndocs\nLICENSE\nplumbum\nREADME.rst\nsetup.py\ntests\ntodo.txt\n' 32 | 33 | See :ref:`guide-local-commands`. 34 | 35 | Piping 36 | ------ 37 | 38 | .. code-block:: python 39 | 40 | >>> chain = ls["-a"] | grep["-v", "\\.py"] | wc["-l"] 41 | >>> print(chain) 42 | /bin/ls -a | /bin/grep -v '\.py' | /usr/bin/wc -l 43 | >>> chain() 44 | '13\n' 45 | 46 | See :ref:`guide-local-commands-pipelining`. 47 | 48 | Redirection 49 | ----------- 50 | 51 | .. code-block:: python 52 | 53 | >>> ((cat < "setup.py") | head["-n", 4])() 54 | '#!/usr/bin/env python3\nimport os\n\ntry:\n' 55 | >>> (ls["-a"] > "file.list")() 56 | '' 57 | >>> (cat["file.list"] | wc["-l"])() 58 | '17\n' 59 | 60 | See :ref:`guide-local-commands-redir`. 61 | 62 | Working-directory manipulation 63 | ------------------------------ 64 | 65 | .. code-block:: python 66 | 67 | >>> local.cwd 68 | 69 | >>> with local.cwd(local.cwd / "docs"): 70 | ... chain() 71 | ... 72 | '15\n' 73 | 74 | A more explicit, and thread-safe way of running a command in a different directory is using the ``.with_cwd()`` method:: 75 | 76 | .. code-block:: python 77 | 78 | >>> ls_in_docs = local.cmd.ls.with_cwd("docs") 79 | >>> ls_in_docs() 80 | 'api\nchangelog.rst\n_cheatsheet.rst\ncli.rst\ncolorlib.rst\n_color_list.html\ncolors.rst\nconf.py\nindex.rst\nlocal_commands.rst\nlocal_machine.rst\nmake.bat\nMakefile\n_news.rst\npaths.rst\nquickref.rst\nremote.rst\n_static\n_templates\ntyped_env.rst\nutils.rst\n' 81 | 82 | See :ref:`guide-paths` and :ref:`guide-local-machine`. 83 | 84 | Foreground and background execution 85 | ----------------------------------- 86 | 87 | .. code-block:: python 88 | 89 | >>> from plumbum import FG, BG 90 | >>> (ls["-a"] | grep["\\.py"]) & FG # The output is printed to stdout directly 91 | build.py 92 | .pydevproject 93 | setup.py 94 | >>> (ls["-a"] | grep["\\.py"]) & BG # The process runs "in the background" 95 | 96 | 97 | See :ref:`guide-local-commands-bgfg`. 98 | 99 | 100 | Command nesting 101 | --------------- 102 | 103 | .. code-block:: python 104 | 105 | >>> from plumbum.cmd import sudo 106 | >>> print(sudo[ifconfig["-a"]]) 107 | /usr/bin/sudo /sbin/ifconfig -a 108 | >>> (sudo[ifconfig["-a"]] | grep["-i", "loop"]) & FG 109 | lo Link encap:Local Loopback 110 | UP LOOPBACK RUNNING MTU:16436 Metric:1 111 | 112 | 113 | See :ref:`guide-local-commands-nesting`. 114 | 115 | Remote commands (over SSH) 116 | -------------------------- 117 | 118 | Supports `openSSH `_-compatible clients, 119 | `PuTTY `_ (on Windows) 120 | and `Paramiko `_ (a pure-Python implementation of SSH2): 121 | 122 | .. code-block:: python 123 | 124 | >>> from plumbum import SshMachine 125 | >>> remote = SshMachine("somehost", user = "john", keyfile = "/path/to/idrsa") 126 | >>> r_ls = remote["ls"] 127 | >>> with remote.cwd("/lib"): 128 | ... (r_ls | grep["0.so.0"])() 129 | ... 130 | 'libusb-1.0.so.0\nlibusb-1.0.so.0.0.0\n' 131 | 132 | See :ref:`guide-remote`. 133 | 134 | 135 | CLI applications 136 | ---------------- 137 | 138 | .. code-block:: python 139 | 140 | import logging 141 | from plumbum import cli 142 | 143 | class MyCompiler(cli.Application): 144 | verbose = cli.Flag(["-v", "--verbose"], help = "Enable verbose mode") 145 | include_dirs = cli.SwitchAttr("-I", list = True, help = "Specify include directories") 146 | 147 | @cli.switch("-loglevel", int) 148 | def set_log_level(self, level): 149 | """Sets the log-level of the logger""" 150 | logging.root.setLevel(level) 151 | 152 | def main(self, *srcfiles): 153 | print("Verbose:", self.verbose) 154 | print("Include dirs:", self.include_dirs) 155 | print("Compiling:", srcfiles) 156 | 157 | if __name__ == "__main__": 158 | MyCompiler.run() 159 | 160 | Sample output 161 | +++++++++++++ 162 | 163 | :: 164 | 165 | $ python3 simple_cli.py -v -I foo/bar -Ispam/eggs x.cpp y.cpp z.cpp 166 | Verbose: True 167 | Include dirs: ['foo/bar', 'spam/eggs'] 168 | Compiling: ('x.cpp', 'y.cpp', 'z.cpp') 169 | 170 | See :ref:`guide-cli`. 171 | 172 | Colors and Styles 173 | ----------------- 174 | 175 | .. code-block:: python 176 | 177 | from plumbum import colors 178 | with colors.red: 179 | print("This library provides safe, flexible color access.") 180 | print(colors.bold | "(and styles in general)", "are easy!") 181 | print("The simple 16 colors or", 182 | colors.orchid & colors.underline | '256 named colors,', 183 | colors.rgb(18, 146, 64) | "or full rgb colors" , 184 | 'can be used.') 185 | print("Unsafe " + colors.bg.dark_khaki + "color access" + colors.bg.reset + " is available too.") 186 | 187 | Sample output 188 | +++++++++++++ 189 | 190 | .. raw:: html 191 | 192 |
193 | 194 |
This library provides safe color access.
195 |     Color (and styles in general) are easy!
196 |     The simple 16 colors, 256 named colors, or full hex colors can be used.
197 |     Unsafe color access is available too.
198 |
199 |
200 | 201 | See :ref:`guide-colors`. 202 | -------------------------------------------------------------------------------- /docs/_news.rst: -------------------------------------------------------------------------------- 1 | * **2024.10.05**: Version 1.9.0 released with Python 3.8-3.13 support, and some small fixes. 2 | 3 | * **2024.04.29**: Version 1.8.3 released with some small fixes, final version to support Python 3.6 and 3.7. 4 | 5 | * **2023.05.30**: Version 1.8.2 released with a PyPI metadata fix, Python 3.12b1 testing, and a bit more typing. 6 | 7 | * **2023.01.01**: Version 1.8.1 released with hatchling replacing setuptools for the build system, and support for Path objects in local. 8 | 9 | * **2022.10.05**: Version 1.8.0 released with ``NO_COLOR``/``FORCE_COLOR``, ``all_markers`` & future annotations for the CLI, some command enhancements, & Python 3.11 testing. 10 | 11 | * **2021.12.23**: Version 1.7.2 released with very minor fixes, final version to support Python 2.7 and 3.5. 12 | 13 | * **2021.11.23**: Version 1.7.1 released with a few features like reverse tunnels, color group titles, and a glob path fix. Better Python 3.10 support. 14 | 15 | * **2021.02.08**: Version 1.7.0 released with a few new features like ``.with_cwd``, some useful bugfixes, and lots of cleanup. 16 | 17 | * **2020.03.23**: Version 1.6.9 released with several Path fixes, final version to support Python 2.6. 18 | 19 | * **2019.10.30**: Version 1.6.8 released with ``local.cmd``, a few command updates, ``Set`` improvements, and ``TypedEnv``. 20 | 21 | * **2018.08.10**: Version 1.6.7 released with several minor additions, mostly to CLI apps, and ``run_*`` modifiers added. 22 | 23 | * **2018.02.12**: Version 1.6.6 released with one more critical bugfix for a error message regression in 1.6.5. 24 | 25 | * **2017.12.29**: Version 1.6.5 released with mostly bugfixes, including a critical one that could break pip installs on some platforms. English cli apps now load as fast as before the localization update. 26 | 27 | * **2017.11.27**: Version 1.6.4 released with new CLI localization support. Several bugfixes and better pathlib compatibility, along with better separation between Plumbum's internal packages. 28 | 29 | * **2016.12.31**: Version 1.6.3 released to provide Python 3.6 compatibility. Mostly bugfixes, several smaller improvements to paths, and a provisional config parser added. 30 | 31 | * **2016.12.3**: Version 1.6.2 is now available through `conda-forge `_, as well. 32 | 33 | * **2016.6.25**: Version 1.6.2 released. This is mostly a bug fix release, but a few new features are included. Modifiers allow some new arguments, and ``Progress`` is improved. Better support for SunOS and other OS's. 34 | 35 | * **2015.12.18**: Version 1.6.1 released. The release mostly contains smaller fixes for CLI, 2.6/3.5 support, and colors. PyTest is now used for tests, and Conda is supported. 36 | 37 | * **2015.10.16**: Version 1.6.0 released. Highlights include Python 3.5 compatibility, the ``plumbum.colors`` package, ``Path`` becoming a subclass of ``str`` and a host of bugfixes. Special thanks go to Henry for his efforts. 38 | 39 | * **2015.07.17**: Version 1.5.0 released. This release brings a host of bug fixes, code cleanups and some experimental new features (be sure to check the changelog). Also, say hi to `Henry Schreiner `_, who has joined as a member of the project. 40 | -------------------------------------------------------------------------------- /docs/_static/fish-text-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/docs/_static/fish-text-black.png -------------------------------------------------------------------------------- /docs/_static/github-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/docs/_static/github-logo.png -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/_static/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/docs/_static/logo2.png -------------------------------------------------------------------------------- /docs/_static/logo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/docs/_static/logo3.png -------------------------------------------------------------------------------- /docs/_static/logo4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/docs/_static/logo4.png -------------------------------------------------------------------------------- /docs/_static/logo6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/docs/_static/logo6.png -------------------------------------------------------------------------------- /docs/_static/logo7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/docs/_static/logo7.png -------------------------------------------------------------------------------- /docs/_static/logo8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/docs/_static/logo8.png -------------------------------------------------------------------------------- /docs/_static/placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/docs/_static/placeholder -------------------------------------------------------------------------------- /docs/_templates/placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/docs/_templates/placeholder -------------------------------------------------------------------------------- /docs/api/cli.rst: -------------------------------------------------------------------------------- 1 | .. _api-cli: 2 | 3 | Package plumbum.cli 4 | =================== 5 | .. automodule:: plumbum.cli.application 6 | :members: 7 | 8 | .. automodule:: plumbum.cli.switches 9 | :members: 10 | 11 | .. automodule:: plumbum.cli.terminal 12 | :members: 13 | 14 | .. automodule:: plumbum.cli.termsize 15 | :members: 16 | 17 | .. automodule:: plumbum.cli.progress 18 | :members: 19 | -------------------------------------------------------------------------------- /docs/api/colors.rst: -------------------------------------------------------------------------------- 1 | Package plumbum.colors 2 | ====================== 3 | 4 | .. automodule:: plumbum.colors 5 | :members: 6 | :special-members: 7 | 8 | plumbum.colorlib 9 | ---------------- 10 | 11 | .. automodule:: plumbum.colorlib 12 | :members: 13 | :special-members: 14 | 15 | plumbum.colorlib.styles 16 | ----------------------- 17 | 18 | .. automodule:: plumbum.colorlib.styles 19 | :members: 20 | :special-members: 21 | 22 | plumbum.colorlib.factories 23 | -------------------------- 24 | 25 | .. automodule:: plumbum.colorlib.factories 26 | :members: 27 | :special-members: 28 | 29 | plumbum.colorlib.names 30 | ---------------------- 31 | 32 | .. automodule:: plumbum.colorlib.names 33 | :members: 34 | :special-members: 35 | -------------------------------------------------------------------------------- /docs/api/commands.rst: -------------------------------------------------------------------------------- 1 | .. _api-commands: 2 | 3 | Package plumbum.commands 4 | ======================== 5 | .. automodule:: plumbum.commands.base 6 | :members: 7 | :special-members: 8 | 9 | .. automodule:: plumbum.commands.daemons 10 | :members: 11 | :special-members: 12 | 13 | .. automodule:: plumbum.commands.modifiers 14 | :members: 15 | :special-members: 16 | 17 | .. automodule:: plumbum.commands.processes 18 | :members: 19 | :special-members: 20 | -------------------------------------------------------------------------------- /docs/api/fs.rst: -------------------------------------------------------------------------------- 1 | Package plumbum.fs 2 | ================== 3 | File system utilities 4 | 5 | .. automodule:: plumbum.fs.atomic 6 | :members: 7 | 8 | .. automodule:: plumbum.fs.mounts 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/api/machines.rst: -------------------------------------------------------------------------------- 1 | .. _api-local-machine: 2 | 3 | 4 | Package plumbum.machines 5 | ======================== 6 | .. automodule:: plumbum.machines.env 7 | :members: 8 | :special-members: 9 | 10 | .. automodule:: plumbum.machines.local 11 | :members: 12 | :special-members: 13 | 14 | .. automodule:: plumbum.machines.session 15 | :members: 16 | :special-members: 17 | 18 | .. _api-remote-machines: 19 | 20 | Remote Machines 21 | --------------- 22 | 23 | .. automodule:: plumbum.machines.remote 24 | :members: 25 | :special-members: 26 | 27 | .. automodule:: plumbum.machines.ssh_machine 28 | :members: 29 | :special-members: 30 | 31 | .. automodule:: plumbum.machines.paramiko_machine 32 | :members: 33 | :special-members: 34 | -------------------------------------------------------------------------------- /docs/api/path.rst: -------------------------------------------------------------------------------- 1 | .. _api-path: 2 | 3 | Package plumbum.path 4 | ==================== 5 | .. automodule:: plumbum.path.base 6 | :members: 7 | :special-members: 8 | 9 | .. automodule:: plumbum.path.local 10 | :members: 11 | :special-members: 12 | 13 | .. automodule:: plumbum.path.remote 14 | :members: 15 | :special-members: 16 | 17 | Utils 18 | ----- 19 | .. automodule:: plumbum.path.utils 20 | :members: 21 | :special-members: 22 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | 4 | .. include:: ../CHANGELOG.rst 5 | -------------------------------------------------------------------------------- /docs/local_machine.rst: -------------------------------------------------------------------------------- 1 | .. _guide-local-machine: 2 | 3 | The Local Object 4 | ================ 5 | So far we've only seen running local commands, but there's more to the ``local`` object than 6 | this; it aims to "fully represent" the *local machine*. 7 | 8 | First, you should get acquainted with ``which``, which performs program name resolution in 9 | the system ``PATH`` and returns the first match (or raises an exception if no match is found):: 10 | 11 | >>> local.which("ls") 12 | 13 | >>> local.which("nonexistent") 14 | Traceback (most recent call last): 15 | [...] 16 | plumbum.commands.CommandNotFound: ('nonexistent', [...]) 17 | 18 | Another member is ``python``, which is a command object that points to the current interpreter 19 | (``sys.executable``):: 20 | 21 | >>> local.python 22 | 23 | >>> local.python("-c", "import sys;print(sys.version)") 24 | '3.10.0 (default, Feb 2 2022, 02:22:22) [MSC v.1931 64 bit (Intel)]\r\n' 25 | 26 | Working Directory 27 | ----------------- 28 | The ``local.cwd`` attribute represents the current working directory. You can change it like so:: 29 | 30 | >>> local.cwd 31 | 32 | >>> local.cwd.chdir("d:\\workspace\\plumbum\\docs") 33 | >>> local.cwd 34 | 35 | 36 | You can also use it as a *context manager*, so it behaves like ``pushd``/``popd``:: 37 | 38 | >>> ls_l = ls | wc["-l"] 39 | >>> with local.cwd("c:\\windows"): 40 | ... print(f"{local.cwd}:{ls_l()}") 41 | ... with local.cwd("c:\\windows\\system32"): 42 | ... print(f"{local.cwd}:{ls_l()}") 43 | ... 44 | c:\windows: 105 45 | c:\windows\system32: 3013 46 | >>> print(f"{local.cwd}:{ls_l()}") 47 | d:\workspace\plumbum: 9 48 | 49 | Finally, A more explicit and thread-safe way of running a command in a different directory is using the ``.with_cwd()`` method: 50 | 51 | >>> ls_in_docs = local.cmd.ls.with_cwd("docs") 52 | >>> ls_in_docs() 53 | 'api\nchangelog.rst\n_cheatsheet.rst\ncli.rst\ncolorlib.rst\n_color_list.html\ncolors.rst\nconf.py\nindex.rst\nlocal_commands.rst\nlocal_machine.rst\nmake.bat\nMakefile\n_news.rst\npaths.rst\nquickref.rst\nremote.rst\n_static\n_templates\ntyped_env.rst\nutils.rst\n' 54 | 55 | 56 | Environment 57 | ----------- 58 | Much like ``cwd``, ``local.env`` represents the *local environment*. It is a dictionary-like 59 | object that holds **environment variables**, which you can get/set intuitively:: 60 | 61 | >>> local.env["JAVA_HOME"] 62 | 'C:\\Program Files\\Java\\jdk1.6.0_20' 63 | >>> local.env["JAVA_HOME"] = "foo" 64 | 65 | And similarity to ``cwd`` is the context-manager nature of ``env``; each level would have 66 | it's own private copy of the environment:: 67 | 68 | >>> with local.env(FOO="BAR"): 69 | ... local.python("-c", "import os; print(os.environ['FOO'])") 70 | ... with local.env(FOO="SPAM"): 71 | ... local.python("-c", "import os; print(os.environ['FOO'])") 72 | ... local.python("-c", "import os; print(os.environ['FOO'])") 73 | ... 74 | 'BAR\r\n' 75 | 'SPAM\r\n' 76 | 'BAR\r\n' 77 | >>> local.python("-c", "import os;print(os.environ['FOO'])") 78 | Traceback (most recent call last): 79 | [...] 80 | ProcessExecutionError: Unexpected exit code: 1 81 | Command line: | /usr/bin/python3 -c "import os; print(os.environ['FOO'])" 82 | Stderr: | Traceback (most recent call last): 83 | | File "", line 1, in 84 | | File "/usr/lib/python3.10/os.py", line 725, in __getitem__ 85 | | raise KeyError(key) from None 86 | | KeyError: 'FOO' 87 | 88 | In order to make cross-platform-ness easier, the ``local.env`` object provides some convenience 89 | properties for getting the username (``.user``), the home path (``.home``), and the executable path 90 | (``path``) as a list. For instance:: 91 | 92 | >>> local.env.user 93 | 'sebulba' 94 | >>> local.env.home 95 | 96 | >>> local.env.path 97 | [, , ...] 98 | >>> 99 | >>> local.which("python") 100 | 101 | >>> local.env.path.insert(0, "c:\\python310") 102 | >>> local.which("python") 103 | 104 | 105 | 106 | For further information, see the :ref:`api docs `. 107 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\PlumbumShellCombinators.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\PlumbumShellCombinators.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/paths.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _guide-paths: 3 | 4 | Paths 5 | ===== 6 | 7 | Apart from commands, Plumbum provides an easy to use path class that represents file system paths. 8 | Paths are returned from several plumbum commands, and local paths can be directly created 9 | by :func:`local.path() `. Paths are always absolute and 10 | are immutable, may refer to a remote machine, and can be used like a ``str``. 11 | In many respects, paths provide a similar API to pathlib in the Python 3.4+ standard library, 12 | with a few improvements and extra features. 13 | 14 | .. versionadded:: 1.6 15 | 16 | Paths now support more pathlib like syntax, several old names have been depreciated, like ``.basename`` 17 | 18 | The primary ways to create paths are from ``.cwd``, ``.env.home``, or ``.path(...)`` on a local 19 | or remote machine, with ``/``, ``//`` or ``[]`` for composition. 20 | 21 | .. note:: 22 | 23 | The path returned from ``.cwd`` can also be used in a context manager and has a ``.chdir(path)`` function. 24 | See :ref:`guide-local-machine` for an example. 25 | 26 | Paths provide a variety of functions that allow you to check the status of a file:: 27 | 28 | >>> p = local.path("c:\\windows") 29 | >>> p.exists() 30 | True 31 | >>> p.is_dir() 32 | True 33 | >>> p.is_file() 34 | False 35 | 36 | Besides checking to see if a file exists, you can check the type of file using ``.is_dir()``, ``is_file()``, or ``is_symlink()``. 37 | You can access details about the file using the properties ``.dirname``, ``.drive``, ``.root``, ``.name``, ``.suffix``, 38 | and ``.stem`` (all suffixes). General stats can be obtained with ``.stat()``. 39 | 40 | You can use ``.with_suffix(suffix, depth=1)`` to replace the last ``depth`` suffixes with a new suffix. 41 | If you specify None for the depth, it will replace all suffixes (for example, ``.tar.gz`` is two suffixes). 42 | Note that a name like ``file.name.10.15.tar.gz`` will have "5" suffixes. 43 | Also available is ``.with_name(name)``, which will will replace the entire name. 44 | ``preferred_suffix(suffix)`` will add a suffix if one does not exist (for default suffix situations). 45 | 46 | Paths can be composed using ``/`` or ``[]``:: 47 | 48 | >>> p / "notepad.exe" 49 | 50 | >>> (p / "notepad.exe").is_file() 51 | True 52 | >>> (p / "notepad.exe").with_suffix(".dll") 53 | 54 | >>> p["notepad.exe"].is_file() 55 | True 56 | >>> p["../some/path"]["notepad.exe"].with_suffix(".dll") 57 | 58 | 59 | 60 | You can also iterate over directories to get the contents:: 61 | 62 | >>> for p2 in p: 63 | ... print(p2) 64 | ... 65 | c:\windows\addins 66 | c:\windows\appcompat 67 | c:\windows\apppatch 68 | ... 69 | 70 | Paths also supply ``.iterdir()``, which may be faster on Python 3.5. 71 | 72 | Globing can be easily performed using ``//`` (floor division):: 73 | >>> p // "*.dll" 74 | [, ...] 75 | >>> p // "*/*.dll" 76 | [, ...] 77 | >>> local.cwd / "docs" // "*.rst" 78 | [, ...] 79 | 80 | 81 | .. versionadded:: 1.6 82 | 83 | Globing a tuple will glob for each of the items in the tuple, and return the aggregated result. 84 | 85 | Files can be opened and read directly:: 86 | >>> with(open(local.cwd / "docs" / "index.rst")) as f: 87 | ... print(read(f)) 88 | <...output...> 89 | 90 | .. versionadded:: 1.6 91 | 92 | Support for treating a path exactly like a ``str``, so they can be used directly in ``open()``. 93 | 94 | Paths also supply ``.delete()``, ``.copy(destination, override=False)``, and ``.move(destination)``. On systems that 95 | support it, you can also use ``.symlink(destination)``, ``.link(destination)``, and ``.unlink()``. You can change permissions with ``.chmod(mode)``, 96 | and change owners with ``.chown(owner=None, group=None, recursive=None)``. If ``recursive`` is ``None``, this will be recursive only 97 | if the path is a directory. 98 | 99 | For **copy**, **move**, or **delete** 100 | in a more general helper function form, see the :ref:`utils modules `. 101 | 102 | Relative paths can be computed using ``.relative_to(source)`` or ``mypath - basepath``, though it should be noted 103 | that relative paths are not as powerful as absolute paths, and are primarily for recording a path or printing. 104 | 105 | For further information, see the :ref:`api docs `. 106 | -------------------------------------------------------------------------------- /docs/typed_env.rst: -------------------------------------------------------------------------------- 1 | .. _guide-typed-env: 2 | 3 | TypedEnv 4 | ======== 5 | Plumbum provides this utility class to facilitate working with environment variables. 6 | Similar to how :class:`plumbum.cli.Application` parses command line arguments into pythonic data types, 7 | :class:`plumbum.typed_env.TypedEnv` parses environment variables: 8 | 9 | class MyEnv(TypedEnv): 10 | username = TypedEnv.Str("USER", default='anonymous') 11 | path = TypedEnv.CSV("PATH", separator=":", type=local.path) 12 | tmp = TypedEnv.Str(["TMP", "TEMP"]) # support 'fallback' var-names 13 | is_travis = TypedEnv.Bool("TRAVIS", default=False) # True is 'yes/true/1' (case-insensitive) 14 | 15 | We can now instantiate this class to access its attributes:: 16 | 17 | >>> env = MyEnv() 18 | >>> env.username 19 | 'ofer' 20 | 21 | >>> env.path 22 | [, 23 | , 24 | , 25 | , 26 | , 27 | , 28 | ] 29 | 30 | >>> env.tmp 31 | Traceback (most recent call last): 32 | [...] 33 | KeyError: 'TMP' 34 | 35 | >>> env.is_travis 36 | False 37 | 38 | Finally, our ``TypedEnv`` object allows us ad-hoc access to the rest of the environment variables, using dot-notation:: 39 | 40 | >>> env.HOME 41 | '/home/ofer' 42 | 43 | We can also update the environment via our ``TypedEnv`` object: 44 | 45 | >>> env.tmp = "/tmp" 46 | >>> env.tmp 47 | '/tmp' 48 | 49 | >>> from os import environ 50 | >>> env.TMP 51 | '/tmp' 52 | 53 | >>> env.is_travis = True 54 | >>> env.TRAVIS 55 | 'yes' 56 | 57 | >>> env.path = [local.path("/a"), local.path("/b")] 58 | >>> env.PATH 59 | '/a:/b' 60 | 61 | 62 | TypedEnv as an Abstraction Layer 63 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 64 | The ``TypedEnv`` class is very useful for separating your application from the actual environment variables. 65 | It provides a layer where parsing and normalizing can take place in a centralized fashion. 66 | 67 | For example, you might start with this simple implementation:: 68 | 69 | class CiBuildEnv(TypedEnv): 70 | job_id = TypedEnv.Str("BUILD_ID") 71 | 72 | 73 | Later, as the application gets more complicated, you may expand your implementation like so:: 74 | 75 | class CiBuildEnv(TypedEnv): 76 | is_travis = TypedEnv.Bool("TRAVIS", default=False) 77 | _travis_job_id = TypedEnv.Str("TRAVIS_JOB_ID") 78 | _jenkins_job_id = TypedEnv.Str("BUILD_ID") 79 | 80 | @property 81 | def job_id(self): 82 | return self._travis_job_id if self.is_travis else self._jenkins_job_id 83 | 84 | 85 | 86 | TypedEnv vs. local.env 87 | ^^^^^^^^^^^^^^^^^^^^^^ 88 | 89 | It is important to note that ``TypedEnv`` is separate and unrelated to the ``LocalEnv`` object that is provided via ``local.env``. 90 | 91 | While ``TypedEnv`` reads and writes directly to ``os.environ``, 92 | ``local.env`` is a frozen copy taken at the start of the python session. 93 | 94 | While ``TypedEnv`` is focused on parsing environment variables to be used by the current process, 95 | ``local.env``'s primary purpose is to manipulate the environment for child processes that are spawned 96 | via plumbum's :ref:`local commands `. 97 | -------------------------------------------------------------------------------- /docs/utils.rst: -------------------------------------------------------------------------------- 1 | .. _guide-utils: 2 | 3 | Utilities 4 | ========= 5 | 6 | The ``utils`` module contains a collection of useful utility functions. Note that they are not 7 | imported into the namespace of ``plumbum`` directly, and you have to explicitly import them, e.g. 8 | ``from plumbum.path.utils import copy``. 9 | 10 | * :func:`copy(src, dst) ` - Copies ``src`` to ``dst`` (recursively, if ``src`` 11 | is a directory). The arguments can be either local or remote paths -- the function will sort 12 | out all the necessary details. 13 | 14 | * If both paths are local, the files are copied locally 15 | 16 | * If one path is local and the other is remote, the function uploads/downloads the files 17 | 18 | * If both paths refer to the same remote machine, the function copies the files locally on the 19 | remote machine 20 | 21 | * If both paths refer to different remote machines, the function downloads the files to a 22 | temporary location and then uploads them to the destination 23 | 24 | * :func:`move(src, dst) ` - Moves ``src`` onto ``dst``. The arguments can be 25 | either local or remote -- the function will sort our all the necessary details (as in ``copy``) 26 | 27 | * :func:`delete(*paths) ` - Deletes the given sequence of paths; each path 28 | may be a string, a local/remote path object, or an iterable of paths. If any of the paths does 29 | not exist, the function silently ignores the error and continues. For example :: 30 | 31 | from plumbum.path.utils import delete 32 | delete(local.cwd // "*/*.pyc", local.cwd // "*/__pycache__") 33 | 34 | * :func:`gui_open(path) ` - Opens a file in the default editor on Windows, Mac, or Linux. Uses ``os.startfile`` if available (Windows), ``xdg_open`` (GNU), or ``open`` (Mac). 35 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | testfigure.pdf 2 | testfigure.svg 3 | testfigure.log 4 | testfigure.aux 5 | testfigure.png 6 | -------------------------------------------------------------------------------- /examples/PHSP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/examples/PHSP.png -------------------------------------------------------------------------------- /examples/SimpleColorCLI.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plumbum import cli, colors 4 | 5 | # from plumbum.colorlib import HTMLStyle, StyleFactory 6 | # plumbum.colors = StyleFactory(HTMLStyle) 7 | 8 | 9 | class MyApp(cli.Application): 10 | PROGNAME = colors.green 11 | VERSION = colors.blue | "1.0.2" 12 | COLOR_GROUPS = {"Meta-switches": colors.yellow} 13 | COLOR_GROUP_TITLES = {"Meta-switches": colors.bold & colors.yellow} 14 | opts = cli.Flag("--ops", help=colors.magenta | "This is help") 15 | 16 | def main(self): 17 | print("HI") 18 | 19 | 20 | if __name__ == "__main__": 21 | MyApp.run() 22 | -------------------------------------------------------------------------------- /examples/alignment.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | from plumbum import cli 5 | 6 | 7 | class App(cli.Application): 8 | # VERSION = "1.2.3" 9 | # x = cli.SwitchAttr("--lala") 10 | y = cli.Flag("-f") 11 | 12 | def main(self, x, y): 13 | pass 14 | 15 | 16 | @App.subcommand("bar") 17 | class Bar(cli.Application): 18 | z = cli.Flag("-z") 19 | 20 | def main(self, z, w): 21 | pass 22 | 23 | 24 | if __name__ == "__main__": 25 | App.run() 26 | -------------------------------------------------------------------------------- /examples/color.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | from plumbum import colors 5 | 6 | with colors.fg.red: 7 | print("This is in red") 8 | 9 | print("This is completely restored, even if an exception is thrown!") 10 | 11 | print("The library will restore color on exiting automatically.") 12 | print(colors.bold["This is bold and exciting!"]) 13 | print(colors.bg.cyan | "This is on a cyan background.") 14 | print(colors.fg[42] | "If your terminal supports 256 colors, this is colorful!") 15 | print() 16 | for c in colors: 17 | print(c + "\u2588", end="") 18 | colors.reset() 19 | print() 20 | print("Colors can be reset " + colors.underline["Too!"]) 21 | for c in colors[:16]: 22 | print(c["This is in color!"]) 23 | 24 | colors.red() 25 | print("This should clean up the color automatically on program exit...") 26 | -------------------------------------------------------------------------------- /examples/filecopy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | import logging 5 | 6 | from plumbum import cli, local 7 | from plumbum.path.utils import copy, delete 8 | 9 | logger = logging.getLogger("FileCopier") 10 | 11 | 12 | class FileCopier(cli.Application): 13 | overwrite = cli.Flag("-o", help="If given, overwrite existing files") 14 | 15 | @cli.switch(["-l", "--log-to-file"], argtype=str) 16 | def log_to_file(self, filename): 17 | """logs all output to the given file""" 18 | handler = logging.FileHandler(filename) 19 | logger.addHandler(handler) 20 | 21 | @cli.switch(["--verbose"], requires=["--log-to-file"]) 22 | def set_debug(self): 23 | """Sets verbose mode""" 24 | logger.setLevel(logging.DEBUG) 25 | 26 | def main(self, src, dst): 27 | if local.path(dst).exists(): 28 | if not self.overwrite: 29 | logger.debug("Oh no! That's terrible") 30 | raise ValueError("Destination already exists") 31 | delete(dst) 32 | 33 | logger.debug("I'm going to copy %s to %s", src, dst) 34 | copy(src, dst) 35 | logger.debug("Great success") 36 | 37 | 38 | if __name__ == "__main__": 39 | FileCopier.run() 40 | -------------------------------------------------------------------------------- /examples/fullcolor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | from plumbum import colors 5 | 6 | with colors: 7 | print("Do you believe in color, punk? DO YOU?") 8 | for i in range(0, 255, 10): 9 | for j in range(0, 255, 10): 10 | print("".join(colors.rgb(i, j, k)["\u2588"] for k in range(0, 255, 10))) 11 | -------------------------------------------------------------------------------- /examples/geet.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Examples:: 4 | 5 | $ python3 geet.py 6 | no command given 7 | 8 | $ python3 geet.py leet 9 | unknown command 'leet' 10 | 11 | $ python3 geet.py --help 12 | geet v1.7.2 13 | The l33t version control 14 | 15 | Usage: geet.py [SWITCHES] [SUBCOMMAND [SWITCHES]] args... 16 | Meta-switches: 17 | -h, --help Prints this help message and quits 18 | -v, --version Prints the program's version and quits 19 | 20 | Subcommands: 21 | commit creates a new commit in the current branch; see 22 | 'geet commit --help' for more info 23 | push pushes the current local branch to the remote 24 | one; see 'geet push --help' for more info 25 | 26 | $ python3 geet.py commit --help 27 | geet commit v1.7.2 28 | creates a new commit in the current branch 29 | 30 | Usage: geet commit [SWITCHES] 31 | Meta-switches: 32 | -h, --help Prints this help message and quits 33 | -v, --version Prints the program's version and quits 34 | 35 | Switches: 36 | -a automatically add changed files 37 | -m VALUE:str sets the commit message; required 38 | 39 | $ python3 geet.py commit -m "foo" 40 | committing... 41 | """ 42 | 43 | from __future__ import annotations 44 | 45 | try: 46 | import colorama 47 | 48 | colorama.init() 49 | except ImportError: 50 | pass 51 | 52 | from plumbum import cli, colors 53 | 54 | 55 | class Geet(cli.Application): 56 | SUBCOMMAND_HELPMSG = False 57 | DESCRIPTION = colors.yellow | """The l33t version control""" 58 | PROGNAME = colors.green 59 | VERSION = colors.blue | "1.7.2" 60 | COLOR_USAGE_TITLE = colors.bold | colors.magenta 61 | COLOR_USAGE = colors.magenta 62 | 63 | _group_names = ["Meta-switches", "Switches", "Sub-commands"] 64 | 65 | COLOR_GROUPS = dict( 66 | zip(_group_names, [colors.do_nothing, colors.skyblue1, colors.yellow]) 67 | ) 68 | 69 | COLOR_GROUP_TITLES = dict( 70 | zip( 71 | _group_names, 72 | [colors.bold, colors.bold | colors.skyblue1, colors.bold | colors.yellow], 73 | ) 74 | ) 75 | 76 | verbosity = cli.SwitchAttr( 77 | "--verbosity", 78 | cli.Set("low", "high", "some-very-long-name", "to-test-wrap-around"), 79 | help=colors.cyan 80 | | "sets the verbosity level of the geet tool. doesn't really do anything except for testing line-wrapping " 81 | "in help " * 3, 82 | ) 83 | verbositie = cli.SwitchAttr( 84 | "--verbositie", 85 | cli.Set("low", "high", "some-very-long-name", "to-test-wrap-around"), 86 | help=colors.hotpink 87 | | "sets the verbosity level of the geet tool. doesn't really do anything except for testing line-wrapping " 88 | "in help " * 3, 89 | ) 90 | 91 | 92 | @Geet.subcommand(colors.red | "commit") 93 | class GeetCommit(cli.Application): 94 | """creates a new commit in the current branch""" 95 | 96 | auto_add = cli.Flag("-a", help="automatically add changed files") 97 | message = cli.SwitchAttr("-m", str, mandatory=True, help="sets the commit message") 98 | 99 | def main(self): 100 | print("committing...") 101 | 102 | 103 | GeetCommit.unbind_switches("-v", "--version") 104 | 105 | 106 | @Geet.subcommand("push") 107 | class GeetPush(cli.Application): 108 | """pushes the current local branch to the remote one""" 109 | 110 | tags = cli.Flag("--tags", help="whether to push tags (default is False)") 111 | 112 | def main(self, remote, branch="master"): 113 | print(f"pushing to {remote}/{branch}...") 114 | 115 | 116 | if __name__ == "__main__": 117 | Geet.run() 118 | -------------------------------------------------------------------------------- /examples/make_figures.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | from plumbum import FG, cli, local 5 | from plumbum.cmd import convert, pdflatex 6 | from plumbum.path.utils import delete 7 | 8 | 9 | def image_comp(item): 10 | pdflatex["-shell-escape", item] & FG 11 | print("Converting", item) 12 | convert[item.with_suffix(".svg"), item.with_suffix(".png")] & FG 13 | 14 | delete( 15 | item.with_suffix(".log"), 16 | item.with_suffix(".aux"), 17 | ) 18 | 19 | 20 | class MyApp(cli.Application): 21 | def main(self, *srcfiles): 22 | print("Tex files should start with:") 23 | print(r"\documentclass[tikz,convert={outfile=\jobname.svg}]{standalone}") 24 | items = map(cli.ExistingFile, srcfiles) if srcfiles else local.cwd // "*.tex" 25 | for item in items: 26 | image_comp(item) 27 | 28 | 29 | if __name__ == "__main__": 30 | MyApp.run() 31 | -------------------------------------------------------------------------------- /examples/simple_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | $ python3 simple_cli.py --help 4 | simple_cli.py v1.0 5 | 6 | Usage: simple_cli.py [SWITCHES] srcfiles... 7 | Meta-switches: 8 | -h, --help Prints this help message and quits 9 | --version Prints the program's version and quits 10 | 11 | Switches: 12 | -I VALUE:str Specify include directories; may be given 13 | multiple times 14 | --loglevel LEVEL:int Sets the log-level of the logger 15 | -v, --verbose Enable verbose mode 16 | 17 | $ python3 simple_cli.py x.cpp y.cpp z.cpp 18 | Verbose: False 19 | Include dirs: [] 20 | Compiling: ('x.cpp', 'y.cpp', 'z.cpp') 21 | 22 | $ python3 simple_cli.py -v 23 | Verbose: True 24 | Include dirs: [] 25 | Compiling: () 26 | 27 | $ python3 simple_cli.py -v -Ifoo/bar -Ispam/eggs 28 | Verbose: True 29 | Include dirs: ['foo/bar', 'spam/eggs'] 30 | Compiling: () 31 | 32 | $ python3 simple_cli.py -v -I foo/bar -Ispam/eggs x.cpp y.cpp z.cpp 33 | Verbose: True 34 | Include dirs: ['foo/bar', 'spam/eggs'] 35 | Compiling: ('x.cpp', 'y.cpp', 'z.cpp') 36 | """ 37 | 38 | from __future__ import annotations 39 | 40 | import logging 41 | 42 | from plumbum import cli 43 | 44 | 45 | class MyCompiler(cli.Application): 46 | verbose = cli.Flag(["-v", "--verbose"], help="Enable verbose mode") 47 | include_dirs = cli.SwitchAttr("-I", list=True, help="Specify include directories") 48 | 49 | @cli.switch("-loglevel", int) 50 | def set_log_level(self, level): 51 | """Sets the log-level of the logger""" 52 | logging.root.setLevel(level) 53 | 54 | def main(self, *srcfiles): 55 | print("Verbose:", self.verbose) 56 | print("Include dirs:", self.include_dirs) 57 | print("Compiling:", srcfiles) 58 | 59 | 60 | if __name__ == "__main__": 61 | MyCompiler() 62 | -------------------------------------------------------------------------------- /examples/testfigure.tex: -------------------------------------------------------------------------------- 1 | \documentclass[tikz,convert={outfile=\jobname.svg}]{standalone} 2 | %\usetikzlibrary{...}% tikz package already loaded by 'tikz' option 3 | \begin{document} 4 | \begin{tikzpicture}% Example: 5 | \draw (0,0) -- (10,10); % ... 6 | \draw (10,0) -- (0,10); % ... 7 | \node at (5,5) {Lorem ipsum at domine standalonus}; 8 | \end{tikzpicture} 9 | \end{document} 10 | -------------------------------------------------------------------------------- /experiments/parallel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plumbum.commands.base import BaseCommand 4 | from plumbum.commands.processes import CommandNotFound, ProcessExecutionError, run_proc 5 | 6 | 7 | def make_concurrent(self, rhs): 8 | if not isinstance(rhs, BaseCommand): 9 | raise TypeError("rhs must be an instance of BaseCommand") 10 | 11 | if isinstance(self, ConcurrentCommand): 12 | if isinstance(rhs, ConcurrentCommand): 13 | self.commands.extend(rhs.commands) 14 | else: 15 | self.commands.append(rhs) 16 | return self 17 | 18 | if isinstance(rhs, ConcurrentCommand): 19 | rhs.commands.insert(0, self) 20 | return rhs 21 | 22 | return ConcurrentCommand(self, rhs) 23 | 24 | 25 | BaseCommand.__and__ = make_concurrent 26 | 27 | 28 | class ConcurrentPopen: 29 | def __init__(self, procs): 30 | self.procs = procs 31 | self.stdin = None 32 | self.stdout = None 33 | self.stderr = None 34 | self.custom_encoding = None 35 | self.returncode = None 36 | 37 | @property 38 | def argv(self): 39 | return [getattr(proc, "argv", []) for proc in self.procs] 40 | 41 | def poll(self): 42 | if self.returncode is not None: 43 | return self.returncode 44 | rcs = [proc.poll() for proc in self.procs] 45 | if any(rc is None for rc in rcs): 46 | return None 47 | self.returncode = 0 48 | for rc in rcs: 49 | if rc != 0: 50 | self.returncode = rc 51 | break 52 | return self.returncode 53 | 54 | def wait(self): 55 | for proc in self.procs: 56 | proc.wait() 57 | return self.poll() 58 | 59 | def communicate(self, input=None): 60 | if input: 61 | raise ValueError("Cannot pass input to ConcurrentPopen.communicate") 62 | out_err_tuples = [proc.communicate() for proc in self.procs] 63 | self.wait() 64 | return tuple(zip(*out_err_tuples)) 65 | 66 | 67 | class ConcurrentCommand(BaseCommand): 68 | def __init__(self, *commands): 69 | self.commands = list(commands) 70 | 71 | def formulate(self, level=0, args=()): 72 | form = ["("] 73 | for cmd in self.commands: 74 | form.extend(cmd.formulate(level, args)) 75 | form.append("&") 76 | return [*form, ")"] 77 | 78 | def popen(self, *args, **kwargs): 79 | return ConcurrentPopen([cmd[args].popen(**kwargs) for cmd in self.commands]) 80 | 81 | def __getitem__(self, args): 82 | """Creates a bound-command with the given arguments""" 83 | if not isinstance(args, (tuple, list)): 84 | args = [ 85 | args, 86 | ] 87 | if not args: 88 | return self 89 | 90 | return ConcurrentCommand(*(cmd[args] for cmd in self.commands)) 91 | 92 | 93 | class Cluster: 94 | def __init__(self, *machines): 95 | self.machines = list(machines) 96 | 97 | def __enter__(self): 98 | return self 99 | 100 | def __exit__(self, t, v, tb): 101 | self.close() 102 | 103 | def close(self): 104 | for mach in self.machines: 105 | mach.close() 106 | del self.machines[:] 107 | 108 | def add_machine(self, machine): 109 | self.machines.append(machine) 110 | 111 | def __iter__(self): 112 | return iter(self.machines) 113 | 114 | def filter(self, pred): 115 | return self.__class__(filter(pred, self)) 116 | 117 | def which(self, progname): 118 | return [mach.which(progname) for mach in self] 119 | 120 | def list_processes(self): 121 | return [mach.list_processes() for mach in self] 122 | 123 | def pgrep(self, pattern): 124 | return [mach.pgrep(pattern) for mach in self] 125 | 126 | def path(self, *parts): 127 | return [mach.path(*parts) for mach in self] 128 | 129 | def __getitem__(self, progname): 130 | if not isinstance(progname, str): 131 | raise TypeError( 132 | "progname must be a string, not {!r}".format( 133 | type( 134 | progname, 135 | ) 136 | ) 137 | ) 138 | return ConcurrentCommand(*(mach[progname] for mach in self)) 139 | 140 | def __contains__(self, cmd): 141 | try: 142 | self[cmd] 143 | except CommandNotFound: 144 | return False 145 | else: 146 | return True 147 | 148 | @property 149 | def python(self): 150 | return ConcurrentCommand(*(mach.python for mach in self)) 151 | 152 | def session(self): 153 | return ClusterSession(*(mach.session() for mach in self)) 154 | 155 | 156 | class ClusterSession: 157 | def __init__(self, *sessions): 158 | self.sessions = sessions 159 | 160 | def __iter__(self): 161 | return iter(self.sessions) 162 | 163 | def __enter__(self): 164 | return self 165 | 166 | def __exit__(self, t, v, tb): 167 | self.close() 168 | 169 | def __del__(self): 170 | try: # noqa: SIM105 171 | self.close() 172 | except Exception: 173 | pass 174 | 175 | def alive(self): 176 | """Returns ``True`` if the underlying shells are all alive, ``False`` otherwise""" 177 | return all(session.alive for session in self) 178 | 179 | def close(self): 180 | """Closes (terminates) all underlying shell sessions""" 181 | for session in self.sessions: 182 | session.close() 183 | del self.sessions[:] 184 | 185 | def popen(self, cmd): 186 | return ConcurrentPopen([session.popen(cmd) for session in self]) 187 | 188 | def run(self, cmd, retcode=None): 189 | return run_proc(self.popen(cmd), retcode) 190 | 191 | 192 | if __name__ == "__main__": 193 | from plumbum import local 194 | from plumbum.cmd import date, ls, sleep 195 | 196 | c = ls & date & sleep[1] 197 | print(c()) 198 | 199 | c = ls & date & sleep[1] & sleep["-z"] 200 | try: 201 | c() 202 | except ProcessExecutionError as ex: 203 | print(ex) 204 | else: 205 | raise AssertionError("Expected an ProcessExecutionError") 206 | 207 | clst = Cluster(local, local, local) 208 | print(clst["ls"]()) 209 | 210 | # This works fine 211 | print(local.session().run("echo $$")) 212 | 213 | # this does not 214 | ret, stdout, stderr = clst.session().run("echo $$") 215 | print(ret) 216 | ret = [int(pid) for pid in stdout] 217 | assert len(set(ret)) == 3 218 | -------------------------------------------------------------------------------- /experiments/test_parallel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import unittest 4 | 5 | from parallel import Cluster 6 | 7 | from plumbum import SshMachine, local 8 | 9 | TEST_HOST = "127.0.0.1" 10 | 11 | 12 | class TestParallel(unittest.TestCase): 13 | def setUp(self): 14 | self.remotes = [] 15 | 16 | def connect(self): 17 | m = SshMachine(TEST_HOST) 18 | self.remotes.append(m) 19 | return m 20 | 21 | def tearDown(self): 22 | for m in self.remotes: 23 | m.close() 24 | 25 | def test_parallel(self): 26 | m = Cluster(local, local) 27 | import time 28 | 29 | t = time.time() 30 | ret = m["sleep"]("2") 31 | assert len(ret) == 2 32 | assert 2 <= time.time() - t < 4 33 | 34 | def test_locals(self): 35 | m = Cluster(local, local, local) 36 | # we should get 3 different proc ids 37 | ret = m["bash"]["-c"]["echo $$"]() 38 | ret = list(map(int, ret)) 39 | assert len(set(ret)) == 3 40 | 41 | def test_sessions(self): 42 | m = Cluster(local, self.connect(), local, self.connect()) 43 | # we should get 4 different proc ids 44 | ret, stdout, stderr = m.session().run("echo $$") 45 | ret = [int(pid) for pid in stdout] 46 | assert len(set(ret)) == 4 47 | 48 | def test_commands(self): 49 | cmds = local["echo"]["1"] & local["echo"]["2"] 50 | ret = cmds() 51 | a, b = map(int, ret) 52 | assert (a, b) == (1, 2) 53 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S uv run 2 | 3 | # /// script 4 | # dependencies = ["nox>=2025.2.9"] 5 | # /// 6 | 7 | from __future__ import annotations 8 | 9 | import nox 10 | 11 | nox.needs_version = ">=2025.2.9" 12 | nox.options.default_venv_backend = "uv|virtualenv" 13 | 14 | PYPROJECT = nox.project.load_toml() 15 | ALL_PYTHONS = nox.project.python_versions(PYPROJECT) 16 | 17 | 18 | @nox.session(reuse_venv=True) 19 | def lint(session): 20 | """ 21 | Run the linter. 22 | """ 23 | session.install("pre-commit") 24 | session.run("pre-commit", "run", "--all-files", *session.posargs) 25 | 26 | 27 | @nox.session 28 | def pylint(session): 29 | """ 30 | Run pylint. 31 | """ 32 | 33 | session.install("-e.", "paramiko", "ipython", "pylint") 34 | session.run("pylint", "plumbum", *session.posargs) 35 | 36 | 37 | @nox.session(python=ALL_PYTHONS, reuse_venv=True) 38 | def tests(session): 39 | """ 40 | Run the unit and regular tests. 41 | """ 42 | test_deps = nox.project.dependency_groups(PYPROJECT, "test") 43 | session.install("-e.", *test_deps) 44 | session.run("pytest", *session.posargs, env={"PYTHONTRACEMALLOC": "5"}) 45 | 46 | 47 | @nox.session(reuse_venv=True, default=False) 48 | def docs(session): 49 | """ 50 | Build the docs. Pass "serve" to serve. 51 | """ 52 | 53 | doc_deps = nox.project.dependency_groups(PYPROJECT, "docs") 54 | session.install("-e.", *doc_deps) 55 | session.chdir("docs") 56 | session.run("sphinx-build", "-M", "html", ".", "_build") 57 | 58 | if session.posargs: 59 | if "serve" in session.posargs: 60 | session.log("Launching docs at http://localhost:8000/ - use Ctrl-C to quit") 61 | session.run("python", "-m", "http.server", "8000", "-d", "_build/html") 62 | else: 63 | session.log("Unsupported argument to docs") 64 | 65 | 66 | @nox.session(default=False) 67 | def build(session): 68 | """ 69 | Build an SDist and wheel. 70 | """ 71 | 72 | session.install("build") 73 | session.run("python", "-m", "build") 74 | 75 | 76 | if __name__ == "__main__": 77 | nox.main() 78 | -------------------------------------------------------------------------------- /plumbum/__init__.py: -------------------------------------------------------------------------------- 1 | r""" 2 | Plumbum Shell Combinators 3 | ------------------------- 4 | A wrist-handy library for writing shell-like scripts in Python, that can serve 5 | as a ``Popen`` replacement, and much more:: 6 | 7 | >>> from plumbum.cmd import ls, grep, wc, cat 8 | >>> ls() 9 | 'build.py\ndist\ndocs\nLICENSE\nplumbum\nREADME.rst\nsetup.py\ntests\ntodo.txt\n' 10 | >>> chain = ls["-a"] | grep["-v", "py"] | wc["-l"] 11 | >>> print(chain) 12 | /bin/ls -a | /bin/grep -v py | /usr/bin/wc -l 13 | >>> chain() 14 | '12\n' 15 | >>> ((ls["-a"] | grep["-v", "py"]) > "/tmp/foo.txt")() 16 | '' 17 | >>> ((cat < "/tmp/foo.txt") | wc["-l"])() 18 | '12\n' 19 | >>> from plumbum import local, FG, BG 20 | >>> with local.cwd("/tmp"): 21 | ... (ls | wc["-l"]) & FG 22 | ... 23 | 13 # printed directly to the interpreter's stdout 24 | >>> (ls | wc["-l"]) & BG 25 | 26 | >>> f = _ 27 | >>> f.stdout # will wait for the process to terminate 28 | '9\n' 29 | 30 | Plumbum includes local/remote path abstraction, working directory and environment 31 | manipulation, process execution, remote process execution over SSH, tunneling, 32 | SCP-based upload/download, and a {arg|opt}parse replacement for the easy creation 33 | of command-line interface (CLI) programs. 34 | 35 | See https://plumbum.readthedocs.io for full details 36 | """ 37 | 38 | from __future__ import annotations 39 | 40 | # Avoids a circular import error later 41 | import plumbum.path # noqa: F401 42 | from plumbum.commands import ( 43 | BG, 44 | ERROUT, 45 | FG, 46 | NOHUP, 47 | RETCODE, 48 | TEE, 49 | TF, 50 | CommandNotFound, 51 | ProcessExecutionError, 52 | ProcessLineTimedOut, 53 | ProcessTimedOut, 54 | ) 55 | from plumbum.machines import BaseRemoteMachine, PuttyMachine, SshMachine, local 56 | from plumbum.path import LocalPath, Path, RemotePath 57 | from plumbum.version import version 58 | 59 | __author__ = "Tomer Filiba (tomerfiliba@gmail.com)" 60 | __version__ = version 61 | 62 | __all__ = ( 63 | "BG", 64 | "ERROUT", 65 | "FG", 66 | "NOHUP", 67 | "RETCODE", 68 | "TEE", 69 | "TF", 70 | "BaseRemoteMachine", 71 | "CommandNotFound", 72 | "LocalPath", 73 | "Path", 74 | "ProcessExecutionError", 75 | "ProcessLineTimedOut", 76 | "ProcessTimedOut", 77 | "PuttyMachine", 78 | "RemotePath", 79 | "SshMachine", 80 | "__author__", 81 | "__version__", 82 | "cmd", 83 | "local", 84 | ) 85 | 86 | from . import cmd 87 | 88 | 89 | def __dir__(): 90 | "Support nice tab completion" 91 | return __all__ 92 | -------------------------------------------------------------------------------- /plumbum/_testtools.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import platform 5 | import sys 6 | 7 | import pytest 8 | 9 | skip_without_chown = pytest.mark.skipif( 10 | not hasattr(os, "chown"), reason="os.chown not supported" 11 | ) 12 | 13 | skip_without_tty = pytest.mark.skipif(not sys.stdin.isatty(), reason="Not a TTY") 14 | 15 | skip_on_windows = pytest.mark.skipif( 16 | sys.platform == "win32", reason="Windows not supported for this test (yet)" 17 | ) 18 | 19 | xfail_on_windows = pytest.mark.xfail( 20 | sys.platform == "win32", reason="Windows not supported for this test (yet)" 21 | ) 22 | 23 | xfail_on_pypy = pytest.mark.xfail( 24 | platform.python_implementation() == "PyPy", 25 | reason="PyPy is currently not working on this test!", 26 | ) 27 | -------------------------------------------------------------------------------- /plumbum/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .application import Application 4 | from .config import Config, ConfigINI 5 | from .switches import ( 6 | CSV, 7 | CountOf, 8 | ExistingDirectory, 9 | ExistingFile, 10 | Flag, 11 | NonexistentPath, 12 | Predicate, 13 | Range, 14 | Set, 15 | SwitchAttr, 16 | SwitchError, 17 | autoswitch, 18 | positional, 19 | switch, 20 | ) 21 | 22 | __all__ = ( 23 | "CSV", 24 | "Application", 25 | "Config", 26 | "ConfigINI", 27 | "CountOf", 28 | "ExistingDirectory", 29 | "ExistingFile", 30 | "Flag", 31 | "NonexistentPath", 32 | "Predicate", 33 | "Range", 34 | "Set", 35 | "SwitchAttr", 36 | "SwitchError", 37 | "autoswitch", 38 | "positional", 39 | "switch", 40 | ) 41 | -------------------------------------------------------------------------------- /plumbum/cli/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | from abc import ABC, abstractmethod 5 | from configparser import ConfigParser, NoOptionError, NoSectionError 6 | 7 | from plumbum import local 8 | 9 | 10 | class ConfigBase(ABC): 11 | """Base class for Config parsers. 12 | 13 | :param filename: The file to use 14 | 15 | The ``with`` statement can be used to automatically try to read on entering and write if changed on exiting. Otherwise, use ``.read`` and ``.write`` as needed. Set and get the options using ``[]`` syntax. 16 | 17 | Usage: 18 | 19 | with Config("~/.myprog_rc") as conf: 20 | value = conf.get("option", "default") 21 | value2 = conf["option"] # shortcut for default=None 22 | 23 | """ 24 | 25 | __slots__ = ["changed", "filename"] 26 | 27 | def __init__(self, filename): 28 | self.filename = local.path(filename) 29 | self.changed = False 30 | 31 | def __enter__(self): 32 | with contextlib.suppress(FileNotFoundError): 33 | self.read() 34 | return self 35 | 36 | def __exit__(self, exc_type, exc_val, exc_tb): 37 | if self.changed: 38 | self.write() 39 | 40 | @abstractmethod 41 | def read(self): 42 | """Read in the linked file""" 43 | 44 | @abstractmethod 45 | def write(self): 46 | """Write out the linked file""" 47 | self.changed = False 48 | 49 | @abstractmethod 50 | def _get(self, option): 51 | """Internal get function for subclasses""" 52 | 53 | @abstractmethod 54 | def _set(self, option, value): 55 | """Internal set function for subclasses. Must return the value that was set.""" 56 | 57 | def get(self, option, default=None): 58 | "Get an item from the store, returns default if fails" 59 | try: 60 | return self._get(option) 61 | except KeyError: 62 | self.changed = True 63 | return self._set(option, default) 64 | 65 | def set(self, option, value): 66 | """Set an item, mark this object as changed""" 67 | self.changed = True 68 | self._set(option, value) 69 | 70 | def __getitem__(self, option): 71 | return self._get(option) 72 | 73 | def __setitem__(self, option, value): 74 | return self.set(option, value) 75 | 76 | 77 | class ConfigINI(ConfigBase): 78 | DEFAULT_SECTION = "DEFAULT" 79 | slots = ["parser"] 80 | 81 | def __init__(self, filename): 82 | super().__init__(filename) 83 | self.parser = ConfigParser() 84 | 85 | def read(self): 86 | self.parser.read(self.filename) 87 | super().read() 88 | 89 | def write(self): 90 | with open(self.filename, "w", encoding="utf-8") as f: 91 | self.parser.write(f) 92 | super().write() 93 | 94 | @classmethod 95 | def _sec_opt(cls, option): 96 | if "." not in option: 97 | sec = cls.DEFAULT_SECTION 98 | else: 99 | sec, option = option.split(".", 1) 100 | return sec, option 101 | 102 | def _get(self, option): 103 | sec, option = self._sec_opt(option) 104 | 105 | try: 106 | return self.parser.get(sec, option) 107 | except (NoSectionError, NoOptionError): 108 | raise KeyError(f"{sec}:{option}") from None 109 | 110 | def _set(self, option, value): 111 | sec, option = self._sec_opt(option) 112 | try: 113 | self.parser.set(sec, option, str(value)) 114 | except NoSectionError: 115 | self.parser.add_section(sec) 116 | self.parser.set(sec, option, str(value)) 117 | return str(value) 118 | 119 | 120 | Config = ConfigINI 121 | -------------------------------------------------------------------------------- /plumbum/cli/i18n.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import locale 4 | 5 | # High performance method for English (no translation needed) 6 | loc = locale.getlocale()[0] 7 | if loc is None or loc.startswith("en") or loc == "C": 8 | 9 | class NullTranslation: 10 | def gettext(self, str1: str) -> str: # pylint: disable=no-self-use 11 | return str1 12 | 13 | def ngettext(self, str1, strN, n): # pylint: disable=no-self-use 14 | if n == 1: 15 | return str1.replace("{0}", str(n)) 16 | 17 | return strN.replace("{0}", str(n)) 18 | 19 | def get_translation_for( 20 | package_name: str, # noqa: ARG001 21 | ) -> NullTranslation: 22 | return NullTranslation() 23 | 24 | else: 25 | import gettext 26 | from importlib.resources import as_file, files 27 | 28 | def get_translation_for(package_name: str) -> gettext.NullTranslations: # type: ignore[misc] 29 | """ 30 | Find and return gettext translation for package 31 | """ 32 | assert loc is not None 33 | 34 | if "." in package_name: 35 | package_name = ".".join(package_name.split(".")[:-1]) 36 | 37 | localedir = None 38 | 39 | with as_file(files(package_name) / "i18n") as mydir: 40 | for localedir in mydir, None: 41 | localefile = gettext.find(package_name, localedir, languages=[loc]) 42 | if localefile: 43 | break 44 | 45 | return gettext.translation( 46 | package_name, localedir=localedir, languages=[loc], fallback=True 47 | ) 48 | -------------------------------------------------------------------------------- /plumbum/cli/i18n/de/LC_MESSAGES/plumbum.cli.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/plumbum/cli/i18n/de/LC_MESSAGES/plumbum.cli.mo -------------------------------------------------------------------------------- /plumbum/cli/i18n/fr.po: -------------------------------------------------------------------------------- 1 | # French Translations for PACKAGE package. 2 | # Traduction francaise du paquet PACKAGE. 3 | # Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER 4 | # This file is distributed under the same license as the PACKAGE package. 5 | # Joel Closier , 2017. 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-10-05 22:39-0400\n" 12 | "PO-Revision-Date: 2017-10-14 15:04+0200\n" 13 | "Last-Translator: Joel Closier \n" 14 | "Language: fr_FR\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | 19 | #: plumbum/cli/application.py:69 20 | #, python-brace-format 21 | msgid "Subcommand({self.name}, {self.subapplication})" 22 | msgstr "Sous-commande({self.name}, {self.subapplication})" 23 | 24 | #: plumbum/cli/application.py:73 25 | msgid "Switches" 26 | msgstr "Options" 27 | 28 | #: plumbum/cli/application.py:73 29 | msgid "Meta-switches" 30 | msgstr "Meta-options" 31 | 32 | #: plumbum/cli/application.py:163 33 | #, python-brace-format 34 | msgid "see '{parent} {sub} --help' for more info" 35 | msgstr "voir '{parent} {sub} --help' pour plus d'information" 36 | 37 | #: plumbum/cli/application.py:220 38 | #, fuzzy 39 | msgid "Sub-command names cannot start with '-'" 40 | msgstr "le nom des Sous-commandes ne peut pas commencer avec '-' " 41 | 42 | #: plumbum/cli/application.py:238 43 | #, fuzzy, python-brace-format 44 | msgid "Switch {name} already defined and is not overridable" 45 | msgstr "Option {name} est déjà définie et ne peut pas être sur-écrite" 46 | 47 | #: plumbum/cli/application.py:343 48 | #, python-brace-format 49 | msgid "Ambiguous partial switch {0}" 50 | msgstr "" 51 | 52 | #: plumbum/cli/application.py:348 plumbum/cli/application.py:373 53 | #: plumbum/cli/application.py:389 54 | #, python-brace-format 55 | msgid "Unknown switch {0}" 56 | msgstr "Option inconnue {0}" 57 | 58 | #: plumbum/cli/application.py:353 plumbum/cli/application.py:362 59 | #: plumbum/cli/application.py:381 60 | #, python-brace-format 61 | msgid "Switch {0} requires an argument" 62 | msgstr "Option {0} nécessite un argument" 63 | 64 | #: plumbum/cli/application.py:401 65 | #, python-brace-format 66 | msgid "Switch {0} already given" 67 | msgstr "Option {0} déjà donnée" 68 | 69 | #: plumbum/cli/application.py:403 70 | #, python-brace-format 71 | msgid "Switch {0} already given ({1} is equivalent)" 72 | msgstr "Option {0} déjà donnée ({1} est équivalent)" 73 | 74 | #: plumbum/cli/application.py:451 75 | msgid "" 76 | "Argument of {name} expected to be {argtype}, not {val!r}:\n" 77 | " {ex!r}" 78 | msgstr "" 79 | "Argument de {name} doit être {argtype} , et non {val!r}:\n" 80 | " {ex!r}" 81 | 82 | #: plumbum/cli/application.py:470 83 | #, python-brace-format 84 | msgid "Switch {0} is mandatory" 85 | msgstr "Option {0} obligatoire" 86 | 87 | #: plumbum/cli/application.py:490 88 | #, python-brace-format 89 | msgid "Given {0}, the following are missing {1}" 90 | msgstr "Etant donné {0}, ce qui suit est manquant {1}" 91 | 92 | #: plumbum/cli/application.py:498 93 | #, python-brace-format 94 | msgid "Given {0}, the following are invalid {1}" 95 | msgstr "Etant donné {0}, ce qui suit est invalide {1}" 96 | 97 | #: plumbum/cli/application.py:515 98 | #, python-brace-format 99 | msgid "Expected at least {0} positional argument, got {1}" 100 | msgid_plural "Expected at least {0} positional arguments, got {1}" 101 | msgstr[0] "Au moins {0} argument de position attendu, reçu {0}" 102 | msgstr[1] "Au moins {0} arguments de position, reçu {0}" 103 | 104 | #: plumbum/cli/application.py:523 105 | #, python-brace-format 106 | msgid "Expected at most {0} positional argument, got {1}" 107 | msgid_plural "Expected at most {0} positional arguments, got {1}" 108 | msgstr[0] "Au plus {0} argument de position attendu, reçu {0}" 109 | msgstr[1] "Au plus {0} arguments de position, reçu {0}" 110 | 111 | #: plumbum/cli/application.py:624 112 | #, python-brace-format 113 | msgid "Error: {0}" 114 | msgstr "Erreur: {0}" 115 | 116 | #: plumbum/cli/application.py:625 plumbum/cli/application.py:711 117 | #: plumbum/cli/application.py:716 118 | msgid "------" 119 | msgstr "------" 120 | 121 | #: plumbum/cli/application.py:694 122 | #, python-brace-format 123 | msgid "Switch {0} must be a sequence (iterable)" 124 | msgstr "Option {0} doit être une séquence (itérable)" 125 | 126 | #: plumbum/cli/application.py:699 127 | #, python-brace-format 128 | msgid "Switch {0} is a boolean flag" 129 | msgstr "Option {0} est un booléen" 130 | 131 | #: plumbum/cli/application.py:710 132 | #, python-brace-format 133 | msgid "Unknown sub-command '{0}'" 134 | msgstr "Sous-commande inconnue '{0}'" 135 | 136 | #: plumbum/cli/application.py:715 137 | msgid "No sub-command given" 138 | msgstr "Pas de sous-commande donnée" 139 | 140 | #: plumbum/cli/application.py:721 141 | msgid "main() not implemented" 142 | msgstr "main() n'est pas implémenté" 143 | 144 | #: plumbum/cli/application.py:734 145 | #, fuzzy 146 | msgid "Prints help messages of all sub-commands and quits" 147 | msgstr "Imprime les messages d'aide de toutes les sous-commandes et sort" 148 | 149 | #: plumbum/cli/application.py:754 150 | msgid "Prints this help message and quits" 151 | msgstr "Imprime ce message d'aide et sort" 152 | 153 | #: plumbum/cli/application.py:877 154 | msgid "Usage:" 155 | msgstr "Utilisation:" 156 | 157 | #: plumbum/cli/application.py:883 158 | #, python-brace-format 159 | msgid " {progname} [SWITCHES] [SUBCOMMAND [SWITCHES]] {tailargs}\n" 160 | msgstr " {progname} [OPTIONS] [SOUS_COMMANDE [OPTIONS]] {tailargs}\n" 161 | 162 | #: plumbum/cli/application.py:886 163 | #, python-brace-format 164 | msgid " {progname} [SWITCHES] {tailargs}\n" 165 | msgstr " {progname} [OPTIONS] {tailargs}\n" 166 | 167 | #: plumbum/cli/application.py:936 168 | msgid "; may be given multiple times" 169 | msgstr "; peut être fourni plusieurs fois" 170 | 171 | #: plumbum/cli/application.py:938 172 | msgid "; required" 173 | msgstr "; nécessaire" 174 | 175 | #: plumbum/cli/application.py:940 176 | #, python-brace-format 177 | msgid "; requires {0}" 178 | msgstr "; nécessite {0}" 179 | 180 | #: plumbum/cli/application.py:947 181 | #, python-brace-format 182 | msgid "; excludes {0}" 183 | msgstr "; exclut {0}" 184 | 185 | #: plumbum/cli/application.py:966 186 | #, fuzzy 187 | msgid "Sub-commands:" 188 | msgstr "Sous-Commandes:" 189 | 190 | #: plumbum/cli/application.py:1014 191 | msgid "Prints the program's version and quits" 192 | msgstr "Imprime la version du programme et sort" 193 | 194 | #: plumbum/cli/application.py:1019 195 | msgid "(version not set)" 196 | msgstr "(version non définie)" 197 | 198 | #: plumbum/cli/switches.py:167 plumbum/cli/switches.py:225 199 | msgid "VALUE" 200 | msgstr "VALEUR" 201 | 202 | #: plumbum/cli/switches.py:238 203 | #, python-brace-format 204 | msgid "; the default is {0}" 205 | msgstr "; la valeur par défaut est {0}" 206 | 207 | #: plumbum/cli/switches.py:437 208 | #, python-brace-format 209 | msgid "Not in range [{0:d}..{1:d}]" 210 | msgstr "Pas dans la chaîne [{0:d}..{1:d}]" 211 | 212 | #: plumbum/cli/switches.py:546 213 | #, python-brace-format 214 | msgid "{0} is not a directory" 215 | msgstr "{0} n'est pas un répertoire" 216 | 217 | #: plumbum/cli/switches.py:565 218 | #, python-brace-format 219 | msgid "{0} is not a file" 220 | msgstr "{0} n'est pas un fichier" 221 | 222 | #: plumbum/cli/switches.py:574 223 | #, python-brace-format 224 | msgid "{0} already exists" 225 | msgstr "{0} existe déjà" 226 | 227 | #, python-brace-format 228 | #~ msgid "got unexpected keyword argument(s): {0}" 229 | #~ msgstr "mot-clé inconnu donné comme argument: {0}" 230 | 231 | #, python-brace-format 232 | #~ msgid "Expected one of {0}" 233 | #~ msgstr "un des {0} attendu" 234 | -------------------------------------------------------------------------------- /plumbum/cli/i18n/fr/LC_MESSAGES/plumbum.cli.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/plumbum/cli/i18n/fr/LC_MESSAGES/plumbum.cli.mo -------------------------------------------------------------------------------- /plumbum/cli/i18n/nl.po: -------------------------------------------------------------------------------- 1 | # Dutch Translations for PACKAGE package. 2 | # Nederlandse vertaling voor het PACKAGE pakket. 3 | # Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER 4 | # This file is distributed under the same license as the PACKAGE package. 5 | # Roel Aaij , 2017. 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-10-05 22:39-0400\n" 12 | "PO-Revision-Date: 2017-10-14 15:04+0200\n" 13 | "Last-Translator: Roel Aaij \n" 14 | "Language: nl_NL\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | 19 | #: plumbum/cli/application.py:69 20 | #, python-brace-format 21 | msgid "Subcommand({self.name}, {self.subapplication})" 22 | msgstr "Subopdracht({self.name}, {self.subapplication})" 23 | 24 | #: plumbum/cli/application.py:73 25 | msgid "Switches" 26 | msgstr "Opties" 27 | 28 | #: plumbum/cli/application.py:73 29 | msgid "Meta-switches" 30 | msgstr "Meta-opties" 31 | 32 | #: plumbum/cli/application.py:163 33 | #, python-brace-format 34 | msgid "see '{parent} {sub} --help' for more info" 35 | msgstr "zie '{parent} {sub} --help' voor meer informatie" 36 | 37 | #: plumbum/cli/application.py:220 38 | #, fuzzy 39 | msgid "Sub-command names cannot start with '-'" 40 | msgstr "Namen van subopdrachten mogen niet met '-' beginnen" 41 | 42 | #: plumbum/cli/application.py:238 43 | #, fuzzy, python-brace-format 44 | msgid "Switch {name} already defined and is not overridable" 45 | msgstr "Optie {name} is al gedefiniëerd en kan niet worden overschreven" 46 | 47 | #: plumbum/cli/application.py:343 48 | #, python-brace-format 49 | msgid "Ambiguous partial switch {0}" 50 | msgstr "" 51 | 52 | #: plumbum/cli/application.py:348 plumbum/cli/application.py:373 53 | #: plumbum/cli/application.py:389 54 | #, python-brace-format 55 | msgid "Unknown switch {0}" 56 | msgstr "Onbekende optie {0}" 57 | 58 | #: plumbum/cli/application.py:353 plumbum/cli/application.py:362 59 | #: plumbum/cli/application.py:381 60 | #, python-brace-format 61 | msgid "Switch {0} requires an argument" 62 | msgstr "Een argument is vereist bij optie {0}" 63 | 64 | #: plumbum/cli/application.py:401 65 | #, python-brace-format 66 | msgid "Switch {0} already given" 67 | msgstr "Optie {0} is al gegeven" 68 | 69 | #: plumbum/cli/application.py:403 70 | #, python-brace-format 71 | msgid "Switch {0} already given ({1} is equivalent)" 72 | msgstr "Optie {0} is al gegeven ({1} is equivalent)" 73 | 74 | #: plumbum/cli/application.py:451 75 | msgid "" 76 | "Argument of {name} expected to be {argtype}, not {val!r}:\n" 77 | " {ex!r}" 78 | msgstr "" 79 | "Argement van {name} hoort {argtype} te zijn, niet {val|1}:\n" 80 | " {ex!r}" 81 | 82 | #: plumbum/cli/application.py:470 83 | #, python-brace-format 84 | msgid "Switch {0} is mandatory" 85 | msgstr "Optie {0} is verplicht" 86 | 87 | #: plumbum/cli/application.py:490 88 | #, python-brace-format 89 | msgid "Given {0}, the following are missing {1}" 90 | msgstr "Gegeven {0}, ontbreken de volgenden {1}" 91 | 92 | #: plumbum/cli/application.py:498 93 | #, python-brace-format 94 | msgid "Given {0}, the following are invalid {1}" 95 | msgstr "Gegeven {0}, zijn de volgenden ongeldig {1}" 96 | 97 | #: plumbum/cli/application.py:515 98 | #, python-brace-format 99 | msgid "Expected at least {0} positional argument, got {1}" 100 | msgid_plural "Expected at least {0} positional arguments, got {1}" 101 | msgstr[0] "Verwachtte ten minste {0} positioneel argument, kreeg {1}" 102 | msgstr[1] "Verwachtte ten minste {0} positionele argumenten, kreeg {1}" 103 | 104 | #: plumbum/cli/application.py:523 105 | #, python-brace-format 106 | msgid "Expected at most {0} positional argument, got {1}" 107 | msgid_plural "Expected at most {0} positional arguments, got {1}" 108 | msgstr[0] "Verwachtte hoogstens {0} positioneel argument, kreeg {0}" 109 | msgstr[1] "Verwachtte hoogstens {0} positionele argumenten, kreeg {0}" 110 | 111 | #: plumbum/cli/application.py:624 112 | #, python-brace-format 113 | msgid "Error: {0}" 114 | msgstr "Fout: {0}" 115 | 116 | #: plumbum/cli/application.py:625 plumbum/cli/application.py:711 117 | #: plumbum/cli/application.py:716 118 | msgid "------" 119 | msgstr "------" 120 | 121 | #: plumbum/cli/application.py:694 122 | #, python-brace-format 123 | msgid "Switch {0} must be a sequence (iterable)" 124 | msgstr "Optie {0} moet een reeks zijn (itereerbaar object)" 125 | 126 | #: plumbum/cli/application.py:699 127 | #, python-brace-format 128 | msgid "Switch {0} is a boolean flag" 129 | msgstr "Optie {0} geeft een waarheidswaarde weer" 130 | 131 | #: plumbum/cli/application.py:710 132 | #, python-brace-format 133 | msgid "Unknown sub-command '{0}'" 134 | msgstr "Onbekend subcommando '{0}'" 135 | 136 | #: plumbum/cli/application.py:715 137 | msgid "No sub-command given" 138 | msgstr "Geen subcommando gegeven" 139 | 140 | #: plumbum/cli/application.py:721 141 | msgid "main() not implemented" 142 | msgstr "main() niet geïmplementeerd" 143 | 144 | #: plumbum/cli/application.py:734 145 | #, fuzzy 146 | msgid "Prints help messages of all sub-commands and quits" 147 | msgstr "Druk hulpberichten van alle subcommando's af en beëindig" 148 | 149 | #: plumbum/cli/application.py:754 150 | msgid "Prints this help message and quits" 151 | msgstr "Drukt dit hulpbericht af en beëindig" 152 | 153 | #: plumbum/cli/application.py:877 154 | msgid "Usage:" 155 | msgstr "Gebruik:" 156 | 157 | #: plumbum/cli/application.py:883 158 | #, python-brace-format 159 | msgid " {progname} [SWITCHES] [SUBCOMMAND [SWITCHES]] {tailargs}\n" 160 | msgstr " {progname} [OPTIES] [SUBCOMMANDO [OPTIES]] {tailargs}\n" 161 | 162 | #: plumbum/cli/application.py:886 163 | #, python-brace-format 164 | msgid " {progname} [SWITCHES] {tailargs}\n" 165 | msgstr " {progname} [OPTIES] {tailargs}\n" 166 | 167 | #: plumbum/cli/application.py:936 168 | msgid "; may be given multiple times" 169 | msgstr "; kan meerdere keren gegeven worden" 170 | 171 | #: plumbum/cli/application.py:938 172 | msgid "; required" 173 | msgstr "; vereist" 174 | 175 | #: plumbum/cli/application.py:940 176 | #, python-brace-format 177 | msgid "; requires {0}" 178 | msgstr "; verseist {0}" 179 | 180 | #: plumbum/cli/application.py:947 181 | #, python-brace-format 182 | msgid "; excludes {0}" 183 | msgstr "; sluit {0} uit" 184 | 185 | #: plumbum/cli/application.py:966 186 | #, fuzzy 187 | msgid "Sub-commands:" 188 | msgstr "Subcommando's" 189 | 190 | #: plumbum/cli/application.py:1014 191 | msgid "Prints the program's version and quits" 192 | msgstr "Drukt de versie van het programma af en beëindigt" 193 | 194 | #: plumbum/cli/application.py:1019 195 | msgid "(version not set)" 196 | msgstr "(versie niet opgegeven)" 197 | 198 | #: plumbum/cli/switches.py:167 plumbum/cli/switches.py:225 199 | msgid "VALUE" 200 | msgstr "WAARDE" 201 | 202 | #: plumbum/cli/switches.py:238 203 | #, python-brace-format 204 | msgid "; the default is {0}" 205 | msgstr "; de standaard is {0}" 206 | 207 | #: plumbum/cli/switches.py:437 208 | #, python-brace-format 209 | msgid "Not in range [{0:d}..{1:d}]" 210 | msgstr "Niet binnen bereik [{0:d}..{1:d}]" 211 | 212 | #: plumbum/cli/switches.py:546 213 | #, python-brace-format 214 | msgid "{0} is not a directory" 215 | msgstr "{0} is geen map" 216 | 217 | #: plumbum/cli/switches.py:565 218 | #, python-brace-format 219 | msgid "{0} is not a file" 220 | msgstr "{0} is geen bestand" 221 | 222 | #: plumbum/cli/switches.py:574 223 | #, python-brace-format 224 | msgid "{0} already exists" 225 | msgstr "{0} bestaat al" 226 | 227 | #, python-brace-format 228 | #~ msgid "got unexpected keyword argument(s): {0}" 229 | #~ msgstr "Onverwacht(e) trefwoord argument(en) gegeven: {0}" 230 | 231 | #, python-brace-format 232 | #~ msgid "Expected one of {0}" 233 | #~ msgstr "Verwachtte één van {0}" 234 | -------------------------------------------------------------------------------- /plumbum/cli/i18n/nl/LC_MESSAGES/plumbum.cli.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/plumbum/cli/i18n/nl/LC_MESSAGES/plumbum.cli.mo -------------------------------------------------------------------------------- /plumbum/cli/i18n/ru/LC_MESSAGES/plumbum.cli.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/plumbum/cli/i18n/ru/LC_MESSAGES/plumbum.cli.mo -------------------------------------------------------------------------------- /plumbum/cli/image.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | from plumbum import colors 6 | 7 | from .. import cli 8 | from .termsize import get_terminal_size 9 | 10 | 11 | class Image: 12 | __slots__ = ["char_ratio", "size"] 13 | 14 | def __init__(self, size=None, char_ratio=2.45): 15 | self.size = size 16 | self.char_ratio = char_ratio 17 | 18 | def best_aspect(self, orig, term): 19 | """Select a best possible size matching the original aspect ratio. 20 | Size is width, height. 21 | The char_ratio option gives the height of each char with respect 22 | to its width, zero for no effect.""" 23 | 24 | if not self.char_ratio: # Don't use if char ratio is 0 25 | return term 26 | 27 | orig_ratio = orig[0] / orig[1] / self.char_ratio 28 | 29 | if int(term[1] / orig_ratio) <= term[0]: 30 | return int(term[1] / orig_ratio), term[1] 31 | 32 | return term[0], int(term[0] * orig_ratio) 33 | 34 | def show(self, filename, double=False): 35 | """Display an image on the command line. Can select a size or show in double resolution.""" 36 | 37 | import PIL.Image 38 | 39 | return ( 40 | self.show_pil_double(PIL.Image.open(filename)) 41 | if double 42 | else self.show_pil(PIL.Image.open(filename)) 43 | ) 44 | 45 | def _init_size(self, im): 46 | """Return the expected image size""" 47 | if self.size is None: 48 | term_size = get_terminal_size() 49 | return self.best_aspect(im.size, term_size) 50 | 51 | return self.size 52 | 53 | def show_pil(self, im): 54 | "Standard show routine" 55 | size = self._init_size(im) 56 | new_im = im.resize(size).convert("RGB") 57 | 58 | for y in range(size[1]): 59 | for x in range(size[0] - 1): 60 | pix = new_im.getpixel((x, y)) 61 | sys.stdout.write(colors.bg.rgb(*pix) + " ") # '\u2588' 62 | sys.stdout.write(colors.reset + " \n") 63 | sys.stdout.write(colors.reset + "\n") 64 | sys.stdout.flush() 65 | 66 | def show_pil_double(self, im): 67 | "Show double resolution on some fonts" 68 | 69 | size = self._init_size(im) 70 | size = (size[0], size[1] * 2) 71 | new_im = im.resize(size).convert("RGB") 72 | 73 | for y in range(size[1] // 2): 74 | for x in range(size[0] - 1): 75 | pix = new_im.getpixel((x, y * 2)) 76 | pixl = new_im.getpixel((x, y * 2 + 1)) 77 | sys.stdout.write( 78 | (colors.bg.rgb(*pixl) & colors.fg.rgb(*pix)) + "\u2580" 79 | ) 80 | sys.stdout.write(colors.reset + " \n") 81 | sys.stdout.write(colors.reset + "\n") 82 | sys.stdout.flush() 83 | 84 | 85 | class ShowImageApp(cli.Application): 86 | "Display an image on the terminal" 87 | 88 | double = cli.Flag( 89 | ["-d", "--double"], help="Double resolution (looks good only with some fonts)" 90 | ) 91 | 92 | @cli.switch(["-c", "--colors"], cli.Range(1, 4), help="Level of color, 1-4") 93 | def colors_set(self, n): # pylint: disable=no-self-use 94 | colors.use_color = n 95 | 96 | size = cli.SwitchAttr(["-s", "--size"], help="Size, should be in the form 100x150") 97 | 98 | ratio = cli.SwitchAttr( 99 | ["--ratio"], float, default=2.45, help="Aspect ratio of the font" 100 | ) 101 | 102 | @cli.positional(cli.ExistingFile) 103 | def main(self, filename): 104 | size = None 105 | if self.size: 106 | size = map(int, self.size.split("x")) 107 | 108 | Image(size, self.ratio).show(filename, self.double) 109 | 110 | 111 | if __name__ == "__main__": 112 | ShowImageApp.run() 113 | -------------------------------------------------------------------------------- /plumbum/cli/termsize.py: -------------------------------------------------------------------------------- 1 | """ 2 | Terminal size utility 3 | --------------------- 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import contextlib 9 | import os 10 | import platform 11 | import warnings 12 | from struct import Struct 13 | 14 | from plumbum import local 15 | 16 | 17 | def get_terminal_size(default: tuple[int, int] = (80, 25)) -> tuple[int, int]: 18 | """ 19 | Get width and height of console; works on linux, os x, windows and cygwin 20 | 21 | Adapted from https://gist.github.com/jtriley/1108174 22 | Originally from: http://stackoverflow.com/questions/566746/how-to-get-console-window-width-in-python 23 | """ 24 | current_os = platform.system() 25 | if current_os == "Windows": # pragma: no cover 26 | size = _get_terminal_size_windows() 27 | if not size: 28 | # needed for window's python in cygwin's xterm! 29 | size = _get_terminal_size_tput() 30 | elif current_os in ("Linux", "Darwin", "FreeBSD", "SunOS") or current_os.startswith( 31 | "CYGWIN" 32 | ): 33 | size = _get_terminal_size_linux() 34 | 35 | else: # pragma: no cover 36 | warnings.warn( 37 | "Plumbum does not know the type of the current OS for term size, defaulting to UNIX", 38 | stacklevel=2, 39 | ) 40 | size = _get_terminal_size_linux() 41 | 42 | # we'll assume the standard 80x25 if for any reason we don't know the terminal size 43 | if size is None: 44 | return default 45 | return size 46 | 47 | 48 | def _get_terminal_size_windows(): # pragma: no cover 49 | try: 50 | from ctypes import create_string_buffer, windll 51 | 52 | STDERR_HANDLE = -12 53 | h = windll.kernel32.GetStdHandle(STDERR_HANDLE) 54 | csbi_struct = Struct("hhhhHhhhhhh") 55 | csbi = create_string_buffer(csbi_struct.size) 56 | res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi) 57 | if res: 58 | _, _, _, _, _, left, top, right, bottom, _, _ = csbi_struct.unpack(csbi.raw) 59 | return right - left + 1, bottom - top + 1 60 | return None 61 | except Exception: 62 | return None 63 | 64 | 65 | def _get_terminal_size_tput(): # pragma: no cover 66 | # get terminal width 67 | # src: http://stackoverflow.com/questions/263890/how-do-i-find-the-width-height-of-a-terminal-window 68 | try: 69 | tput = local["tput"] 70 | cols = int(tput("cols")) 71 | rows = int(tput("lines")) 72 | return (cols, rows) 73 | except Exception: 74 | return None 75 | 76 | 77 | def _ioctl_GWINSZ(fd: int) -> tuple[int, int] | None: 78 | yx = Struct("hh") 79 | try: 80 | import fcntl 81 | import termios 82 | 83 | # TODO: Clean this up. Problems could be hidden by the broad except. 84 | return yx.unpack(fcntl.ioctl(fd, termios.TIOCGWINSZ, b"1234")) 85 | except Exception: 86 | return None 87 | 88 | 89 | def _get_terminal_size_linux() -> tuple[int, int] | None: 90 | cr = _ioctl_GWINSZ(0) or _ioctl_GWINSZ(1) or _ioctl_GWINSZ(2) 91 | if not cr: 92 | with contextlib.suppress(Exception): 93 | fd = os.open(os.ctermid(), os.O_RDONLY) 94 | cr = _ioctl_GWINSZ(fd) 95 | os.close(fd) 96 | if not cr: 97 | try: 98 | cr = (int(os.environ["LINES"]), int(os.environ["COLUMNS"])) 99 | except Exception: 100 | return None 101 | return cr[1], cr[0] 102 | -------------------------------------------------------------------------------- /plumbum/cmd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import plumbum 4 | 5 | 6 | def __getattr__(name: str) -> plumbum.machines.LocalCommand: 7 | """The module-hack that allows us to use ``from plumbum.cmd import some_program``""" 8 | try: 9 | return plumbum.local[name] 10 | except plumbum.CommandNotFound: 11 | raise AttributeError(name) from None 12 | -------------------------------------------------------------------------------- /plumbum/colorlib/__init__.py: -------------------------------------------------------------------------------- 1 | """\ 2 | The ``ansicolor`` object provides ``bg`` and ``fg`` to access colors, 3 | and attributes like bold and 4 | underlined text. It also provides ``reset`` to recover the normal font. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import sys 10 | 11 | from .factories import StyleFactory 12 | from .styles import ANSIStyle, ColorNotFound, HTMLStyle, Style 13 | 14 | __all__ = ( 15 | "ANSIStyle", 16 | "ColorNotFound", 17 | "HTMLStyle", 18 | "Style", 19 | "StyleFactory", 20 | "ansicolors", 21 | "htmlcolors", 22 | "load_ipython_extension", 23 | "main", 24 | ) 25 | 26 | ansicolors = StyleFactory(ANSIStyle) 27 | htmlcolors = StyleFactory(HTMLStyle) 28 | 29 | 30 | def load_ipython_extension(ipython): # pragma: no cover 31 | try: 32 | from ._ipython_ext import OutputMagics # pylint:disable=import-outside-toplevel 33 | except ImportError: 34 | print("IPython required for the IPython extension to be loaded.") # noqa: T201 35 | raise 36 | 37 | ipython.push({"colors": htmlcolors}) 38 | ipython.register_magics(OutputMagics) 39 | 40 | 41 | def main(): # pragma: no cover 42 | """Color changing script entry. Call using 43 | python3 -m plumbum.colors, will reset if no arguments given.""" 44 | color = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else "" 45 | ansicolors.use_color = True 46 | ansicolors.get_colors_from_string(color).now() 47 | -------------------------------------------------------------------------------- /plumbum/colorlib/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is provided as a quick way to recover your terminal. Simply run 3 | ``python3 -m plumbum.colorlib`` 4 | to recover terminal color. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from . import main 10 | 11 | main() 12 | -------------------------------------------------------------------------------- /plumbum/colorlib/_ipython_ext.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from io import StringIO 5 | 6 | import IPython.display 7 | from IPython.core.magic import Magics, cell_magic, magics_class, needs_local_scope 8 | 9 | valid_choices = [x[8:] for x in dir(IPython.display) if x[:8] == "display_"] 10 | 11 | 12 | @magics_class 13 | class OutputMagics(Magics): # pragma: no cover 14 | @needs_local_scope 15 | @cell_magic 16 | def to(self, line, cell, local_ns=None): 17 | choice = line.strip() 18 | assert choice in valid_choices, "Valid choices for '%%to' are: " + str( 19 | valid_choices 20 | ) 21 | display_fn = getattr(IPython.display, "display_" + choice) 22 | 23 | # Captures stdout and renders it in the notebook 24 | with StringIO() as out: 25 | old_out = sys.stdout 26 | try: 27 | sys.stdout = out 28 | exec(cell, self.shell.user_ns, local_ns) # pylint: disable=exec-used 29 | out.seek(0) 30 | display_fn(out.getvalue(), raw=True) 31 | finally: 32 | sys.stdout = old_out 33 | -------------------------------------------------------------------------------- /plumbum/colors.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module imitates a real module, providing standard syntax 3 | like from `plumbum.colors` and from `plumbum.colors.bg` to work alongside 4 | all the standard syntax for colors. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import atexit 10 | import sys 11 | 12 | from plumbum.colorlib import ansicolors, main 13 | 14 | _reset = ansicolors.reset.now 15 | if __name__ == "__main__": 16 | main() 17 | else: # Don't register an exit if this is called using -m! 18 | atexit.register(_reset) 19 | 20 | sys.modules[__name__ + ".fg"] = ansicolors.fg 21 | sys.modules[__name__ + ".bg"] = ansicolors.bg 22 | sys.modules[__name__] = ansicolors # type: ignore[assignment] 23 | -------------------------------------------------------------------------------- /plumbum/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plumbum.commands.base import ( 4 | ERROUT, 5 | BaseCommand, 6 | ConcreteCommand, 7 | shquote, 8 | shquote_list, 9 | ) 10 | from plumbum.commands.modifiers import ( 11 | BG, 12 | FG, 13 | NOHUP, 14 | RETCODE, 15 | TEE, 16 | TF, 17 | ExecutionModifier, 18 | Future, 19 | ) 20 | from plumbum.commands.processes import ( 21 | CommandNotFound, 22 | ProcessExecutionError, 23 | ProcessLineTimedOut, 24 | ProcessTimedOut, 25 | run_proc, 26 | ) 27 | 28 | __all__ = ( 29 | "BG", 30 | "ERROUT", 31 | "FG", 32 | "NOHUP", 33 | "RETCODE", 34 | "TEE", 35 | "TF", 36 | "BaseCommand", 37 | "CommandNotFound", 38 | "ConcreteCommand", 39 | "ExecutionModifier", 40 | "Future", 41 | "ProcessExecutionError", 42 | "ProcessLineTimedOut", 43 | "ProcessTimedOut", 44 | "run_proc", 45 | "shquote", 46 | "shquote_list", 47 | ) 48 | 49 | 50 | def __dir__(): 51 | return __all__ 52 | -------------------------------------------------------------------------------- /plumbum/commands/daemons.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import errno 5 | import os 6 | import signal 7 | import subprocess 8 | import sys 9 | import time 10 | import traceback 11 | 12 | from plumbum.commands.processes import ProcessExecutionError 13 | 14 | 15 | class _fake_lock: 16 | """Needed to allow normal os.exit() to work without error""" 17 | 18 | @staticmethod 19 | def acquire(_): 20 | return True 21 | 22 | @staticmethod 23 | def release(): 24 | pass 25 | 26 | 27 | def posix_daemonize(command, cwd, stdout=None, stderr=None, append=True): 28 | if stdout is None: 29 | stdout = os.devnull 30 | if stderr is None: 31 | stderr = stdout 32 | 33 | MAX_SIZE = 16384 34 | rfd, wfd = os.pipe() 35 | argv = command.formulate() 36 | firstpid = os.fork() 37 | if firstpid == 0: 38 | # first child: become session leader 39 | os.close(rfd) 40 | rc = 0 41 | try: 42 | os.setsid() 43 | os.umask(0) 44 | stdin = open(os.devnull, encoding="utf-8") 45 | stdout = open(stdout, "a" if append else "w", encoding="utf-8") 46 | stderr = open(stderr, "a" if append else "w", encoding="utf-8") 47 | signal.signal(signal.SIGHUP, signal.SIG_IGN) 48 | proc = command.popen( 49 | cwd=cwd, 50 | close_fds=True, 51 | stdin=stdin.fileno(), 52 | stdout=stdout.fileno(), 53 | stderr=stderr.fileno(), 54 | ) 55 | os.write(wfd, str(proc.pid).encode("utf8")) 56 | except Exception: 57 | rc = 1 58 | tbtext = "".join(traceback.format_exception(*sys.exc_info()))[-MAX_SIZE:] 59 | os.write(wfd, tbtext.encode("utf8")) 60 | finally: 61 | os.close(wfd) 62 | os._exit(rc) 63 | 64 | # wait for first child to die 65 | os.close(wfd) 66 | _, rc = os.waitpid(firstpid, 0) 67 | output = os.read(rfd, MAX_SIZE) 68 | os.close(rfd) 69 | with contextlib.suppress(UnicodeError): 70 | output = output.decode("utf8") 71 | if rc == 0 and output.isdigit(): 72 | secondpid = int(output) 73 | else: 74 | raise ProcessExecutionError(argv, rc, "", output) 75 | proc = subprocess.Popen.__new__(subprocess.Popen) 76 | proc._child_created = True 77 | proc.returncode = None 78 | proc.stdout = None 79 | proc.stdin = None 80 | proc.stderr = None 81 | proc.pid = secondpid 82 | proc.universal_newlines = False 83 | proc._input = None 84 | proc._waitpid_lock = _fake_lock() 85 | proc._communication_started = False 86 | proc.args = argv 87 | proc.argv = argv 88 | 89 | def poll(self=proc): 90 | if self.returncode is None: 91 | try: 92 | os.kill(self.pid, 0) 93 | except OSError as ex: 94 | if ex.errno == errno.ESRCH: 95 | # process does not exist 96 | self.returncode = 0 97 | else: 98 | raise 99 | return self.returncode 100 | 101 | def wait(self=proc): 102 | while self.returncode is None: 103 | if self.poll() is None: 104 | time.sleep(0.5) 105 | return proc.returncode 106 | 107 | proc.poll = poll 108 | proc.wait = wait 109 | return proc 110 | 111 | 112 | def win32_daemonize(command, cwd, stdout=None, stderr=None, append=True): 113 | if stdout is None: 114 | stdout = os.devnull 115 | if stderr is None: 116 | stderr = stdout 117 | DETACHED_PROCESS = 0x00000008 118 | stdin = open(os.devnull, encoding="utf-8") 119 | stdout = open(stdout, "a" if append else "w", encoding="utf-8") 120 | stderr = open(stderr, "a" if append else "w", encoding="utf-8") 121 | return command.popen( 122 | cwd=cwd, 123 | stdin=stdin.fileno(), 124 | stdout=stdout.fileno(), 125 | stderr=stderr.fileno(), 126 | creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS, 127 | ) 128 | -------------------------------------------------------------------------------- /plumbum/fs/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | file-system related operations 3 | """ 4 | -------------------------------------------------------------------------------- /plumbum/fs/mounts.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | 6 | class MountEntry: 7 | """ 8 | Represents a mount entry (device file, mount point and file system type) 9 | """ 10 | 11 | def __init__(self, dev, point, fstype, options): 12 | self.dev = dev 13 | self.point = point 14 | self.fstype = fstype 15 | self.options = options.split(",") 16 | 17 | def __str__(self): 18 | options = ",".join(self.options) 19 | return f"{self.dev} on {self.point} type {self.fstype} ({options})" 20 | 21 | 22 | MOUNT_PATTERN = re.compile(r"(.+?)\s+on\s+(.+?)\s+type\s+(\S+)(?:\s+\((.+?)\))?") 23 | 24 | 25 | def mount_table(): 26 | """returns the system's current mount table (a list of 27 | :class:`MountEntry ` objects)""" 28 | from plumbum.cmd import mount 29 | 30 | table = [] 31 | for line in mount().splitlines(): 32 | m = MOUNT_PATTERN.match(line) 33 | if not m: 34 | continue 35 | table.append(MountEntry(*m.groups())) 36 | return table 37 | 38 | 39 | def mounted(fs): 40 | """ 41 | Indicates if a the given filesystem (device file or mount point) is currently mounted 42 | """ 43 | return any(fs in {entry.dev, entry.point} for entry in mount_table()) 44 | -------------------------------------------------------------------------------- /plumbum/lib.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import os 5 | import sys 6 | from contextlib import contextmanager 7 | from io import StringIO 8 | 9 | IS_WIN32 = sys.platform == "win32" 10 | 11 | 12 | class ProcInfo: 13 | def __init__(self, pid, uid, stat, args): 14 | self.pid = pid 15 | self.uid = uid 16 | self.stat = stat 17 | self.args = args 18 | 19 | def __repr__(self): 20 | return f"ProcInfo({self.pid!r}, {self.uid!r}, {self.stat!r}, {self.args!r})" 21 | 22 | 23 | @contextmanager 24 | def captured_stdout(stdin=""): 25 | """ 26 | Captures stdout (similar to the redirect_stdout in Python 3.4+, but with slightly different arguments) 27 | """ 28 | prevstdin = sys.stdin 29 | prevstdout = sys.stdout 30 | sys.stdin = StringIO(stdin) 31 | sys.stdout = StringIO() 32 | try: 33 | yield sys.stdout 34 | finally: 35 | sys.stdin = prevstdin 36 | sys.stdout = prevstdout 37 | 38 | 39 | class StaticProperty: 40 | """This acts like a static property, allowing access via class or object. 41 | This is a non-data descriptor.""" 42 | 43 | def __init__(self, function): 44 | self._function = function 45 | self.__doc__ = function.__doc__ 46 | 47 | def __get__(self, obj, klass=None): 48 | return self._function() 49 | 50 | 51 | def getdoc(obj): 52 | """ 53 | This gets a docstring if available, and cleans it, but does not look up docs in 54 | inheritance tree (Pre Python 3.5 behavior of ``inspect.getdoc``). 55 | """ 56 | try: 57 | doc = obj.__doc__ 58 | except AttributeError: 59 | return None 60 | if not isinstance(doc, str): 61 | return None 62 | return inspect.cleandoc(doc) 63 | 64 | 65 | def read_fd_decode_safely(fd, size=4096): 66 | """ 67 | This reads a utf-8 file descriptor and returns a chunk, growing up to 68 | three bytes if needed to decode the character at the end. 69 | 70 | Returns the data and the decoded text. 71 | """ 72 | data = os.read(fd.fileno(), size) 73 | for _ in range(3): 74 | try: 75 | return data, data.decode("utf-8") 76 | except UnicodeDecodeError as e: 77 | if e.reason != "unexpected end of data": 78 | raise 79 | data += os.read(fd.fileno(), 1) 80 | 81 | return data, data.decode("utf-8") 82 | -------------------------------------------------------------------------------- /plumbum/machines/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plumbum.machines.local import LocalCommand, LocalMachine, local 4 | from plumbum.machines.remote import BaseRemoteMachine, RemoteCommand 5 | from plumbum.machines.ssh_machine import PuttyMachine, SshMachine 6 | 7 | __all__ = ( 8 | "BaseRemoteMachine", 9 | "LocalCommand", 10 | "LocalMachine", 11 | "PuttyMachine", 12 | "RemoteCommand", 13 | "SshMachine", 14 | "local", 15 | ) 16 | -------------------------------------------------------------------------------- /plumbum/machines/_windows.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import struct 4 | 5 | LFANEW_OFFSET = 30 * 2 6 | FILE_HEADER_SIZE = 5 * 4 7 | SUBSYSTEM_OFFSET = 17 * 4 8 | IMAGE_SUBSYSTEM_WINDOWS_GUI = 2 9 | IMAGE_SUBSYSTEM_WINDOWS_CUI = 3 10 | 11 | 12 | def get_pe_subsystem(filename): 13 | with open(filename, "rb") as f: 14 | if f.read(2) != b"MZ": 15 | return None 16 | f.seek(LFANEW_OFFSET) 17 | lfanew = struct.unpack("L", f.read(4))[0] 18 | f.seek(lfanew) 19 | if f.read(4) != b"PE\x00\x00": 20 | return None 21 | f.seek(FILE_HEADER_SIZE + SUBSYSTEM_OFFSET, 1) 22 | return struct.unpack("H", f.read(2))[0] 23 | 24 | 25 | # print(get_pe_subsystem("c:\\windows\\notepad.exe")) == 2 26 | # print(get_pe_subsystem("c:\\python32\\python.exe")) == 3 27 | # print(get_pe_subsystem("c:\\python32\\pythonw.exe")) == 2 28 | -------------------------------------------------------------------------------- /plumbum/machines/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plumbum.commands.processes import ( 4 | CommandNotFound, 5 | ProcessExecutionError, 6 | ProcessTimedOut, 7 | ) 8 | 9 | 10 | class PopenAddons: 11 | """This adds a verify to popen objects to that the correct command is attributed when 12 | an error is thrown.""" 13 | 14 | def verify(self, retcode, timeout, stdout, stderr): 15 | """This verifies that the correct command is attributed.""" 16 | if getattr(self, "_timed_out", False): 17 | raise ProcessTimedOut( 18 | f"Process did not terminate within {timeout} seconds", 19 | getattr(self, "argv", None), 20 | ) 21 | 22 | if retcode is not None: 23 | if hasattr(retcode, "__contains__"): 24 | if self.returncode not in retcode: 25 | raise ProcessExecutionError( 26 | getattr(self, "argv", None), self.returncode, stdout, stderr 27 | ) 28 | elif self.returncode != retcode: 29 | raise ProcessExecutionError( 30 | getattr(self, "argv", None), self.returncode, stdout, stderr 31 | ) 32 | 33 | 34 | class BaseMachine: 35 | """This is a base class for other machines. It contains common code to 36 | all machines in Plumbum.""" 37 | 38 | def get(self, cmd, *othercommands): 39 | """This works a little like the ``.get`` method with dict's, only 40 | it supports an unlimited number of arguments, since later arguments 41 | are tried as commands and could also fail. It 42 | will try to call the first command, and if that is not found, 43 | it will call the next, etc. Will raise if no file named for the 44 | executable if a path is given, unlike ``[]`` access. 45 | 46 | Usage:: 47 | 48 | best_zip = local.get('pigz','gzip') 49 | """ 50 | try: 51 | command = self[cmd] 52 | if not command.executable.exists(): 53 | raise CommandNotFound(cmd, command.executable) 54 | return command 55 | except CommandNotFound: 56 | if othercommands: 57 | return self.get(othercommands[0], *othercommands[1:]) 58 | raise 59 | 60 | def __contains__(self, cmd): 61 | """Tests for the existence of the command, e.g., ``"ls" in plumbum.local``. 62 | ``cmd`` can be anything acceptable by ``__getitem__``. 63 | """ 64 | try: 65 | self[cmd] 66 | except CommandNotFound: 67 | return False 68 | return True 69 | 70 | @property 71 | def encoding(self): 72 | "This is a wrapper for custom_encoding" 73 | return self.custom_encoding 74 | 75 | @encoding.setter 76 | def encoding(self, value): 77 | self.custom_encoding = value 78 | 79 | def daemonic_popen(self, command, cwd="/", stdout=None, stderr=None, append=True): 80 | raise NotImplementedError("This is not implemented on this machine!") 81 | 82 | class Cmd: 83 | def __init__(self, machine): 84 | self._machine = machine 85 | 86 | def __getattr__(self, name): 87 | try: 88 | return self._machine[name] 89 | except CommandNotFound: 90 | raise AttributeError(name) from None 91 | 92 | @property 93 | def cmd(self): 94 | return self.Cmd(self) 95 | 96 | def clear_program_cache(self): 97 | """ 98 | Clear the program cache, which is populated via ``machine.which(progname)`` calls. 99 | 100 | This cache speeds up the lookup of a program in the machines PATH, and is particularly 101 | effective for RemoteMachines. 102 | """ 103 | self._program_cache.clear() 104 | -------------------------------------------------------------------------------- /plumbum/machines/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from contextlib import contextmanager 5 | 6 | 7 | class EnvPathList(list): 8 | __slots__ = ["__weakref__", "_path_factory", "_pathsep"] 9 | 10 | def __init__(self, path_factory, pathsep): 11 | super().__init__() 12 | self._path_factory = path_factory 13 | self._pathsep = pathsep 14 | 15 | def append(self, path): 16 | list.append(self, self._path_factory(path)) 17 | 18 | def extend(self, paths): 19 | list.extend(self, (self._path_factory(p) for p in paths)) 20 | 21 | def insert(self, index, path): 22 | list.insert(self, index, self._path_factory(path)) 23 | 24 | def index(self, path): 25 | list.index(self, self._path_factory(path)) 26 | 27 | def __contains__(self, path): 28 | return list.__contains__(self, self._path_factory(path)) 29 | 30 | def remove(self, path): 31 | list.remove(self, self._path_factory(path)) 32 | 33 | def update(self, text): 34 | self[:] = [self._path_factory(p) for p in text.split(self._pathsep)] 35 | 36 | def join(self): 37 | return self._pathsep.join(str(p) for p in self) 38 | 39 | 40 | class BaseEnv: 41 | """The base class of LocalEnv and RemoteEnv""" 42 | 43 | __slots__ = ["__weakref__", "_curr", "_path", "_path_factory"] 44 | CASE_SENSITIVE = True 45 | 46 | def __init__(self, path_factory, pathsep, *, _curr): 47 | self._curr = _curr 48 | self._path_factory = path_factory 49 | self._path = EnvPathList(path_factory, pathsep) 50 | self._update_path() 51 | 52 | def _update_path(self): 53 | self._path.update(self.get("PATH", "")) 54 | 55 | @contextmanager 56 | def __call__(self, *args, **kwargs): 57 | """A context manager that can be used for temporal modifications of the environment. 58 | Any time you enter the context, a copy of the old environment is stored, and then restored, 59 | when the context exits. 60 | 61 | :param args: Any positional arguments for ``update()`` 62 | :param kwargs: Any keyword arguments for ``update()`` 63 | """ 64 | prev = self._curr.copy() 65 | self.update(**kwargs) 66 | try: 67 | yield 68 | finally: 69 | self._curr = prev 70 | self._update_path() 71 | 72 | def __iter__(self): 73 | """Returns an iterator over the items ``(key, value)`` of current environment 74 | (like dict.items)""" 75 | return iter(self._curr.items()) 76 | 77 | def __hash__(self): 78 | raise TypeError("unhashable type") 79 | 80 | def __len__(self): 81 | """Returns the number of elements of the current environment""" 82 | return len(self._curr) 83 | 84 | def __contains__(self, name): 85 | """Tests whether an environment variable exists in the current environment""" 86 | return (name if self.CASE_SENSITIVE else name.upper()) in self._curr 87 | 88 | def __getitem__(self, name): 89 | """Returns the value of the given environment variable from current environment, 90 | raising a ``KeyError`` if it does not exist""" 91 | return self._curr[name if self.CASE_SENSITIVE else name.upper()] 92 | 93 | def keys(self): 94 | """Returns the keys of the current environment (like dict.keys)""" 95 | return self._curr.keys() 96 | 97 | def items(self): 98 | """Returns the items of the current environment (like dict.items)""" 99 | return self._curr.items() 100 | 101 | def values(self): 102 | """Returns the values of the current environment (like dict.values)""" 103 | return self._curr.values() 104 | 105 | def get(self, name, *default): 106 | """Returns the keys of the current environment (like dict.keys)""" 107 | return self._curr.get((name if self.CASE_SENSITIVE else name.upper()), *default) 108 | 109 | def __delitem__(self, name): 110 | """Deletes an environment variable from the current environment""" 111 | name = name if self.CASE_SENSITIVE else name.upper() 112 | del self._curr[name] 113 | if name == "PATH": 114 | self._update_path() 115 | 116 | def __setitem__(self, name, value): 117 | """Sets/replaces an environment variable's value in the current environment""" 118 | name = name if self.CASE_SENSITIVE else name.upper() 119 | self._curr[name] = value 120 | if name == "PATH": 121 | self._update_path() 122 | 123 | def pop(self, name, *default): 124 | """Pops an element from the current environment (like dict.pop)""" 125 | name = name if self.CASE_SENSITIVE else name.upper() 126 | res = self._curr.pop(name, *default) 127 | if name == "PATH": 128 | self._update_path() 129 | return res 130 | 131 | def clear(self): 132 | """Clears the current environment (like dict.clear)""" 133 | self._curr.clear() 134 | self._update_path() 135 | 136 | def update(self, *args, **kwargs): 137 | """Updates the current environment (like dict.update)""" 138 | self._curr.update(*args, **kwargs) 139 | if not self.CASE_SENSITIVE: 140 | for k, v in list(self._curr.items()): 141 | self._curr[k.upper()] = v 142 | self._update_path() 143 | 144 | def getdict(self): 145 | """Returns the environment as a real dictionary""" 146 | self._curr["PATH"] = self.path.join() 147 | return {k: str(v) for k, v in self._curr.items()} 148 | 149 | @property 150 | def path(self): 151 | """The system's ``PATH`` (as an easy-to-manipulate list)""" 152 | return self._path 153 | 154 | def _get_home(self): 155 | if "HOME" in self: 156 | return self._path_factory(self["HOME"]) 157 | if "USERPROFILE" in self: # pragma: no cover 158 | return self._path_factory(self["USERPROFILE"]) 159 | if "HOMEPATH" in self: # pragma: no cover 160 | return self._path_factory(self.get("HOMEDRIVE", ""), self["HOMEPATH"]) 161 | return None 162 | 163 | def _set_home(self, p): 164 | if "HOME" in self: 165 | self["HOME"] = str(p) 166 | elif "USERPROFILE" in self: # pragma: no cover 167 | self["USERPROFILE"] = str(p) 168 | elif "HOMEPATH" in self: # pragma: no cover 169 | self["HOMEPATH"] = str(p) 170 | else: # pragma: no cover 171 | self["HOME"] = str(p) 172 | 173 | home = property(_get_home, _set_home) 174 | """Get or set the home path""" 175 | 176 | @property 177 | def user(self): 178 | """Return the user name, or ``None`` if it is not set""" 179 | # adapted from getpass.getuser() 180 | for name in ("LOGNAME", "USER", "LNAME", "USERNAME"): # pragma: no branch 181 | if name in self: 182 | return self[name] 183 | try: 184 | # POSIX only 185 | import pwd 186 | except ImportError: 187 | return None 188 | return pwd.getpwuid(os.getuid())[0] # @UndefinedVariable 189 | -------------------------------------------------------------------------------- /plumbum/path/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plumbum.path.base import FSUser, Path, RelativePath 4 | from plumbum.path.local import LocalPath, LocalWorkdir 5 | from plumbum.path.remote import RemotePath, RemoteWorkdir 6 | from plumbum.path.utils import copy, delete, move 7 | 8 | __all__ = ( 9 | "FSUser", 10 | "LocalPath", 11 | "LocalWorkdir", 12 | "Path", 13 | "RelativePath", 14 | "RemotePath", 15 | "RemoteWorkdir", 16 | "copy", 17 | "delete", 18 | "move", 19 | ) 20 | -------------------------------------------------------------------------------- /plumbum/path/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | from plumbum.machines.local import local 6 | from plumbum.path.base import Path 7 | from plumbum.path.local import LocalPath 8 | 9 | 10 | def delete(*paths): 11 | """Deletes the given paths. The arguments can be either strings, 12 | :class:`local paths `, 13 | :class:`remote paths `, or iterables of such. 14 | No error is raised if any of the paths does not exist (it is silently ignored) 15 | """ 16 | for p in paths: 17 | if isinstance(p, Path): 18 | p.delete() 19 | elif isinstance(p, str): 20 | local.path(p).delete() 21 | elif hasattr(p, "__iter__"): 22 | delete(*p) 23 | else: 24 | raise TypeError(f"Cannot delete {p!r}") 25 | 26 | 27 | def _move(src, dst): 28 | ret = copy(src, dst) 29 | delete(src) 30 | return ret 31 | 32 | 33 | def move(src, dst): 34 | """Moves the source path onto the destination path; ``src`` and ``dst`` can be either 35 | strings, :class:`LocalPaths ` or 36 | :class:`RemotePath `; any combination of the three will 37 | work. 38 | 39 | .. versionadded:: 1.3 40 | ``src`` can also be a list of strings/paths, in which case ``dst`` must not exist or be a directory. 41 | """ 42 | if not isinstance(dst, Path): 43 | dst = local.path(dst) 44 | if isinstance(src, (tuple, list)): 45 | if not dst.exists(): 46 | dst.mkdir() 47 | elif not dst.is_dir(): 48 | raise ValueError( 49 | f"When using multiple sources, dst {dst!r} must be a directory" 50 | ) 51 | for src2 in src: 52 | move(src2, dst) 53 | return dst 54 | if not isinstance(src, Path): 55 | src = local.path(src) 56 | 57 | if isinstance(src, LocalPath): 58 | return src.move(dst) if isinstance(dst, LocalPath) else _move(src, dst) 59 | if isinstance(dst, LocalPath): 60 | return _move(src, dst) 61 | if src.remote == dst.remote: 62 | return src.move(dst) 63 | 64 | return _move(src, dst) 65 | 66 | 67 | def copy(src, dst): 68 | """ 69 | Copy (recursively) the source path onto the destination path; ``src`` and ``dst`` can be 70 | either strings, :class:`LocalPaths ` or 71 | :class:`RemotePath `; any combination of the three will 72 | work. 73 | 74 | .. versionadded:: 1.3 75 | ``src`` can also be a list of strings/paths, in which case ``dst`` must not exist or be a directory. 76 | """ 77 | if not isinstance(dst, Path): 78 | dst = local.path(dst) 79 | if isinstance(src, (tuple, list)): 80 | if not dst.exists(): 81 | dst.mkdir() 82 | elif not dst.is_dir(): 83 | raise ValueError( 84 | f"When using multiple sources, dst {dst!r} must be a directory" 85 | ) 86 | for src2 in src: 87 | copy(src2, dst) 88 | return dst 89 | 90 | if not isinstance(src, Path): 91 | src = local.path(src) 92 | 93 | if isinstance(src, LocalPath): 94 | if isinstance(dst, LocalPath): 95 | return src.copy(dst) 96 | dst.remote.upload(src, dst) 97 | return dst 98 | 99 | if isinstance(dst, LocalPath): 100 | src.remote.download(src, dst) 101 | return dst 102 | 103 | if src.remote == dst.remote: 104 | return src.copy(dst) 105 | 106 | with local.tempdir() as tmp: 107 | copy(src, tmp) 108 | copy(tmp / src.name, dst) 109 | return dst 110 | 111 | 112 | def gui_open(filename): 113 | """This selects the proper gui open function. This can 114 | also be achieved with webbrowser, but that is not supported.""" 115 | if hasattr(os, "startfile"): 116 | os.startfile(filename) 117 | else: 118 | local.get("xdg-open", "open")(filename) 119 | -------------------------------------------------------------------------------- /plumbum/typed_env.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import os 5 | from collections.abc import MutableMapping 6 | 7 | NO_DEFAULT = object() 8 | 9 | 10 | # must not inherit from AttributeError, so not to mess with python's attribute-lookup flow 11 | class EnvironmentVariableError(KeyError): 12 | pass 13 | 14 | 15 | class TypedEnv(MutableMapping): 16 | """ 17 | This object can be used in 'exploratory' mode: 18 | 19 | nv = TypedEnv() 20 | print(nv.HOME) 21 | 22 | It can also be used as a parser and validator of environment variables: 23 | 24 | class MyEnv(TypedEnv): 25 | username = TypedEnv.Str("USER", default='anonymous') 26 | path = TypedEnv.CSV("PATH", separator=":") 27 | tmp = TypedEnv.Str("TMP TEMP".split()) # support 'fallback' var-names 28 | 29 | nv = MyEnv() 30 | 31 | print(nv.username) 32 | 33 | for p in nv.path: 34 | print(p) 35 | 36 | try: 37 | print(p.tmp) 38 | except EnvironmentVariableError: 39 | print("TMP/TEMP is not defined") 40 | else: 41 | assert False 42 | """ 43 | 44 | __slots__ = ["_defined_keys", "_env"] 45 | 46 | class _BaseVar: 47 | def __init__(self, name, default=NO_DEFAULT): 48 | self.names = tuple(name) if isinstance(name, (tuple, list)) else (name,) 49 | self.name = self.names[0] 50 | self.default = default 51 | 52 | def convert(self, value): # pylint:disable=no-self-use 53 | return value 54 | 55 | def __get__(self, instance, owner): 56 | if not instance: 57 | return self 58 | try: 59 | return self.convert(instance._raw_get(*self.names)) 60 | except EnvironmentVariableError: 61 | if self.default is NO_DEFAULT: 62 | raise 63 | return self.default 64 | 65 | def __set__(self, instance, value): 66 | instance[self.name] = value 67 | 68 | class Str(_BaseVar): 69 | pass 70 | 71 | class Bool(_BaseVar): 72 | """ 73 | Converts 'yes|true|1|no|false|0' to the appropriate boolean value. 74 | Case-insensitive. Throws a ``ValueError`` for any other value. 75 | """ 76 | 77 | def convert(self, value): 78 | value = value.lower() 79 | if value not in {"yes", "no", "true", "false", "1", "0"}: 80 | raise ValueError(f"Unrecognized boolean value: {value!r}") 81 | return value in {"yes", "true", "1"} 82 | 83 | def __set__(self, instance, value): 84 | instance[self.name] = "yes" if value else "no" 85 | 86 | class Int(_BaseVar): 87 | convert = staticmethod(int) 88 | 89 | class Float(_BaseVar): 90 | convert = staticmethod(float) 91 | 92 | class CSV(_BaseVar): 93 | """ 94 | Comma-separated-strings get split using the ``separator`` (',' by default) into 95 | a list of objects of type ``type`` (``str`` by default). 96 | """ 97 | 98 | def __init__(self, name, default=NO_DEFAULT, type=str, separator=","): # pylint:disable=redefined-builtin 99 | super().__init__(name, default=default) 100 | self.type = type 101 | self.separator = separator 102 | 103 | def __set__(self, instance, value): 104 | instance[self.name] = self.separator.join(map(str, value)) 105 | 106 | def convert(self, value): 107 | return [self.type(v.strip()) for v in value.split(self.separator)] 108 | 109 | # ========= 110 | 111 | def __init__(self, env=None): 112 | if env is None: 113 | env = os.environ 114 | self._env = env 115 | self._defined_keys = { 116 | k 117 | for (k, v) in inspect.getmembers(self.__class__) 118 | if isinstance(v, self._BaseVar) 119 | } 120 | 121 | def __iter__(self): 122 | return iter(dir(self)) 123 | 124 | def __len__(self): 125 | return len(self._env) 126 | 127 | def __delitem__(self, name): 128 | del self._env[name] 129 | 130 | def __setitem__(self, name, value): 131 | self._env[name] = str(value) 132 | 133 | def _raw_get(self, *key_names): 134 | for key in key_names: 135 | value = self._env.get(key, NO_DEFAULT) 136 | if value is not NO_DEFAULT: 137 | return value 138 | raise EnvironmentVariableError(key_names[0]) 139 | 140 | def __contains__(self, key): 141 | try: 142 | self._raw_get(key) 143 | except EnvironmentVariableError: 144 | return False 145 | return True 146 | 147 | def __getattr__(self, name): 148 | # if we're here then there was no descriptor defined 149 | try: 150 | return self._raw_get(name) 151 | except EnvironmentVariableError: 152 | raise AttributeError( 153 | f"{self.__class__} has no attribute {name!r}" 154 | ) from None 155 | 156 | def __getitem__(self, key): 157 | return getattr(self, key) # delegate through the descriptors 158 | 159 | def get(self, key, default=None): 160 | try: 161 | return self[key] 162 | except EnvironmentVariableError: 163 | return default 164 | 165 | def __dir__(self): 166 | if self._defined_keys: 167 | # return only defined 168 | return sorted(self._defined_keys) 169 | # return whatever is in the environment (for convenience) 170 | members = set(self._env.keys()) 171 | members.update(dir(self.__class__)) 172 | return sorted(members) 173 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "hatchling>=1.27.0", 4 | "hatch-vcs", 5 | ] 6 | build-backend = "hatchling.build" 7 | 8 | 9 | [project] 10 | name = "plumbum" 11 | description = "Plumbum: shell combinators library" 12 | readme = "README.rst" 13 | authors = [{ name="Tomer Filiba", email="tomerfiliba@gmail.com" }] 14 | license = "MIT" 15 | license-files = ["LICENSE"] 16 | requires-python = ">=3.9" 17 | dynamic = ["version"] 18 | dependencies = [ 19 | "pywin32; platform_system=='Windows' and platform_python_implementation!='PyPy'", 20 | ] 21 | classifiers = [ 22 | "Development Status :: 5 - Production/Stable", 23 | "Operating System :: Microsoft :: Windows", 24 | "Operating System :: POSIX", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3 :: Only", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Programming Language :: Python :: 3.14", 33 | "Topic :: Software Development :: Build Tools", 34 | "Topic :: System :: Systems Administration", 35 | ] 36 | keywords = [ 37 | "path", 38 | "local", 39 | "remote", 40 | "ssh", 41 | "shell", 42 | "pipe", 43 | "popen", 44 | "process", 45 | "execution", 46 | "color", 47 | "cli", 48 | ] 49 | 50 | [project.urls] 51 | Homepage = "https://github.com/tomerfiliba/plumbum" 52 | Documentation = "https://plumbum.readthedocs.io/" 53 | "Bug Tracker" = "https://github.com/tomerfiliba/plumbum/issues" 54 | Changelog = "https://plumbum.readthedocs.io/en/latest/changelog.html" 55 | Cheatsheet = "https://plumbum.readthedocs.io/en/latest/quickref.html" 56 | 57 | 58 | [project.optional-dependencies] 59 | ssh = [ 60 | "paramiko", 61 | ] 62 | 63 | [tool.hatch] 64 | version.source = "vcs" 65 | build.hooks.vcs.version-file = "plumbum/version.py" 66 | 67 | [dependency-groups] 68 | test = [ 69 | "coverage[toml]", 70 | "paramiko", 71 | "psutil", 72 | "pytest-cov", 73 | "pytest-mock", 74 | "pytest-timeout", 75 | "pytest>=7.0", 76 | ] 77 | dev = [ 78 | { include-group = "test" } 79 | ] 80 | docs = [ 81 | "sphinx>=6.0.0", 82 | "sphinx-rtd-theme>=1.0.0", 83 | ] 84 | 85 | 86 | [tool.mypy] 87 | files = ["plumbum"] 88 | python_version = "3.9" 89 | warn_unused_configs = true 90 | warn_unused_ignores = true 91 | show_error_codes = true 92 | enable_error_code = ["ignore-without-code", "truthy-bool"] 93 | disallow_any_generics = false 94 | disallow_subclassing_any = false 95 | disallow_untyped_calls = false 96 | disallow_untyped_defs = false 97 | disallow_incomplete_defs = true 98 | check_untyped_defs = false 99 | disallow_untyped_decorators = false 100 | no_implicit_optional = true 101 | warn_redundant_casts = true 102 | warn_return_any = false 103 | no_implicit_reexport = true 104 | strict_equality = true 105 | 106 | [[tool.mypy.overrides]] 107 | module = ["IPython.*", "pywintypes.*", "win32con.*", "win32file.*", "PIL.*", "plumbum.cmd.*", "ipywidgets.*", "traitlets.*", "plumbum.version"] 108 | ignore_missing_imports = true 109 | 110 | 111 | [tool.pytest.ini_options] 112 | testpaths = ["tests"] 113 | minversion = "7.0" 114 | addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config", "--cov-config=pyproject.toml" ] 115 | norecursedirs = ["examples", "experiments"] 116 | filterwarnings = [ 117 | "always", 118 | ] 119 | log_cli_level = "info" 120 | xfail_strict = true 121 | required_plugins = ["pytest-timeout", "pytest-mock"] 122 | timeout = 300 123 | optional_tests = """ 124 | ssh: requires self ssh access to run 125 | sudo: requires sudo access to run 126 | """ 127 | 128 | 129 | [tool.pylint] 130 | py-version = "3.9" 131 | jobs = "0" 132 | load-plugins = ["pylint.extensions.no_self_use"] 133 | reports.output-format = "colorized" 134 | similarities.ignore-imports = "yes" 135 | messages_control.enable = [ 136 | "useless-suppression", 137 | ] 138 | messages_control.disable = [ 139 | "arguments-differ", # TODO: investigate 140 | "attribute-defined-outside-init", # TODO: investigate 141 | "broad-except", # TODO: investigate 142 | "consider-using-with", # TODO: should be handled 143 | "cyclic-import", 144 | "duplicate-code", # TODO: check 145 | "fixme", 146 | "import-error", 147 | "import-outside-toplevel", # TODO: see if this can be limited to certain imports 148 | "invalid-name", 149 | "line-too-long", 150 | "missing-class-docstring", 151 | "missing-function-docstring", 152 | "missing-module-docstring", 153 | "no-member", 154 | #"non-parent-init-called", # TODO: should be looked at 155 | "protected-access", 156 | "too-few-public-methods", 157 | "too-many-arguments", 158 | "too-many-branches", 159 | "too-many-function-args", 160 | "too-many-instance-attributes", 161 | "too-many-lines", 162 | "too-many-locals", 163 | "too-many-nested-blocks", 164 | "too-many-public-methods", 165 | "too-many-return-statements", 166 | "too-many-statements", 167 | "too-many-positional-arguments", 168 | "unidiomatic-typecheck", # TODO: might be able to remove 169 | "unnecessary-lambda-assignment", # TODO: 4 instances 170 | "unused-import", # identical to flake8 but has typing false positives 171 | "eval-used", # Needed for Python <3.10 annotations 172 | "unused-argument", # Covered by ruff 173 | "global-statement", # Covered by ruff 174 | "pointless-statement", # Covered by ruff 175 | ] 176 | 177 | [tool.ruff] 178 | exclude = ["docs/conf.py"] 179 | 180 | [tool.ruff.lint] 181 | extend-select = [ 182 | "B", # flake8-bugbear 183 | "I", # isort 184 | "ARG", # flake8-unused-arguments 185 | "C4", # flake8-comprehensions 186 | "ICN", # flake8-import-conventions 187 | "ISC", # flake8-implicit-str-concat 188 | "PGH", # pygrep-hooks 189 | "PIE", # flake8-pie 190 | "PL", # pylint 191 | "PT", # flake8-pytest-style 192 | "RET", # flake8-return 193 | "RUF", # Ruff-specific 194 | "SIM", # flake8-simplify 195 | "T20", # flake8-print 196 | "UP", # pyupgrade 197 | "YTT", # flake8-2020 198 | ] 199 | ignore = [ 200 | "E501", 201 | "PLR", 202 | "E721", # Type comparisons (TODO) 203 | "PT011", # TODO: add match parameter 204 | "RUF012", # ClassVar required if mutable 205 | ] 206 | flake8-unused-arguments.ignore-variadic-names = true 207 | isort.required-imports = ["from __future__ import annotations"] 208 | 209 | [tool.ruff.lint.per-file-ignores] 210 | "examples/*" = ["T20"] 211 | "experiments/*" = ["T20"] 212 | "tests/*" = ["T20"] 213 | "plumbum/cli/application.py" = ["T20"] 214 | "plumbum/commands/base.py" = ["SIM115"] 215 | "plumbum/commands/daemons.py" = ["SIM115"] 216 | 217 | [tool.codespell] 218 | ignore-words-list = "ans,switchs,hart,ot,twoo,fo" 219 | skip = "*.po" 220 | 221 | 222 | [tool.coverage.run] 223 | branch = true 224 | relative_files = true 225 | source_pkgs = ["plumbum"] 226 | omit = [ 227 | "*ipython*.py", 228 | "*__main__.py", 229 | "*_windows.py", 230 | ] 231 | 232 | [tool.coverage.report] 233 | exclude_also = [ 234 | "def __repr__", 235 | "raise AssertionError", 236 | "raise NotImplementedError", 237 | "if __name__ == .__main__.:", 238 | ] 239 | -------------------------------------------------------------------------------- /tests/_test_paramiko.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plumbum import local 4 | from plumbum.paramiko_machine import ParamikoMachine as PM 5 | 6 | local.env.path.append("c:\\progra~1\\git\\bin") 7 | from plumbum.cmd import grep, ls # noqa: E402 8 | 9 | m = PM("192.168.1.143") 10 | mls = m["ls"] 11 | mgrep = m["grep"] 12 | # (mls | mgrep["b"])() 13 | 14 | (mls | grep["\\."])() 15 | 16 | (ls | mgrep["\\."])() 17 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | import logging 5 | import os 6 | import re 7 | import tempfile 8 | 9 | import pytest 10 | 11 | SDIR = os.path.dirname(os.path.abspath(__file__)) 12 | 13 | 14 | @pytest.fixture 15 | def testdir(): 16 | os.chdir(SDIR) 17 | 18 | 19 | @pytest.fixture 20 | def cleandir(): 21 | newpath = tempfile.mkdtemp() 22 | os.chdir(newpath) 23 | 24 | 25 | # Pulled from https://github.com/reece/pytest-optional-tests 26 | 27 | """implements declaration of optional tests using pytest markers 28 | 29 | The MIT License (MIT) 30 | 31 | Copyright (c) 2019 Reece Hart 32 | 33 | Permission is hereby granted, free of charge, to any person obtaining a copy 34 | of this software and associated documentation files (the "Software"), to deal 35 | in the Software without restriction, including without limitation the rights 36 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 37 | copies of the Software, and to permit persons to whom the Software is 38 | furnished to do so, subject to the following conditions: 39 | 40 | The above copyright notice and this permission notice shall be included in 41 | all copies or substantial portions of the Software. 42 | 43 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 44 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 45 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 46 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 47 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 48 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 49 | THE SOFTWARE. 50 | 51 | """ 52 | 53 | 54 | _logger = logging.getLogger(__name__) 55 | 56 | marker_re = re.compile(r"^\s*(?P\w+)(:\s*(?P.*))?") 57 | 58 | 59 | def pytest_addoption(parser): 60 | group = parser.getgroup("collect") 61 | group.addoption( 62 | "--run-optional-tests", 63 | action="append", 64 | dest="run_optional_tests", 65 | default=None, 66 | help="Optional test markers to run, multiple and/or comma separated okay", 67 | ) 68 | parser.addini( 69 | "optional_tests", "list of optional markers", type="linelist", default="" 70 | ) 71 | 72 | 73 | def pytest_configure(config): 74 | # register all optional tests declared in ini file as markers 75 | # https://docs.pytest.org/en/latest/writing_plugins.html#registering-custom-markers 76 | ot_ini = config.inicfg.get("optional_tests").strip().splitlines() 77 | for ot_ in ot_ini: 78 | # ot should be a line like "optmarker: this is an opt marker", as with markers section 79 | config.addinivalue_line("markers", ot_) 80 | ot_markers = {marker_re.match(ln).group(1) for ln in ot_ini} 81 | 82 | # collect requested optional tests 83 | ot_run = config.getoption("run_optional_tests") 84 | if ot_run: 85 | ot_run = list(itertools.chain.from_iterable(a.split(",") for a in ot_run)) 86 | else: 87 | ot_run = config.inicfg.get("run_optional_tests", []) 88 | if ot_run: 89 | ot_run = list(re.split(r"[,\s]+", ot_run)) 90 | ot_run = set(ot_run) 91 | 92 | _logger.info("optional tests to run: %s", ot_run) 93 | if ot_run: 94 | unknown_tests = ot_run - ot_markers 95 | if unknown_tests: 96 | raise ValueError( 97 | "Requested execution of undeclared optional tests: {}".format( 98 | ", ".join(unknown_tests) 99 | ) 100 | ) 101 | 102 | config._ot_markers = set(ot_markers) 103 | config._ot_run = set(ot_run) 104 | 105 | 106 | def pytest_collection_modifyitems(config, items): 107 | # https://stackoverflow.com/a/50114028/342839 108 | ot_markers = config._ot_markers 109 | ot_run = config._ot_run 110 | 111 | skips = {} 112 | for item in items: 113 | marker_names = {m.name for m in item.iter_markers()} 114 | if not marker_names: 115 | continue 116 | test_otms = marker_names & ot_markers 117 | if not test_otms: 118 | # test is not marked with any optional marker 119 | continue 120 | if test_otms & ot_run: 121 | # test is marked with an enabled optional test; don't skip 122 | continue 123 | mns = str(marker_names) 124 | if mns not in skips: 125 | skips[mns] = pytest.mark.skip( 126 | reason="Skipping; marked with disabled optional tests ({})".format( 127 | ", ".join(marker_names) 128 | ) 129 | ) 130 | item.add_marker(skips[mns]) 131 | -------------------------------------------------------------------------------- /tests/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import platform 5 | import sys 6 | 7 | LINUX = sys.platform.startswith("linux") 8 | MACOS = sys.platform.startswith("darwin") 9 | WIN = sys.platform.startswith("win32") or sys.platform.startswith("cygwin") 10 | 11 | CPYTHON = platform.python_implementation() == "CPython" 12 | PYPY = platform.python_implementation() == "PyPy" 13 | 14 | IS_A_TTY = sys.stdin.isatty() 15 | HAS_CHOWN = hasattr(os, "chown") 16 | -------------------------------------------------------------------------------- /tests/file with space.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomerfiliba/plumbum/23c5f93bcba41ea7f70e9e06157fd10aefaa9e4c/tests/file with space.txt -------------------------------------------------------------------------------- /tests/not-in-path/dummy-executable: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | -------------------------------------------------------------------------------- /tests/slow_process.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "Starting test" > slow_process.out 4 | for i in $(seq 1 3) 5 | do 6 | echo $i 7 | echo $i >> slow_process.out 8 | sleep 1 9 | done 10 | -------------------------------------------------------------------------------- /tests/test_3_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plumbum import cli 4 | 5 | 6 | class Main3Validator(cli.Application): 7 | def main(self, myint: int, myint2: int, *mylist: int): 8 | print(myint, myint2, mylist) 9 | 10 | 11 | class TestProg3: 12 | def test_prog(self, capsys): 13 | _, rc = Main3Validator.run(["prog", "1", "2", "3", "4", "5"], exit=False) 14 | assert rc == 0 15 | assert "1 2 (3, 4, 5)" in capsys.readouterr()[0] 16 | 17 | 18 | class Main4Validator(cli.Application): 19 | def main(self, myint: int, myint2: int, *mylist: int) -> None: 20 | print(myint, myint2, mylist) 21 | 22 | 23 | class TestProg4: 24 | def test_prog(self, capsys): 25 | _, rc = Main4Validator.run(["prog", "1", "2", "3", "4", "5"], exit=False) 26 | assert rc == 0 27 | assert "1 2 (3, 4, 5)" in capsys.readouterr()[0] 28 | -------------------------------------------------------------------------------- /tests/test_clicolor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plumbum import cli, colors 4 | 5 | colors.use_color = 3 6 | 7 | 8 | def make_app(): 9 | class SimpleApp(cli.Application): 10 | PROGNAME = colors.green 11 | VERSION = colors.red | "1.0.3" 12 | 13 | @cli.switch(["a"]) 14 | def spam(self): 15 | print("!!a") 16 | 17 | def main(self, *args): 18 | print("lalala") 19 | 20 | return SimpleApp 21 | 22 | 23 | class TestSimpleApp: 24 | def test_runs(self): 25 | SimpleApp = make_app() 26 | _, rc = SimpleApp.run(["SimpleApp"], exit=False) 27 | assert rc == 0 28 | 29 | def test_colorless_run(self, capsys): 30 | colors.use_color = 0 31 | SimpleApp = make_app() 32 | _, rc = SimpleApp.run(["SimpleApp"], exit=False) 33 | assert capsys.readouterr()[0] == "lalala\n" 34 | 35 | def test_colorful_run(self, capsys): 36 | colors.use_color = 4 37 | SimpleApp = make_app() 38 | _, rc = SimpleApp.run(["SimpleApp"], exit=False) 39 | assert capsys.readouterr()[0] == "lalala\n" 40 | 41 | def test_colorless_output(self, capsys): 42 | colors.use_color = 0 43 | SimpleApp = make_app() 44 | _, rc = SimpleApp.run(["SimpleApp", "-h"], exit=False) 45 | output = capsys.readouterr()[0] 46 | assert "SimpleApp 1.0.3" in output 47 | assert "SimpleApp [SWITCHES] args..." in output 48 | 49 | def test_colorful_help(self, capsys): 50 | colors.use_color = 4 51 | SimpleApp = make_app() 52 | _, rc = SimpleApp.run(["SimpleApp", "-h"], exit=False) 53 | output = capsys.readouterr()[0] 54 | assert "SimpleApp 1.0.3" not in output 55 | assert SimpleApp.PROGNAME | "SimpleApp" in output 56 | 57 | 58 | class TestNSApp: 59 | def test_colorful_output(self, capsys): 60 | colors.use_color = 4 61 | 62 | class NotSoSimpleApp(cli.Application): 63 | PROGNAME = colors.blue | "NSApp" 64 | VERSION = "1.2.3" 65 | COLOR_GROUPS = {"Switches": colors.cyan} 66 | COLOR_GROUP_TITLES = {"Switches": colors.bold & colors.cyan} 67 | COLOR_USAGE_TITLE = colors.bold & colors.cyan 68 | 69 | @cli.switch(["b"], help="this is a bacon switch") 70 | def bacon(self): 71 | print("Oooooh, I love BACON!") 72 | 73 | @cli.switch(["c"], help=colors.red | "crunchy") 74 | def crunchy(self): 75 | print("Crunchy...") 76 | 77 | def main(self): 78 | print("Eating!") 79 | 80 | _, rc = NotSoSimpleApp.run(["NotSoSimpleApp", "-h"], exit=False) 81 | output = capsys.readouterr()[0] 82 | assert rc == 0 83 | expected = str((colors.blue | "NSApp") + " 1.2.3") 84 | assert str(colors.bold & colors.cyan | "Switches:") in output 85 | assert str(colors.bold & colors.cyan | "Usage:") in output 86 | assert "-b" in output 87 | assert str(colors.red | "crunchy") in output 88 | assert str(colors.cyan | "this is a bacon switch") in output 89 | assert expected in output 90 | -------------------------------------------------------------------------------- /tests/test_color.py: -------------------------------------------------------------------------------- 1 | # Just check to see if this file is importable 2 | from __future__ import annotations 3 | 4 | from plumbum.cli.image import Image # noqa: F401 5 | from plumbum.colorlib.names import FindNearest, color_html 6 | from plumbum.colorlib.styles import ( # noqa: F401 7 | ANSIStyle, 8 | AttributeNotFound, 9 | Color, 10 | ColorNotFound, 11 | ) 12 | 13 | 14 | class TestNearestColor: 15 | def test_exact(self): 16 | assert FindNearest(0, 0, 0).all_fast() == 0 17 | for n, color in enumerate(color_html): 18 | # Ignoring duplicates 19 | if n not in (16, 21, 46, 51, 196, 201, 226, 231, 244): 20 | rgb = (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)) 21 | assert FindNearest(*rgb).all_fast() == n 22 | 23 | def test_nearby(self): 24 | assert FindNearest(1, 2, 2).all_fast() == 0 25 | assert FindNearest(7, 7, 9).all_fast() == 232 26 | 27 | def test_simplecolor(self): 28 | assert FindNearest(1, 2, 4).only_basic() == 0 29 | assert FindNearest(0, 255, 0).only_basic() == 2 30 | assert FindNearest(100, 100, 0).only_basic() == 3 31 | assert FindNearest(140, 140, 140).only_basic() == 7 32 | 33 | 34 | class TestColorLoad: 35 | def test_rgb(self): 36 | blue = Color(0, 0, 255) # Red, Green, Blue 37 | assert blue.rgb == (0, 0, 255) 38 | 39 | def test_simple_name(self): 40 | green = Color.from_simple("green") 41 | assert green.number == 2 42 | 43 | def test_different_names(self): 44 | assert Color("Dark Blue") == Color("Dark_Blue") 45 | assert Color("Dark_blue") == Color("Dark_Blue") 46 | assert Color("DARKBLUE") == Color("Dark_Blue") 47 | assert Color("DarkBlue") == Color("Dark_Blue") 48 | assert Color("Dark Green") == Color("Dark_Green") 49 | 50 | def test_loading_methods(self): 51 | assert Color("Yellow") == Color.from_full("Yellow") 52 | assert ( 53 | Color.from_full("yellow").representation 54 | != Color.from_simple("yellow").representation 55 | ) 56 | 57 | 58 | class TestANSIColor: 59 | @classmethod 60 | def setup_class(cls): 61 | ANSIStyle.use_color = True 62 | 63 | def test_ansi(self): 64 | assert str(ANSIStyle(fgcolor=Color("reset"))) == "\033[39m" 65 | assert str(ANSIStyle(fgcolor=Color.from_full("green"))) == "\033[38;5;2m" 66 | assert str(ANSIStyle(fgcolor=Color.from_simple("red"))) == "\033[31m" 67 | 68 | 69 | class TestNearestColorAgain: 70 | def test_allcolors(self): 71 | myrange = ( 72 | 0, 73 | 1, 74 | 2, 75 | 5, 76 | 17, 77 | 39, 78 | 48, 79 | 73, 80 | 82, 81 | 140, 82 | 193, 83 | 210, 84 | 240, 85 | 244, 86 | 250, 87 | 254, 88 | 255, 89 | ) 90 | for r in myrange: 91 | for g in myrange: 92 | for b in myrange: 93 | near = FindNearest(r, g, b) 94 | assert near.all_slow() == near.all_fast(), f"Tested: {r}, {g}, {b}" 95 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from plumbum import local 6 | from plumbum.cli import Config, ConfigINI 7 | 8 | fname = "test_config.ini" 9 | 10 | 11 | @pytest.mark.usefixtures("cleandir") 12 | class TestConfig: 13 | def test_makefile(self): 14 | with ConfigINI(fname) as conf: 15 | conf["value"] = 12 16 | conf["string"] = "ho" 17 | 18 | with open(fname) as f: 19 | contents = f.read() 20 | 21 | assert "value = 12" in contents 22 | assert "string = ho" in contents 23 | 24 | def test_readfile(self): 25 | with open(fname, "w") as f: 26 | print( 27 | """ 28 | [DEFAULT] 29 | one = 1 30 | two = hello""", 31 | file=f, 32 | ) 33 | 34 | with ConfigINI(fname) as conf: 35 | assert conf["one"] == "1" 36 | assert conf["two"] == "hello" 37 | 38 | def test_complex_ini(self): 39 | with Config(fname) as conf: 40 | conf["value"] = "normal" 41 | conf["newer.value"] = "other" 42 | 43 | with Config(fname) as conf: 44 | assert conf["value"] == "normal" 45 | assert conf["DEFAULT.value"] == "normal" 46 | assert conf["newer.value"] == "other" 47 | 48 | def test_nowith(self): 49 | conf = ConfigINI(fname) 50 | conf["something"] = "nothing" 51 | conf.write() 52 | 53 | with open(fname) as f: 54 | contents = f.read() 55 | 56 | assert "something = nothing" in contents 57 | 58 | def test_home(self): 59 | mypath = local.env.home / "some_simple_home_rc.ini" 60 | assert not mypath.exists() 61 | try: 62 | with Config("~/some_simple_home_rc.ini") as conf: 63 | conf["a"] = "b" 64 | assert mypath.exists() 65 | mypath.unlink() 66 | 67 | with Config(mypath) as conf: 68 | conf["a"] = "b" 69 | assert mypath.exists() 70 | mypath.unlink() 71 | 72 | finally: 73 | mypath.unlink() 74 | 75 | def test_notouch(self): 76 | ConfigINI(fname) 77 | assert not local.path(fname).exists() 78 | 79 | def test_only_string(self): 80 | conf = ConfigINI(fname) 81 | value = conf.get("value", 2) 82 | assert value == "2" 83 | -------------------------------------------------------------------------------- /tests/test_env.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | 5 | from plumbum import local 6 | from plumbum._testtools import skip_on_windows 7 | 8 | with contextlib.suppress(ModuleNotFoundError): 9 | from plumbum.cmd import printenv 10 | 11 | 12 | @skip_on_windows 13 | class TestEnv: 14 | def test_change_env(self): 15 | with local.env(silly=12): 16 | assert local.env["silly"] == 12 17 | actual = {x.split("=")[0] for x in printenv().splitlines() if "=" in x} 18 | localenv = {x[0] for x in local.env} 19 | print(actual, localenv) 20 | assert localenv == actual 21 | assert len(local.env) == len(actual) 22 | 23 | def test_dictlike(self): 24 | keys = {x.split("=")[0] for x in printenv().splitlines() if "=" in x} 25 | values = { 26 | x.split("=", 1)[1].strip() for x in printenv().splitlines() if "=" in x 27 | } 28 | 29 | assert keys == set(local.env.keys()) 30 | assert len(values) == len(set(local.env.values())) 31 | 32 | def test_custom_env(self): 33 | with local.env(): 34 | items = {"one": "OnE", "tww": "TWOO"} 35 | local.env.update(items) 36 | assert "tww" in local.env 37 | local.env.clear() 38 | assert "tww" not in local.env 39 | 40 | def test_item(self): 41 | with local.env(): 42 | local.env["simple_plum"] = "thing" 43 | assert "simple_plum" in local.env 44 | del local.env["simple_plum"] 45 | assert "simple_plum" not in local.env 46 | local.env["simple_plum"] = "thing" 47 | assert "simple_plum" in local.env 48 | assert local.env.pop("simple_plum") == "thing" 49 | assert "simple_plum" not in local.env 50 | local.env["simple_plum"] = "thing" 51 | assert "simple_plum" not in local.env 52 | 53 | @skip_on_windows 54 | def test_home(self): 55 | assert local.env.home == local.env["HOME"] 56 | old_home = local.env.home 57 | with local.env(): 58 | local.env.home = "Nobody" 59 | assert local.env.home == local.env["HOME"] 60 | assert local.env.home == "Nobody" 61 | assert local.env.home == old_home 62 | 63 | @skip_on_windows 64 | def test_user(self): 65 | assert local.env.user 66 | -------------------------------------------------------------------------------- /tests/test_factories.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | import pytest 5 | 6 | from plumbum import colors 7 | from plumbum.colorlib import htmlcolors 8 | from plumbum.colorlib.styles import ANSIStyle as Style 9 | from plumbum.colorlib.styles import ColorNotFound 10 | 11 | 12 | class TestImportColors: 13 | def testDifferentImports(self): 14 | from plumbum.colors import bold 15 | from plumbum.colors.fg import red 16 | 17 | assert str(red) == str(colors.red) 18 | assert str(bold) == str(colors.bold) 19 | 20 | 21 | class TestANSIColor: 22 | def setup_method(self, method): # noqa: ARG002 23 | colors.use_color = True 24 | 25 | def testColorSlice(self): 26 | vals = colors[:8] 27 | assert len(vals) == 8 28 | assert vals[1] == colors.red 29 | vals = colors[40:50] 30 | assert len(vals) == 10 31 | assert vals[1] == colors.full(41) 32 | 33 | def testLoadNumericalColor(self): 34 | assert colors.full(2) == colors[2] 35 | assert colors.simple(2) == colors(2) 36 | assert colors(54) == colors[54] 37 | assert colors(1, 30, 77) == colors.rgb(1, 30, 77) 38 | assert colors[1, 30, 77] == colors.rgb(1, 30, 77) 39 | 40 | def testColorStrings(self): 41 | assert colors.reset == "\033[0m" 42 | assert colors.bold == "\033[1m" 43 | assert colors.fg.reset == "\033[39m" 44 | 45 | def testNegateIsReset(self): 46 | assert colors.reset == ~colors 47 | assert colors.fg.reset == ~colors.fg 48 | assert colors.bg.reset == ~colors.bg 49 | 50 | def testFromPreviousColor(self): 51 | assert colors(colors.red) == colors.red 52 | assert colors(colors.bg.red) == colors.bg.red 53 | assert colors(colors.bold) == colors.bold 54 | 55 | def testFromCode(self): 56 | assert colors("\033[31m") == colors.red 57 | 58 | def testEmptyStyle(self): 59 | assert str(colors()) == "" 60 | assert str(colors("")) == "" 61 | assert str(colors(None)) == "" 62 | 63 | def testLoadColorByName(self): 64 | assert colors["LightBlue"] == colors.fg["LightBlue"] 65 | assert colors.bg["light_green"] == colors.bg["LightGreen"] 66 | assert colors["DeepSkyBlue1"] == colors["#00afff"] 67 | assert colors["DeepSkyBlue1"] == colors.hex("#00afff") 68 | 69 | assert colors["DeepSkyBlue1"] == colors[39] 70 | assert colors.DeepSkyBlue1 == colors[39] 71 | assert colors.deepskyblue1 == colors[39] 72 | assert colors.Deep_Sky_Blue1 == colors[39] 73 | assert colors.red == colors.RED 74 | 75 | with pytest.raises(AttributeError): 76 | colors.Notacolorsatall # noqa: B018 77 | 78 | def testMultiColor(self): 79 | sumcolors = colors.bold & colors.blue 80 | assert colors.bold.reset & colors.fg.reset == ~sumcolors 81 | 82 | def testSums(self): 83 | # Sums should not be communitave, last one is used 84 | assert colors.red == colors.blue & colors.red 85 | assert colors.bg.green == colors.bg.red & colors.bg.green 86 | 87 | def testRepresentations(self): 88 | colors1 = colors.full(87) 89 | assert colors1 == colors.DarkSlateGray2 90 | assert colors1.basic == colors.DarkSlateGray2 91 | assert str(colors1.basic) == str(colors.LightGray) 92 | 93 | colors2 = colors.rgb(1, 45, 214) 94 | assert str(colors2.full) == str(colors.Blue3A) 95 | 96 | def testFromAnsi(self): 97 | for c in colors[1:7]: 98 | assert c == colors.from_ansi(str(c)) 99 | for c in colors.bg[1:7]: 100 | assert c == colors.from_ansi(str(c)) 101 | for c in colors: 102 | assert c == colors.from_ansi(str(c)) 103 | for c in colors.bg: 104 | assert c == colors.from_ansi(str(c)) 105 | for c in colors[:16]: 106 | assert c == colors.from_ansi(str(c)) 107 | for c in colors.bg[:16]: 108 | assert c == colors.from_ansi(str(c)) 109 | for c in (colors.bold, colors.underline, colors.italics): 110 | assert c == colors.from_ansi(str(c)) 111 | 112 | col = colors.bold & colors.fg.green & colors.bg.blue & colors.underline 113 | assert col == colors.from_ansi(str(col)) 114 | col = colors.reset 115 | assert col == colors.from_ansi(str(col)) 116 | 117 | def testWrappedColor(self): 118 | string = "This is a string" 119 | wrapped = "\033[31mThis is a string\033[39m" 120 | assert colors.red.wrap(string) == wrapped 121 | assert colors.red | string == wrapped 122 | assert colors.red[string] == wrapped 123 | 124 | newcolors = colors.blue & colors.underline 125 | assert newcolors[string] == string | newcolors 126 | assert newcolors.wrap(string) == string | colors.blue & colors.underline 127 | 128 | def testUndoColor(self): 129 | assert ~colors.fg == "\033[39m" 130 | assert ~colors.bg == "\033[49m" 131 | assert ~colors.bold == "\033[22m" 132 | assert ~colors.dim == "\033[22m" 133 | for i in range(7): 134 | assert ~colors(i) == "\033[39m" 135 | assert ~colors.bg(i) == "\033[49m" 136 | assert ~colors.fg(i) == "\033[39m" 137 | assert ~colors.bg(i) == "\033[49m" 138 | for i in range(256): 139 | assert ~colors.fg[i] == "\033[39m" 140 | assert ~colors.bg[i] == "\033[49m" 141 | assert ~colors.reset == "\033[0m" 142 | assert colors.do_nothing == ~colors.do_nothing 143 | 144 | assert colors.bold.reset == ~colors.bold 145 | 146 | def testLackOfColor(self): 147 | Style.use_color = False 148 | assert colors.fg.red == "" 149 | assert ~colors.fg == "" 150 | assert colors.fg["LightBlue"] == "" 151 | 152 | def testFromHex(self): 153 | with pytest.raises(ColorNotFound): 154 | colors.hex("asdf") 155 | 156 | with pytest.raises(ColorNotFound): 157 | colors.hex("#1234Z2") 158 | 159 | with pytest.raises(ColorNotFound): 160 | colors.hex(12) 161 | 162 | def testDirectCall(self, capsys): 163 | colors.blue() 164 | assert capsys.readouterr()[0] == str(colors.blue) 165 | 166 | def testPrint(self, capsys): 167 | colors.yellow.print("This is printed to stdout", end="") 168 | assert capsys.readouterr()[0] == str( 169 | colors.yellow.wrap("This is printed to stdout") 170 | ) 171 | 172 | 173 | class TestHTMLColor: 174 | def test_html(self): 175 | red_tagged = 'This is tagged' 176 | assert htmlcolors.red["This is tagged"] == red_tagged 177 | assert "This is tagged" | htmlcolors.red == red_tagged 178 | 179 | twin_tagged = 'This is tagged' 180 | assert "This is tagged" | htmlcolors.red & htmlcolors.em == twin_tagged 181 | assert "This is tagged" | htmlcolors.em & htmlcolors.red == twin_tagged 182 | assert htmlcolors.em & htmlcolors.red | "This is tagged" == twin_tagged 183 | -------------------------------------------------------------------------------- /tests/test_nohup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import time 5 | 6 | import psutil 7 | import pytest 8 | 9 | from plumbum import NOHUP, local 10 | 11 | try: 12 | from plumbum.cmd import bash, echo 13 | except ImportError: 14 | bash = None 15 | echo = None 16 | from plumbum._testtools import skip_on_windows 17 | from plumbum.path.utils import delete 18 | 19 | 20 | @skip_on_windows 21 | class TestNohupLocal: 22 | def read_file(self, filename): 23 | assert filename in os.listdir(".") 24 | with open(filename) as f: 25 | return f.read() 26 | 27 | @pytest.mark.usefixtures("testdir") 28 | def test_slow(self): 29 | delete("nohup.out") 30 | sp = bash["slow_process.bash"] 31 | sp & NOHUP 32 | time.sleep(0.5) 33 | assert self.read_file("slow_process.out") == "Starting test\n1\n" 34 | assert self.read_file("nohup.out") == "1\n" 35 | time.sleep(1) 36 | assert self.read_file("slow_process.out") == "Starting test\n1\n2\n" 37 | assert self.read_file("nohup.out") == "1\n2\n" 38 | time.sleep(2) 39 | delete("nohup.out", "slow_process.out") 40 | 41 | def test_append(self): 42 | delete("nohup.out") 43 | output = echo["This is output"] 44 | output & NOHUP 45 | time.sleep(0.2) 46 | assert self.read_file("nohup.out") == "This is output\n" 47 | output & NOHUP 48 | time.sleep(0.2) 49 | assert self.read_file("nohup.out") == "This is output\n" * 2 50 | delete("nohup.out") 51 | 52 | def test_redir(self): 53 | delete("nohup_new.out") 54 | output = echo["This is output"] 55 | 56 | output & NOHUP(stdout="nohup_new.out") 57 | time.sleep(0.2) 58 | assert self.read_file("nohup_new.out") == "This is output\n" 59 | delete("nohup_new.out") 60 | 61 | (output > "nohup_new.out") & NOHUP 62 | time.sleep(0.2) 63 | assert self.read_file("nohup_new.out") == "This is output\n" 64 | delete("nohup_new.out") 65 | 66 | output & NOHUP 67 | time.sleep(0.2) 68 | assert self.read_file("nohup.out") == "This is output\n" 69 | delete("nohup.out") 70 | 71 | def test_closed_filehandles(self): 72 | proc = psutil.Process() 73 | file_handles_prior = proc.num_fds() 74 | sleep_proc = local["sleep"]["1"] & NOHUP 75 | sleep_proc.wait() 76 | file_handles_after = proc.num_fds() 77 | assert file_handles_prior >= file_handles_after 78 | -------------------------------------------------------------------------------- /tests/test_pipelines.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | import plumbum 6 | from plumbum._testtools import skip_on_windows 7 | from plumbum.commands import BaseCommand 8 | 9 | 10 | @skip_on_windows 11 | @pytest.mark.timeout(3) 12 | def test_draining_stderr(generate_cmd, process_cmd): 13 | stdout, stderr = get_output_with_iter_lines( 14 | generate_cmd | process_cmd | process_cmd 15 | ) 16 | expected_output = {f"generated {i}" for i in range(5000)} 17 | expected_output.update(f"consumed {i}" for i in range(5000)) 18 | assert set(stderr) - expected_output == set() 19 | assert len(stderr) == 15000 20 | assert len(stdout) == 5000 21 | 22 | 23 | @skip_on_windows 24 | @pytest.mark.timeout(3) 25 | def test_draining_stderr_with_stderr_redirect(tmp_path, generate_cmd, process_cmd): 26 | stdout, stderr = get_output_with_iter_lines( 27 | generate_cmd | (process_cmd >= str(tmp_path / "output.txt")) | process_cmd 28 | ) 29 | expected_output = {f"generated {i}" for i in range(5000)} 30 | expected_output.update(f"consumed {i}" for i in range(5000)) 31 | assert set(stderr) - expected_output == set() 32 | assert len(stderr) == 10000 33 | assert len(stdout) == 5000 34 | 35 | 36 | @skip_on_windows 37 | @pytest.mark.timeout(3) 38 | def test_draining_stderr_with_stdout_redirect(tmp_path, generate_cmd, process_cmd): 39 | stdout, stderr = get_output_with_iter_lines( 40 | generate_cmd | process_cmd | process_cmd > str(tmp_path / "output.txt") 41 | ) 42 | expected_output = {f"generated {i}" for i in range(5000)} 43 | expected_output.update(f"consumed {i}" for i in range(5000)) 44 | assert set(stderr) - expected_output == set() 45 | assert len(stderr) == 15000 46 | assert len(stdout) == 0 47 | 48 | 49 | @pytest.fixture 50 | def generate_cmd(tmp_path): 51 | generate = tmp_path / "generate.py" 52 | generate.write_text( 53 | """\ 54 | import sys 55 | for i in range(5000): 56 | print("generated", i, file=sys.stderr) 57 | print(i) 58 | """ 59 | ) 60 | return plumbum.local["python"][generate] 61 | 62 | 63 | @pytest.fixture 64 | def process_cmd(tmp_path): 65 | process = tmp_path / "process.py" 66 | process.write_text( 67 | """\ 68 | import sys 69 | for line in sys.stdin: 70 | i = line.strip() 71 | print("consumed", i, file=sys.stderr) 72 | print(i) 73 | """ 74 | ) 75 | return plumbum.local["python"][process] 76 | 77 | 78 | def get_output_with_iter_lines(cmd: BaseCommand) -> tuple[list[str], list[str]]: 79 | stderr, stdout = [], [] 80 | proc = cmd.popen() 81 | for stdout_line, stderr_line in proc.iter_lines(retcode=[0, None]): 82 | if stderr_line: 83 | stderr.append(stderr_line) 84 | if stdout_line: 85 | stdout.append(stdout_line) 86 | proc.wait() 87 | return stdout, stderr 88 | -------------------------------------------------------------------------------- /tests/test_putty.py: -------------------------------------------------------------------------------- 1 | """Test that PuttyMachine initializes its SshMachine correctly""" 2 | 3 | from __future__ import annotations 4 | 5 | import pytest 6 | 7 | from plumbum import PuttyMachine, SshMachine 8 | 9 | 10 | @pytest.fixture(params=["default", "322"]) 11 | def ssh_port(request): 12 | return request.param 13 | 14 | 15 | class TestPuttyMachine: 16 | def test_putty_command(self, mocker, ssh_port): 17 | local = mocker.patch("plumbum.machines.ssh_machine.local") 18 | init = mocker.spy(SshMachine, "__init__") 19 | mocker.patch("plumbum.machines.ssh_machine.BaseRemoteMachine") 20 | 21 | host = mocker.MagicMock() 22 | user = local.env.user 23 | port = keyfile = None 24 | ssh_command = local["plink"] 25 | scp_command = local["pscp"] 26 | ssh_opts = ["-ssh"] 27 | if ssh_port == "default": 28 | putty_port = None 29 | scp_opts = () 30 | else: 31 | putty_port = int(ssh_port) 32 | ssh_opts.extend(["-P", ssh_port]) 33 | scp_opts = ["-P", ssh_port] 34 | encoding = mocker.MagicMock() 35 | connect_timeout = 20 36 | new_session = True 37 | 38 | PuttyMachine( 39 | host, 40 | port=putty_port, 41 | connect_timeout=connect_timeout, 42 | new_session=new_session, 43 | encoding=encoding, 44 | ) 45 | 46 | init.assert_called_with( 47 | mocker.ANY, 48 | host, 49 | user, 50 | port, 51 | keyfile=keyfile, 52 | ssh_command=ssh_command, 53 | scp_command=scp_command, 54 | ssh_opts=ssh_opts, 55 | scp_opts=scp_opts, 56 | encoding=encoding, 57 | connect_timeout=connect_timeout, 58 | new_session=new_session, 59 | ) 60 | 61 | def test_putty_str(self, mocker): 62 | local = mocker.patch("plumbum.machines.ssh_machine.local") 63 | mocker.patch("plumbum.machines.ssh_machine.BaseRemoteMachine") 64 | 65 | host = mocker.MagicMock() 66 | user = local.env.user 67 | 68 | machine = PuttyMachine(host) 69 | assert str(machine) == f"putty-ssh://{user}@{host}" 70 | -------------------------------------------------------------------------------- /tests/test_sudo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from plumbum import local 6 | from plumbum._testtools import skip_on_windows 7 | 8 | pytestmark = pytest.mark.sudo 9 | 10 | # This is a separate file to make separating (ugly) sudo command easier 11 | # For example, you can now run test_local directly without typing a password 12 | 13 | 14 | class TestSudo: 15 | @skip_on_windows 16 | def test_as_user(self): 17 | with local.as_root(): 18 | local["date"]() 19 | -------------------------------------------------------------------------------- /tests/test_terminal.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from collections import OrderedDict 5 | from contextlib import contextmanager 6 | from io import StringIO 7 | 8 | from plumbum.cli.terminal import Progress, ask, choose, hexdump, prompt 9 | 10 | 11 | @contextmanager 12 | def send_stdin(stdin="\n"): 13 | prevstdin = sys.stdin 14 | sys.stdin = StringIO(stdin) 15 | try: 16 | yield sys.stdin 17 | finally: 18 | sys.stdin = prevstdin 19 | 20 | 21 | class TestPrompt: 22 | def test_simple(self, capsys): 23 | with send_stdin("12"): 24 | assert prompt("Enter a random int:", type=int) == 12 25 | assert capsys.readouterr()[0] == "Enter a random int: " 26 | 27 | def test_try_twice(self, capsys): 28 | with send_stdin("\n13"): 29 | assert prompt("Enter a random int:", type=int) == 13 30 | assert capsys.readouterr()[0] == "Enter a random int: Enter a random int: " 31 | 32 | def test_str(self): 33 | with send_stdin("1234"): 34 | assert prompt("Enter a string", type=str) == "1234" 35 | 36 | def test_default(self, capsys): 37 | with send_stdin(""): 38 | assert prompt("Enter nothing", default="hi") == "hi" 39 | assert capsys.readouterr()[0] == "Enter nothing [hi]: " 40 | 41 | def test_typefail(self, capsys): 42 | with send_stdin("1.2\n13"): 43 | assert prompt("Enter int", type=int) == 13 44 | assert "try again" in capsys.readouterr()[0] 45 | 46 | def test_validator(self, capsys): 47 | with send_stdin("12\n9"): 48 | assert ( 49 | prompt("Enter in range < 10", type=int, validator=lambda x: x < 10) == 9 50 | ) 51 | assert "try again" in capsys.readouterr()[0] 52 | 53 | 54 | class TestTerminal: 55 | def test_ask(self, capsys): 56 | with send_stdin("\n"): 57 | assert ask("Do you like cats?", default=True) 58 | assert capsys.readouterr()[0] == "Do you like cats? [Y/n] " 59 | 60 | with send_stdin("\nyes"): 61 | assert ask("Do you like cats?") 62 | assert ( 63 | capsys.readouterr()[0] 64 | == "Do you like cats? (y/n) Invalid response, please try again\nDo you like cats? (y/n) " 65 | ) 66 | 67 | def test_choose(self, capsys): 68 | with send_stdin("foo\n2\n"): 69 | assert ( 70 | choose("What is your favorite color?", ["blue", "yellow", "green"]) 71 | == "yellow" 72 | ) 73 | assert ( 74 | capsys.readouterr()[0] 75 | == "What is your favorite color?\n(1) blue\n(2) yellow\n(3) green\nChoice: Invalid choice, please try again\nChoice: " 76 | ) 77 | 78 | with send_stdin("foo\n2\n"): 79 | assert ( 80 | choose( 81 | "What is your favorite color?", 82 | [("blue", 10), ("yellow", 11), ("green", 12)], 83 | ) 84 | == 11 85 | ) 86 | assert ( 87 | capsys.readouterr()[0] 88 | == "What is your favorite color?\n(1) blue\n(2) yellow\n(3) green\nChoice: Invalid choice, please try again\nChoice: " 89 | ) 90 | 91 | with send_stdin("foo\n\n"): 92 | assert ( 93 | choose( 94 | "What is your favorite color?", 95 | ["blue", "yellow", "green"], 96 | default="yellow", 97 | ) 98 | == "yellow" 99 | ) 100 | assert ( 101 | capsys.readouterr()[0] 102 | == "What is your favorite color?\n(1) blue\n(2) yellow\n(3) green\nChoice [2]: Invalid choice, please try again\nChoice [2]: " 103 | ) 104 | 105 | def test_choose_dict(self): 106 | with send_stdin("23\n1"): 107 | value = choose("Pick", {"one": "a", "two": "b"}) 108 | assert value in ("a", "b") 109 | 110 | def test_ordered_dict(self): 111 | dic = OrderedDict() 112 | dic["one"] = "a" 113 | dic["two"] = "b" 114 | with send_stdin("1"): 115 | value = choose("Pick", dic) 116 | assert value == "a" 117 | with send_stdin("2"): 118 | value = choose("Pick", dic) 119 | assert value == "b" 120 | 121 | def test_choose_dict_default(self, capsys): 122 | dic = OrderedDict() 123 | dic["one"] = "a" 124 | dic["two"] = "b" 125 | with send_stdin(): 126 | assert choose("Pick", dic, default="a") == "a" 127 | assert "[1]" in capsys.readouterr()[0] 128 | 129 | def test_hexdump(self): 130 | data = "hello world my name is queen marry" + "A" * 66 + "foo bar" 131 | output = """\ 132 | 000000 | 68 65 6c 6c 6f 20 77 6f 72 6c 64 20 6d 79 20 6e | hello world my n 133 | 000010 | 61 6d 65 20 69 73 20 71 75 65 65 6e 20 6d 61 72 | ame is queen mar 134 | 000020 | 72 79 41 41 41 41 41 41 41 41 41 41 41 41 41 41 | ryAAAAAAAAAAAAAA 135 | 000030 | 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 | AAAAAAAAAAAAAAAA 136 | * 137 | 000060 | 41 41 41 41 66 6f 6f 20 62 61 72 | AAAAfoo bar""" 138 | assert "\n".join(hexdump(data)) == output 139 | 140 | assert "\n".join(hexdump(StringIO(data))) == output 141 | 142 | def test_progress(self, capsys): 143 | for _ in Progress.range(4, has_output=True, timer=False): 144 | print("hi") 145 | stdout, _stderr = capsys.readouterr() 146 | output = """\ 147 | 0% complete 148 | 0% complete 149 | hi 150 | 25% complete 151 | hi 152 | 50% complete 153 | hi 154 | 75% complete 155 | hi 156 | 100% complete 157 | 158 | """ 159 | assert stdout == output 160 | 161 | def test_progress_empty(self, capsys): 162 | for _ in Progress.range(0, has_output=True, timer=False): 163 | print("hi") 164 | stdout = capsys.readouterr().out 165 | output = "0/0 complete" 166 | assert output in stdout 167 | -------------------------------------------------------------------------------- /tests/test_translate.py: -------------------------------------------------------------------------------- 1 | # Setting French as system language 2 | from __future__ import annotations 3 | 4 | import importlib 5 | import locale 6 | 7 | import pytest 8 | 9 | import plumbum.cli 10 | import plumbum.cli.i18n 11 | 12 | 13 | def reload_cli(): 14 | importlib.reload(plumbum.cli.i18n) 15 | importlib.reload(plumbum.cli.switches) 16 | importlib.reload(plumbum.cli.application) 17 | importlib.reload(plumbum.cli) 18 | 19 | 20 | @pytest.fixture 21 | def french(): 22 | try: 23 | locale.setlocale(locale.LC_ALL, "fr_FR.utf-8") 24 | reload_cli() 25 | yield 26 | except locale.Error: 27 | pytest.skip( 28 | "No fr_FR locale found, run 'sudo locale-gen fr_FR.UTF-8' to run this test" 29 | ) 30 | finally: 31 | locale.setlocale(locale.LC_ALL, "") 32 | reload_cli() 33 | 34 | 35 | @pytest.mark.usefixtures("french") 36 | def test_nolang_switches(): 37 | class Simple(plumbum.cli.Application): 38 | foo = plumbum.cli.SwitchAttr("--foo") 39 | 40 | def main(self): 41 | pass 42 | 43 | _, rc = Simple.run(["foo", "-h"], exit=False) 44 | assert rc == 0 45 | _, rc = Simple.run(["foo", "--version"], exit=False) 46 | assert rc == 0 47 | 48 | 49 | @pytest.mark.usefixtures("french") 50 | def test_help_lang(capsys): 51 | class Simple(plumbum.cli.Application): 52 | foo = plumbum.cli.SwitchAttr("--foo") 53 | 54 | def main(self): 55 | pass 56 | 57 | _, rc = Simple.run(["foo", "-h"], exit=False) 58 | assert rc == 0 59 | stdout, stderr = capsys.readouterr() 60 | assert "Utilisation" in stdout 61 | assert "Imprime ce message d'aide et sort" in stdout 62 | -------------------------------------------------------------------------------- /tests/test_typed_env.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from plumbum.typed_env import TypedEnv 6 | 7 | 8 | class TestTypedEnv: 9 | def test_env(self): 10 | class E(TypedEnv): 11 | terminal = TypedEnv.Str("TERM") 12 | B = TypedEnv.Bool("BOOL", default=True) 13 | I = TypedEnv.Int(["INT", "INTEGER"]) # noqa: E741 # noqa: E741 14 | INTS = TypedEnv.CSV("CS_INTS", type=int) 15 | 16 | raw_env = {"TERM": "xterm", "CS_INTS": "1,2,3,4"} 17 | e = E(raw_env) 18 | 19 | assert e.terminal == "xterm" 20 | e.terminal = "foo" 21 | assert e.terminal == "foo" 22 | assert raw_env["TERM"] == "foo" 23 | assert "terminal" not in raw_env 24 | 25 | # check default 26 | assert e.B is True 27 | 28 | raw_env["BOOL"] = "no" 29 | assert e.B is False 30 | 31 | raw_env["BOOL"] = "0" 32 | assert e.B is False 33 | 34 | e.B = True 35 | assert raw_env["BOOL"] == "yes" 36 | 37 | e.B = False 38 | assert raw_env["BOOL"] == "no" 39 | 40 | assert e.INTS == [1, 2, 3, 4] 41 | e.INTS = [1, 2] 42 | assert e.INTS == [1, 2] 43 | e.INTS = [1, 2, 3, 4] 44 | 45 | with pytest.raises(KeyError): 46 | e.I # noqa: B018 47 | 48 | raw_env["INTEGER"] = "4" 49 | assert e.I == 4 50 | assert e["I"] == 4 51 | 52 | e.I = "5" 53 | assert raw_env["INT"] == "5" 54 | assert e.I == 5 55 | assert e["I"] == 5 56 | 57 | assert "{I} {B} {terminal}".format(**e) == "5 False foo" 58 | assert dict(e) == {"I": 5, "B": False, "terminal": "foo", "INTS": [1, 2, 3, 4]} 59 | 60 | r = TypedEnv(raw_env) 61 | assert "{INT} {BOOL} {TERM}".format(**r) == "5 no foo" 62 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from plumbum import SshMachine, local 6 | from plumbum._testtools import skip_on_windows 7 | from plumbum.path.utils import copy, delete, move 8 | 9 | 10 | @skip_on_windows 11 | @pytest.mark.ssh 12 | def test_copy_move_delete(): 13 | from plumbum.cmd import touch 14 | 15 | with local.tempdir() as dir: 16 | (dir / "orog").mkdir() 17 | (dir / "orog" / "rec").mkdir() 18 | for i in range(20): 19 | touch(dir / "orog" / f"f{i}.txt") 20 | for i in range(20, 40): 21 | touch(dir / "orog" / "rec" / f"f{i}.txt") 22 | 23 | move(dir / "orog", dir / "orig") 24 | 25 | s1 = sorted(f.name for f in (dir / "orig").walk()) 26 | 27 | copy(dir / "orig", dir / "dup") 28 | s2 = sorted(f.name for f in (dir / "dup").walk()) 29 | assert s1 == s2 30 | 31 | with SshMachine("localhost") as rem, rem.tempdir() as dir2: 32 | copy(dir / "orig", dir2) 33 | s3 = sorted(f.name for f in (dir2 / "orig").walk()) 34 | assert s1 == s3 35 | 36 | copy(dir2 / "orig", dir2 / "dup") 37 | s4 = sorted(f.name for f in (dir2 / "dup").walk()) 38 | assert s1 == s4 39 | 40 | copy(dir2 / "dup", dir / "dup2") 41 | s5 = sorted(f.name for f in (dir / "dup2").walk()) 42 | assert s1 == s5 43 | 44 | with SshMachine("localhost") as rem2, rem2.tempdir() as dir3: 45 | copy(dir2 / "dup", dir3) 46 | s6 = sorted(f.name for f in (dir3 / "dup").walk()) 47 | assert s1 == s6 48 | 49 | move(dir3 / "dup", dir / "superdup") 50 | assert not (dir3 / "dup").exists() 51 | 52 | s7 = sorted(f.name for f in (dir / "superdup").walk()) 53 | assert s1 == s7 54 | 55 | # test rm 56 | delete(dir) 57 | -------------------------------------------------------------------------------- /tests/test_validate.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plumbum import cli 4 | 5 | 6 | class TestValidator: 7 | def test_named(self): 8 | class Try: 9 | @cli.positional(x=abs, y=str) 10 | def main(selfy, x, y): 11 | pass 12 | 13 | assert Try.main.positional == [abs, str] 14 | assert Try.main.positional_varargs is None 15 | 16 | def test_position(self): 17 | class Try: 18 | @cli.positional(abs, str) 19 | def main(selfy, x, y): 20 | pass 21 | 22 | assert Try.main.positional == [abs, str] 23 | assert Try.main.positional_varargs is None 24 | 25 | def test_mix(self): 26 | class Try: 27 | @cli.positional(abs, str, d=bool) 28 | def main(selfy, x, y, z, d): 29 | pass 30 | 31 | assert Try.main.positional == [abs, str, None, bool] 32 | assert Try.main.positional_varargs is None 33 | 34 | def test_var(self): 35 | class Try: 36 | @cli.positional(abs, str, int) 37 | def main(selfy, x, y, *g): 38 | pass 39 | 40 | assert Try.main.positional == [abs, str] 41 | assert Try.main.positional_varargs is int 42 | 43 | def test_defaults(self): 44 | class Try: 45 | @cli.positional(abs, str) 46 | def main(selfy, x, y="hello"): 47 | pass 48 | 49 | assert Try.main.positional == [abs, str] 50 | 51 | 52 | class TestProg: 53 | def test_prog(self, capsys): 54 | class MainValidator(cli.Application): 55 | @cli.positional(int, int, int) 56 | def main(self, myint, myint2, *mylist): 57 | print(repr(myint), myint2, mylist) 58 | 59 | _, rc = MainValidator.run(["prog", "1", "2", "3", "4", "5"], exit=False) 60 | assert rc == 0 61 | assert capsys.readouterr()[0].strip() == "1 2 (3, 4, 5)" 62 | 63 | def test_failure(self, capsys): 64 | class MainValidator(cli.Application): 65 | @cli.positional(int, int, int) 66 | def main(self, myint, myint2, *mylist): 67 | print(myint, myint2, mylist) 68 | 69 | _, rc = MainValidator.run(["prog", "1.2", "2", "3", "4", "5"], exit=False) 70 | 71 | assert rc == 2 72 | value = capsys.readouterr()[0].strip() 73 | assert "int" in value 74 | assert "not" in value 75 | assert "1.2" in value 76 | 77 | def test_defaults(self, capsys): 78 | class MainValidator(cli.Application): 79 | @cli.positional(int, int) 80 | def main(self, myint, myint2=2): 81 | print(repr(myint), repr(myint2)) 82 | 83 | _, rc = MainValidator.run(["prog", "1"], exit=False) 84 | assert rc == 0 85 | assert capsys.readouterr()[0].strip() == "1 2" 86 | 87 | _, rc = MainValidator.run(["prog", "1", "3"], exit=False) 88 | assert rc == 0 89 | assert capsys.readouterr()[0].strip() == "1 3" 90 | -------------------------------------------------------------------------------- /tests/test_visual_color.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | import os 5 | import unittest 6 | 7 | from plumbum import colors 8 | 9 | # This is really intended to be run manually, so the output can be observed, rather than with py.test 10 | 11 | 12 | class TestVisualColor(unittest.TestCase): 13 | def setUp(self): 14 | if os.name == "nt": 15 | try: 16 | import colorama 17 | 18 | colorama.init() 19 | self.colorama = colorama 20 | colors.use_color = 1 21 | print() 22 | print("Colorama initialized") 23 | except ImportError: 24 | self.colorama = None 25 | else: 26 | self.colorama = None 27 | 28 | def tearDown(self): 29 | if self.colorama: 30 | self.colorama.deinit() 31 | 32 | def testVisualColors(self): 33 | print() 34 | for c in colors.fg[:16]: 35 | with c: 36 | print("Cycle color test", end=" ") 37 | print(" - > back to normal") 38 | with colors: 39 | print( 40 | colors.fg.green 41 | + "Green " 42 | + colors.bold 43 | + "Bold " 44 | + ~colors.bold 45 | + "Normal" 46 | ) 47 | print("Reset all") 48 | 49 | def testToggleColors(self): 50 | print() 51 | print(colors.fg.red["This is in red"], "but this is not") 52 | print( 53 | colors.fg.green 54 | + "Hi, " 55 | + colors.bg[23] 56 | + "This is on a BG" 57 | + ~colors.bg 58 | + " and this is not but is still green." 59 | ) 60 | colors.yellow.print("This is printed from color.") 61 | colors.reset() 62 | 63 | for attr in colors._style.attribute_names: 64 | print("This is", attr | getattr(colors, attr), "and this is not.") 65 | colors.reset() 66 | 67 | def testLimits(self): 68 | print() 69 | cval = colors.use_color 70 | colors.use_color = 4 71 | c = colors.rgb(123, 40, 200) 72 | print("True", repr(str(c)), repr(c)) 73 | colors.use_color = 3 74 | print("Full", repr(str(c)), repr(c)) 75 | colors.use_color = 2 76 | print("Simple", repr(str(c)), repr(c)) 77 | colors.use_color = 1 78 | print("Basic", repr(str(c)), repr(c)) 79 | colors.use_color = 0 80 | print("None", repr(str(c)), repr(c)) 81 | colors.use_color = cval 82 | 83 | 84 | if __name__ == "__main__": 85 | unittest.main() 86 | -------------------------------------------------------------------------------- /translations.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # If you are on macOS and using brew, you might need the following first: 4 | # export PATH="/usr/local/opt/gettext/bin:$PATH" 5 | from __future__ import annotations 6 | 7 | from plumbum import FG, local 8 | from plumbum.cmd import msgfmt, msgmerge, xgettext 9 | 10 | translation_dir = local.cwd / "plumbum/cli/i18n" 11 | template = translation_dir / "messages.pot" 12 | 13 | ( 14 | xgettext[ 15 | "--from-code", 16 | "utf-8", 17 | "-L", 18 | "python", 19 | "--keyword=T_", 20 | "--package-name=Plumbum.cli", 21 | "-o", 22 | template, 23 | sorted(x - local.cwd for x in local.cwd / "plumbum/cli" // "*.py"), 24 | ] 25 | & FG 26 | ) 27 | 28 | for translation in translation_dir // "*.po": 29 | lang = translation.stem 30 | new_tfile = translation.with_suffix(".po.new") 31 | 32 | # Merge changes to new file 33 | (msgmerge[translation, template] > new_tfile) & FG 34 | 35 | new_tfile.move(translation) 36 | 37 | # Render new file into runtime output 38 | local_dir = translation_dir / lang / "LC_MESSAGES" 39 | if not local_dir.exists(): 40 | local_dir.mkdir() 41 | msgfmt["-o", local_dir / "plumbum.cli.mo", translation] & FG 42 | --------------------------------------------------------------------------------