├── .git-blame-ignore-revs ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── MANIFEST.in ├── README.rst ├── docs ├── _static │ └── .empty ├── conf.py ├── index.rst ├── license.rst ├── modules │ └── scripttest.rst └── news.rst ├── pyproject.toml ├── scripttest ├── __init__.py └── py.typed ├── tests ├── __init__.py ├── a_script.py ├── test_string.py └── test_testscript.py └── tox.ini /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 093f47250c02097c8be8de27ef586f642a4d6920 # Black v25 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | docs: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - run: python -m pip install tox 14 | - run: tox -e docs 15 | 16 | lint: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: pre-commit/action@v3.0.1 22 | 23 | tests-unix: 24 | name: tests / ${{ matrix.python }} / Ubuntu 25 | runs-on: ubuntu-latest 26 | strategy: 27 | matrix: 28 | python: 29 | - "3.8" 30 | - "3.9" 31 | - "3.10" 32 | - "3.11" 33 | - "3.12" 34 | - "3.13" 35 | - "pypy3.10" 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/setup-python@v5 40 | with: 41 | python-version: ${{ matrix.python }} 42 | - run: pipx run tox -e py 43 | 44 | tests-windows: 45 | name: tests / ${{ matrix.python }} / Windows 46 | runs-on: ubuntu-latest 47 | strategy: 48 | matrix: 49 | python: 50 | - "3.8" 51 | - "3.13" 52 | 53 | steps: 54 | - uses: actions/checkout@v4 55 | - uses: actions/setup-python@v5 56 | with: 57 | python-version: ${{ matrix.python }} 58 | - run: pipx run tox -e py 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | __pycache__/ 3 | _build/ 4 | .tox/ 5 | *.egg-info/ 6 | .coverage 7 | dist/ 8 | build/ 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-builtin-literals 6 | - id: check-added-large-files 7 | - id: check-case-conflict 8 | - id: check-toml 9 | - id: check-yaml 10 | - id: debug-statements 11 | - id: end-of-file-fixer 12 | - id: forbid-new-submodules 13 | - id: trailing-whitespace 14 | 15 | - repo: https://github.com/psf/black-pre-commit-mirror 16 | rev: 25.1.0 17 | hooks: 18 | - id: black 19 | 20 | - repo: https://github.com/pre-commit/mirrors-mypy 21 | rev: v1.15.0 22 | hooks: 23 | - id: mypy 24 | exclude: docs 25 | args: ["--pretty", "--show-error-codes"] 26 | 27 | - repo: https://github.com/astral-sh/ruff-pre-commit 28 | rev: v0.9.10 29 | hooks: 30 | - id: ruff 31 | args: [--fix, --exit-non-zero-on-fix] 32 | 33 | - repo: https://github.com/pre-commit/pygrep-hooks 34 | rev: v1.10.0 35 | hooks: 36 | - id: python-no-log-warn 37 | - id: python-no-eval 38 | - id: rst-backticks 39 | files: .*\.rst$ 40 | types: [file] 41 | 42 | - repo: https://github.com/codespell-project/codespell 43 | rev: v2.4.1 44 | hooks: 45 | - id: codespell 46 | 47 | ci: 48 | autofix_prs: false 49 | autoupdate_commit_msg: 'pre-commit autoupdate' 50 | autoupdate_schedule: monthly 51 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include tests/*.py 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | scripttest 2 | ========== 3 | 4 | .. image:: https://img.shields.io/pypi/v/scripttest.svg 5 | :target: https://pypi.python.org/pypi/scripttest/ 6 | :alt: Latest Version 7 | 8 | .. image:: https://github.com/pypa/scripttest/actions/workflows/ci.yaml/badge.svg 9 | :target: https://github.com/pypa/scripttest/actions/workflows/ci.yaml 10 | :alt: Build Status 11 | 12 | scripttest is a library to help you test your interactive command-line 13 | applications. 14 | 15 | With it you can easily run the command (in a subprocess) and see the 16 | output (stdout, stderr) and any file modifications. 17 | 18 | * The `source repository `_. 19 | * The `documentation `_. 20 | -------------------------------------------------------------------------------- /docs/_static/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/scripttest/481f7dfa0c4515820c28379fef67a88953ceb3e9/docs/_static/.empty -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Cryptography documentation build configuration file, created by 2 | # sphinx-quickstart on Tue Aug 6 19:19:14 2013. 3 | # 4 | # This file is execfile()d with the current directory set to its containing dir 5 | # 6 | # Note that not all possible configuration values are present in this 7 | # autogenerated file. 8 | # 9 | # All configuration values have a default; values that are commented out 10 | # serve to show the default. 11 | 12 | import os 13 | import sys 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # sys.path.insert(0, os.path.abspath('.')) 19 | sys.path.insert(0, os.path.abspath(os.pardir)) 20 | 21 | # -- General configuration ---------------------------------------------------- 22 | 23 | 24 | on_rtd = os.environ.get("READTHEDOCS", None) == "True" 25 | if on_rtd: 26 | import sphinx_rtd_theme 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 33 | extensions = [ 34 | "sphinx.ext.autodoc", 35 | "sphinx.ext.doctest", 36 | "sphinx.ext.intersphinx", 37 | "sphinx.ext.viewcode", 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ["_templates"] 42 | 43 | # The suffix of source filenames. 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 = "ScriptTest" 54 | copyright = "2013, Individual Contributors" 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | # The short X.Y version. 61 | version = "3.0" 62 | # The full version, including alpha/beta/rc tags. 63 | release = "3.0.dev1" 64 | 65 | # The language for content autogenerated by Sphinx. Refer to documentation 66 | # for a list of supported languages. 67 | # language = None 68 | 69 | # There are two options for replacing |today|: either, you set today to some 70 | # non-false value, then it is used: 71 | # today = '' 72 | # Else, today_fmt is used as the format for a strftime call. 73 | # today_fmt = '%B %d, %Y' 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | exclude_patterns = ["_build"] 78 | 79 | # The reST default role (used for this markup: `text`) to use for all documents 80 | # default_role = None 81 | 82 | # If true, '()' will be appended to :func: etc. cross-reference text. 83 | # add_function_parentheses = True 84 | 85 | # If true, the current module name will be prepended to all description 86 | # unit titles (such as .. function::). 87 | # add_module_names = True 88 | 89 | # If true, sectionauthor and moduleauthor directives will be shown in the 90 | # output. They are ignored by default. 91 | # show_authors = False 92 | 93 | # The name of the Pygments (syntax highlighting) style to use. 94 | pygments_style = "sphinx" 95 | 96 | # A list of ignored prefixes for module index sorting. 97 | # modindex_common_prefix = [] 98 | 99 | 100 | # -- Options for HTML output -------------------------------------------------- 101 | 102 | # The theme to use for HTML and HTML Help pages. See the documentation for 103 | # a list of builtin themes. 104 | html_theme = "alabaster" 105 | if on_rtd: 106 | html_theme = "sphinx_rtd_theme" 107 | 108 | # Theme options are theme-specific and customize the look and feel of a theme 109 | # further. For a list of options available for each theme, see the 110 | # documentation. 111 | # html_theme_options = {} 112 | 113 | # Add any paths that contain custom themes here, relative to this directory. 114 | # html_theme_path = [] 115 | if on_rtd: 116 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 117 | 118 | # The name for this set of Sphinx documents. If None, it defaults to 119 | # " v documentation". 120 | # html_title = None 121 | 122 | # A shorter title for the navigation bar. Default is the same as html_title. 123 | # html_short_title = None 124 | 125 | # The name of an image file (relative to this directory) to place at the top 126 | # of the sidebar. 127 | # html_logo = None 128 | 129 | # The name of an image file (within the static path) to use as favicon of the 130 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 131 | # pixels large. 132 | # html_favicon = None 133 | 134 | # Add any paths that contain custom static files (such as style sheets) here, 135 | # relative to this directory. They are copied after the builtin static files, 136 | # so a file named "default.css" will overwrite the builtin "default.css". 137 | html_static_path = ["_static"] 138 | 139 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 140 | # using the given strftime format. 141 | # html_last_updated_fmt = '%b %d, %Y' 142 | 143 | # If true, SmartyPants will be used to convert quotes and dashes to 144 | # typographically correct entities. 145 | # html_use_smartypants = True 146 | 147 | # Custom sidebar templates, maps document names to template names. 148 | # html_sidebars = {} 149 | 150 | # Additional templates that should be rendered to pages, maps page names to 151 | # template names. 152 | # html_additional_pages = {} 153 | 154 | # If false, no module index is generated. 155 | # html_domain_indices = True 156 | 157 | # If false, no index is generated. 158 | # html_use_index = True 159 | 160 | # If true, the index is split into individual pages for each letter. 161 | # html_split_index = False 162 | 163 | # If true, links to the reST sources are added to the pages. 164 | # html_show_sourcelink = True 165 | 166 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 167 | # html_show_sphinx = True 168 | 169 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 170 | # html_show_copyright = True 171 | 172 | # If true, an OpenSearch description file will be output, and all pages will 173 | # contain a tag referring to it. The value of this option must be the 174 | # base URL from which the finished HTML is served. 175 | # html_use_opensearch = '' 176 | 177 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 178 | # html_file_suffix = None 179 | 180 | # Output file base name for HTML help builder. 181 | htmlhelp_basename = "ScriptTest doc" 182 | 183 | 184 | # -- Options for LaTeX output ------------------------------------------------- 185 | 186 | latex_elements = { 187 | # The paper size ('letterpaper' or 'a4paper'). 188 | #'papersize': 'letterpaper', 189 | # The font size ('10pt', '11pt' or '12pt'). 190 | #'pointsize': '10pt', 191 | # Additional stuff for the LaTeX preamble. 192 | #'preamble': '', 193 | } 194 | 195 | # Grouping the document tree into LaTeX files. List of tuples 196 | # (source start file, target name, title, author, documentclass [howto/manual]) 197 | latex_documents = [ 198 | ( 199 | "index", 200 | "ScriptTest.tex", 201 | "ScriptTest Documentation", 202 | "Individual Contributors", 203 | "manual", 204 | ), 205 | ] 206 | 207 | # The name of an image file (relative to this directory) to place at the top of 208 | # the title page. 209 | # latex_logo = None 210 | 211 | # For "manual" documents, if this is true, then toplevel headings are parts, 212 | # not chapters. 213 | # latex_use_parts = False 214 | 215 | # If true, show page references after internal links. 216 | # latex_show_pagerefs = False 217 | 218 | # If true, show URL addresses after external links. 219 | # latex_show_urls = False 220 | 221 | # Documents to append as an appendix to all manuals. 222 | # latex_appendices = [] 223 | 224 | # If false, no module index is generated. 225 | # latex_domain_indices = True 226 | 227 | 228 | # -- Options for manual page output ------------------------------------------- 229 | 230 | # One entry per manual page. List of tuples 231 | # (source start file, name, description, authors, manual section). 232 | man_pages = [ 233 | ( 234 | "index", 235 | "scripttest", 236 | "ScriptTest Documentation", 237 | ["Individual Contributors"], 238 | 1, 239 | ) 240 | ] 241 | 242 | # If true, show URL addresses after external links. 243 | # man_show_urls = False 244 | 245 | 246 | # -- Options for Texinfo output ----------------------------------------------- 247 | 248 | # Grouping the document tree into Texinfo files. List of tuples 249 | # (source start file, target name, title, author, 250 | # dir menu entry, description, category) 251 | texinfo_documents = [ 252 | ( 253 | "index", 254 | "ScriptTest", 255 | "ScriptTest Documentation", 256 | "Individual Contributors", 257 | "ScriptTest", 258 | "Utilities to help with testing command line scripts", 259 | "Miscellaneous", 260 | ), 261 | ] 262 | 263 | # Documents to append as an appendix to all manuals. 264 | # texinfo_appendices = [] 265 | 266 | # If false, no module index is generated. 267 | # texinfo_domain_indices = True 268 | 269 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 270 | # texinfo_show_urls = 'footnote' 271 | 272 | 273 | # Example configuration for intersphinx: refer to the Python standard library. 274 | intersphinx_mapping = {"python": ("http://docs.python.org/", None)} 275 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ScriptTest 2 | ========== 3 | 4 | .. toctree:: 5 | 6 | modules/scripttest 7 | license 8 | news 9 | 10 | .. contents:: 11 | 12 | Status & License 13 | ---------------- 14 | 15 | ScriptTest is an extraction of ``paste.fixture.TestFileEnvironment`` 16 | from the 17 | `Paste `_ 18 | project. It was originally 19 | written to test 20 | `Paste Script `_. 21 | 22 | It is licensed under an `MIT-style permissive license 23 | `_. 24 | 25 | Bugs should go in the `Github issue list 26 | `_. 27 | 28 | It is available on `pypi `_ 29 | or in a `git repository `_. 30 | You can get a checkout with:: 31 | 32 | $ git clone https://github.com/pypa/scripttest.git 33 | 34 | Purpose & Introduction 35 | ---------------------- 36 | 37 | This library helps you test command-line scripts. It runs a script 38 | and watches the output, looks for non-zero exit codes, output on 39 | stderr, and any files created, deleted, or modified. 40 | 41 | To start you instantiate ``TestFileEnvironment``, which is the context 42 | in which all your scripts are run. You give it a base directory 43 | (typically a scratch directory), or if you don't it will guess 44 | ``call_module_dir/test-output/``. Example:: 45 | 46 | >>> from scripttest import TestFileEnvironment 47 | >>> env = TestFileEnvironment('./test-output') 48 | 49 | .. note:: 50 | 51 | Everything in ``./test-output`` will be deleted every test run. To 52 | make sure you don't point at an important directory, the scratch 53 | directory must be created by ScriptTest (a hidden file is written 54 | by ScriptTest to confirm that it created the directory). If the 55 | directory already exists, you must delete it manually. 56 | 57 | Then you run scripts with ``env.run(script, arg1, arg2, ...)``:: 58 | 59 | >>> print(env.run('echo', 'hey')) 60 | Script result: echo hey 61 | -- stdout: -------------------- 62 | hey 63 | 64 | 65 | There's several keyword arguments you can use with ``env.run()``: 66 | 67 | ``expect_error``: (default False) 68 | Don't raise an exception in case of errors 69 | ``expect_stderr``: (default ``expect_error``) 70 | Don't raise an exception if anything is printed to stderr 71 | ``stdin``: (default ``""``) 72 | Input to the script 73 | ``cwd``: (default ``self.cwd``) 74 | The working directory to run in (default ``base_dir``) 75 | 76 | As you can see from the options, if the script indicates anything 77 | error-like it is, by default, turned into an exception. This of 78 | course includes a non-zero response code. Also any output on stderr 79 | also counts as an error (unless turned off with 80 | ``expect_stderr=True``). 81 | 82 | The object you get back from a run represents what happened during the 83 | script. It has a useful ``str()`` (as you can see in the previous 84 | example) that shows a summary and can be useful in a doctest. It also 85 | has several useful attributes: 86 | 87 | ``stdout``, ``stderr``: 88 | What is produced on those streams 89 | 90 | ``returncode``: 91 | The return code of the script. 92 | 93 | ``files_created``, ``files_deleted``, ``files_updated``: 94 | Dictionaries mapping filenames (relative to the ``base_dir``) 95 | to `FoundFile `_ or 96 | `FoundDir `_ objects. 97 | 98 | Of course by default ``stderr`` must be empty, and ``returncode`` must 99 | be zero, since anything else would be considered an error. 100 | 101 | Of particular interest are the dictionaries ``files_created``, etc. 102 | These show just what files were handled by the script. Each 103 | dictionary points to another helper object for inspecting the files 104 | (``.files_deleted`` contains the files as they existed *before* the 105 | script ran). 106 | 107 | Each file or directory object has useful attributes: 108 | 109 | ``path``: 110 | The path of the file, relative to the ``base_path`` 111 | 112 | ``full``: 113 | The full path 114 | 115 | ``stat``: 116 | The results of ``os.stat``. Also ``mtime`` and ``size`` 117 | contain the ``.st_mtime`` and ``st_size`` of the stat. 118 | (Directories have no ``size``) 119 | 120 | ``bytes``: 121 | The contents of the file (does not apply to directories). 122 | 123 | ``file``, ``dir``: 124 | ``file`` is true for files, ``dir`` is true for directories. 125 | 126 | You may use the ``in`` operator with the file objects (tested against 127 | the contents of the file), and the ``.mustcontain()`` method, where 128 | ``file.mustcontain('a', 'b')`` means ``assert 'a' in file; assert 'b' 129 | in file``. 130 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | Copyright (c) 2007 Ian Bicking and Contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining 7 | a copy of this software and associated documentation files (the 8 | "Software"), to deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, merge, publish, 10 | distribute, sublicense, and/or sell copies of the Software, and to 11 | permit persons to whom the Software is furnished to do so, subject to 12 | the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 21 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 22 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 23 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /docs/modules/scripttest.rst: -------------------------------------------------------------------------------- 1 | :mod:`scripttest` -- test command-line scripts 2 | ============================================== 3 | 4 | .. automodule:: scripttest 5 | 6 | Module Contents 7 | --------------- 8 | 9 | .. autoclass:: TestFileEnvironment 10 | :special-members: __init__ 11 | :members: 12 | 13 | Objects that are returned 14 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 15 | 16 | These objects are returned when you use ``env.run(...)``. The 17 | ``ProcResult`` object is returned, and it has ``.files_updated``, 18 | ``.files_created``, and ``.files_deleted`` which are dictionaries of 19 | ``FoundFile`` and ``FoundDir``. The files in ``.files_deleted`` represent 20 | the pre-deletion state of the file; the other files represent the 21 | state of the files after the command is run. 22 | 23 | and ``.files_deleted``. These objects dictionary 24 | 25 | .. autoclass:: ProcResult 26 | 27 | .. autoclass:: FoundFile 28 | 29 | .. autoclass:: FoundDir 30 | -------------------------------------------------------------------------------- /docs/news.rst: -------------------------------------------------------------------------------- 1 | News 2 | ==== 3 | 4 | 2.0 5 | --- 6 | 7 | **March 8, 2025**. 8 | 9 | * Python 3.6 or higher is now required 10 | * The temporary file discovery logic was rewritten, thus it will flag dangling 11 | temporary files more reliably 12 | * Various metadata updates to reflect that scripttest is now a PyPA project 13 | hosted on GitHub and ReadTheDocs 14 | * Don't attempt to hash a named pipe to avoid indefinite hanging 15 | * Include tests in source distributions 16 | 17 | 18 | 1.3 19 | --- 20 | 21 | * Use CRC32 to protect against a race condition where if a run took less than 22 | 1 second updates files would not appear to be updated. 23 | 24 | 25 | 1.2 26 | --- 27 | 28 | * Python 3 support (thanks Marc Abramowitz!) 29 | 30 | 1.1.1 31 | ----- 32 | 33 | * Python 3 fixes 34 | 35 | 1.1 36 | --- 37 | 38 | * Python 3 compatibility, from Hugo Tavares 39 | * More Windows fixes, from Hugo Tavares 40 | 41 | 1.0.4 42 | ----- 43 | 44 | * Windows fixes (thanks Dave Abrahams); including an option for more careful 45 | string splitting (useful when testing a script with a space in the path), 46 | and more careful handling of environmental variables. 47 | 48 | 1.0.3 49 | ----- 50 | 51 | * Added a ``capture_temp`` argument to 52 | :class:`scripttest.TestFileEnvironment` and ``env.assert_no_temp()`` 53 | to test that no temporary files are left over. 54 | 55 | 1.0.2 56 | ----- 57 | 58 | * Fixed regression with ``FoundDir.invalid`` 59 | 60 | 1.0.1 61 | ----- 62 | 63 | * Windows fix for cleaning up scratch files more reliably 64 | 65 | * Allow spaces in the ``script`` name, e.g., ``C:/program 66 | files/some-script`` (but you must use multiple arguments to 67 | ``env.run(script, more_args)``). 68 | 69 | * Remove the resolution of scripts to an absolute path (just allow the 70 | OS to do this). 71 | 72 | * Don't fail if there is an invalid symlink 73 | 74 | 1.0 75 | --- 76 | 77 | * ``env.run()`` now takes a keyword argument ``quiet``. If quiet is 78 | false, then if there is any error (return code != 0, or stderr 79 | output) the complete output of the script will be printed. 80 | 81 | * ScriptTest puts a marker file in scratch directories it deletes, so 82 | that if you point it at a directory not created by ScriptTest it 83 | will raise an error. Without this, unwitting developers could point 84 | ScriptTest at the project directory, which would cause the entire 85 | project directory to be wiped. 86 | 87 | * ProcResults now no longer print the absolute path of the script 88 | (which is often system dependent, and so not good for doctests). 89 | 90 | * Added :func:`scripttest.ProcResults.wildcard_matches` which returns file 91 | objects based on a wildcard expression. 92 | 93 | 0.9 94 | --- 95 | 96 | Initial release 97 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "scripttest" 3 | version = "3.0.dev1" 4 | description = "Helper to test command-line scripts" 5 | readme = "README.rst" 6 | license = { text = "MIT" } 7 | 8 | authors = [ 9 | {name = "Ian Bicking", email = "ianb@colorstudy.com" } 10 | ] 11 | maintainers = [ 12 | {name = "The pip developers", email = "distutils-sig@python.org"}, 13 | ] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: MIT License", 18 | "Topic :: Software Development :: Testing", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3 :: Only", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Programming Language :: Python :: Implementation :: CPython", 29 | "Programming Language :: Python :: Implementation :: PyPy", 30 | ] 31 | keywords = ["test", "unittest", "doctest", "command-line scripts"] 32 | 33 | requires-python = ">=3.8" 34 | 35 | [project.urls] 36 | Source = "https://github.com/pypa/scripttest/" 37 | Documentation = "https://scripttest.readthedocs.io/en/stable/" 38 | 39 | [build-system] 40 | requires = ["setuptools >= 64.0"] 41 | build-backend = "setuptools.build_meta" 42 | 43 | [tool.pytest.ini_options] 44 | addopts = "--ignore scripttest -r aR --color=yes" 45 | xfail_strict = true 46 | filterwarnings = [ 47 | "ignore:cannot collect test class 'TestFileEnvironment'" 48 | ] 49 | -------------------------------------------------------------------------------- /scripttest/__init__.py: -------------------------------------------------------------------------------- 1 | # (c) 2005-2007 Ian Bicking and contributors; written for Paste 2 | # Licensed under the MIT license: 3 | # http://www.opensource.org/licenses/mit-license.php 4 | """ 5 | Helpers for testing command-line scripts 6 | """ 7 | import sys 8 | import os 9 | import stat 10 | import shutil 11 | import shlex 12 | import subprocess 13 | import re 14 | import zlib 15 | from types import TracebackType 16 | from typing import ( 17 | Any, 18 | Callable, 19 | Dict, 20 | Iterable, 21 | List, 22 | Optional, 23 | Tuple, 24 | Type, 25 | Union, 26 | ) 27 | 28 | _ExcInfo = Tuple[Type[BaseException], BaseException, TracebackType] 29 | 30 | 31 | if sys.platform == "win32": 32 | 33 | def clean_environ(e: Dict[str, str]) -> Dict[str, str]: 34 | ret = {str(k): str(v) for k, v in e.items()} 35 | return ret 36 | 37 | else: 38 | 39 | def clean_environ(e: Dict[str, str]) -> Dict[str, str]: 40 | return e 41 | 42 | 43 | def string(string: Union[bytes, str]) -> str: 44 | if isinstance(string, str): 45 | return string 46 | return str(string, "utf-8") 47 | 48 | 49 | # From pathutils by Michael Foord: 50 | # http://www.voidspace.org.uk/python/pathutils.html 51 | def onerror(func: Callable[..., Any], path: str, exc_info: _ExcInfo) -> None: 52 | """ 53 | Error handler for ``shutil.rmtree``. 54 | 55 | If the error is due to an access error (read only file) 56 | it attempts to add write permission and then retries. 57 | 58 | If the error is for another reason it re-raises the error. 59 | 60 | Usage : ``shutil.rmtree(path, onerror=onerror)`` 61 | 62 | """ 63 | if not os.access(path, os.W_OK): 64 | # Is the error an access error ? 65 | os.chmod(path, stat.S_IWUSR) 66 | func(path) 67 | else: 68 | raise 69 | 70 | 71 | __all__ = ["TestFileEnvironment"] 72 | 73 | 74 | class TestFileEnvironment: 75 | """ 76 | This represents an environment in which files will be written, and 77 | scripts will be run. 78 | """ 79 | 80 | # for py.test 81 | disabled = True 82 | 83 | def __init__( 84 | self, 85 | base_path: Optional[str] = None, 86 | template_path: Optional[str] = None, 87 | environ: Optional[Dict[str, str]] = None, 88 | cwd: Optional[str] = None, 89 | start_clear: bool = True, 90 | ignore_paths: Optional[Iterable[str]] = None, 91 | ignore_hidden: bool = True, 92 | ignore_temp_paths: Optional[Iterable[str]] = None, 93 | capture_temp: bool = False, 94 | assert_no_temp: bool = False, 95 | split_cmd: bool = True, 96 | ) -> None: 97 | """ 98 | Creates an environment. ``base_path`` is used as the current 99 | working directory, and generally where changes are looked for. 100 | If not given, it will be the directory of the calling script plus 101 | ``test-output/``. 102 | 103 | ``template_path`` is the directory to look for *template* 104 | files, which are files you'll explicitly add to the 105 | environment. This is done with ``.writefile()``. 106 | 107 | ``environ`` is the operating system environment, 108 | ``os.environ`` if not given. 109 | 110 | ``cwd`` is the working directory, ``base_path`` by default. 111 | 112 | If ``start_clear`` is true (default) then the ``base_path`` 113 | will be cleared (all files deleted) when an instance is 114 | created. You can also use ``.clear()`` to clear the files. 115 | 116 | ``ignore_paths`` is a set of specific filenames that should be 117 | ignored when created in the environment. ``ignore_hidden`` 118 | means, if true (default) that filenames and directories 119 | starting with ``'.'`` will be ignored. 120 | 121 | ``capture_temp`` will put temporary files inside the 122 | environment (using ``$TMPDIR``). You can then assert that no 123 | temporary files are left using ``.assert_no_temp()``. 124 | """ 125 | if base_path is None: 126 | base_path = self._guess_base_path(1) 127 | self.base_path = base_path 128 | self.template_path = template_path 129 | if environ is None: 130 | environ = os.environ.copy() 131 | self.environ = environ 132 | if cwd is None: 133 | cwd = base_path 134 | self.cwd = cwd 135 | self.capture_temp = capture_temp 136 | self.temp_path: Optional[str] 137 | if self.capture_temp: 138 | self.temp_path = os.path.join(self.base_path, "tmp") 139 | self.environ["TMPDIR"] = self.temp_path 140 | else: 141 | self.temp_path = None 142 | if start_clear: 143 | self.clear() 144 | elif not os.path.exists(base_path): 145 | os.makedirs(base_path) 146 | self.ignore_paths = ignore_paths or [] 147 | self.ignore_temp_paths = ignore_temp_paths or [] 148 | self.ignore_hidden = ignore_hidden 149 | self.split_cmd = split_cmd 150 | 151 | if assert_no_temp and not self.capture_temp: 152 | raise TypeError("You cannot use assert_no_temp unless capture_temp=True") 153 | self._assert_no_temp = assert_no_temp 154 | 155 | self.split_cmd = split_cmd 156 | 157 | def _guess_base_path(self, stack_level: int) -> str: 158 | frame = sys._getframe(stack_level + 1) 159 | file = frame.f_globals.get("__file__") 160 | if not file: 161 | raise TypeError( 162 | "Could not guess a base_path argument from the calling scope " 163 | "(no __file__ found)" 164 | ) 165 | dir = os.path.dirname(file) 166 | return os.path.join(dir, "test-output") 167 | 168 | def run(self, script: str, *args: Any, **kw: Any) -> "ProcResult": 169 | """ 170 | Run the command, with the given arguments. The ``script`` 171 | argument can have space-separated arguments, or you can use 172 | the positional arguments. 173 | 174 | Keywords allowed are: 175 | 176 | ``expect_error``: (default False) 177 | Don't raise an exception in case of errors 178 | ``expect_stderr``: (default ``expect_error``) 179 | Don't raise an exception if anything is printed to stderr 180 | ``stdin``: (default ``""``) 181 | Input to the script 182 | ``cwd``: (default ``self.cwd``) 183 | The working directory to run in (default ``base_path``) 184 | ``quiet``: (default False) 185 | When there's an error (return code != 0), do not print 186 | stdout/stderr 187 | 188 | Returns a `ProcResult 189 | `_ object. 190 | """ 191 | __tracebackhide__ = True 192 | expect_error = kw.pop("expect_error", False) 193 | expect_stderr = kw.pop("expect_stderr", expect_error) 194 | cwd = kw.pop("cwd", self.cwd) 195 | stdin = kw.pop("stdin", None) 196 | quiet = kw.pop("quiet", False) 197 | debug = kw.pop("debug", False) 198 | if not self.temp_path: 199 | if "expect_temp" in kw: 200 | raise TypeError( 201 | "You cannot use expect_temp unless you use " "capture_temp=True" 202 | ) 203 | expect_temp = kw.pop("expect_temp", not self._assert_no_temp) 204 | script_args = list(map(str, args)) 205 | assert not kw, "Arguments not expected: %s" % ", ".join(kw.keys()) 206 | if self.split_cmd and " " in script: 207 | if script_args: 208 | # Then treat this as a script that has a space in it 209 | pass 210 | else: 211 | script, script_args_s = script.split(None, 1) 212 | script_args = shlex.split(script_args_s) 213 | 214 | all = [script] + script_args 215 | 216 | files_before = self._find_files() 217 | 218 | if debug: 219 | proc = subprocess.Popen( 220 | all, 221 | cwd=cwd, 222 | # see http://bugs.python.org/issue8557 223 | shell=(sys.platform == "win32"), 224 | env=clean_environ(self.environ), 225 | ) 226 | else: 227 | proc = subprocess.Popen( 228 | all, 229 | stdin=subprocess.PIPE, 230 | stderr=subprocess.PIPE, 231 | stdout=subprocess.PIPE, 232 | cwd=cwd, 233 | # see http://bugs.python.org/issue8557 234 | shell=(sys.platform == "win32"), 235 | env=clean_environ(self.environ), 236 | ) 237 | 238 | if debug: 239 | stdout_bytes, stderr_bytes = proc.communicate() 240 | else: 241 | stdout_bytes, stderr_bytes = proc.communicate(stdin) 242 | stdout = string(stdout_bytes) 243 | stderr = string(stderr_bytes) 244 | 245 | stdout = string(stdout).replace("\r\n", "\n") 246 | stderr = string(stderr).replace("\r\n", "\n") 247 | files_after = self._find_files() 248 | result = ProcResult( 249 | self, 250 | all, 251 | stdin, 252 | stdout, 253 | stderr, 254 | returncode=proc.returncode, 255 | files_before=files_before, 256 | files_after=files_after, 257 | ) 258 | if not expect_error: 259 | result.assert_no_error(quiet) 260 | if not expect_stderr: 261 | result.assert_no_stderr(quiet) 262 | if not expect_temp: 263 | self.assert_no_temp() 264 | return result 265 | 266 | def _find_files(self) -> Dict[str, Union["FoundDir", "FoundFile"]]: 267 | result: Dict[str, Union["FoundDir", "FoundFile"]] = {} 268 | for fn in os.listdir(self.base_path): 269 | if self._ignore_file(fn): 270 | continue 271 | self._find_traverse(fn, result) 272 | return result 273 | 274 | def _ignore_file(self, fn: str) -> bool: 275 | if fn in self.ignore_paths: 276 | return True 277 | if self.ignore_hidden and os.path.basename(fn).startswith("."): 278 | return True 279 | return False 280 | 281 | def _find_traverse( 282 | self, 283 | path: str, 284 | result: Dict[str, Union["FoundDir", "FoundFile"]], 285 | ) -> None: 286 | full = os.path.join(self.base_path, path) 287 | if os.path.isdir(full): 288 | if not self.temp_path or path != "tmp": 289 | result[path] = FoundDir(self.base_path, path) 290 | for fn in os.listdir(full): 291 | fn = os.path.join(path, fn) 292 | if self._ignore_file(fn): 293 | continue 294 | self._find_traverse(fn, result) 295 | else: 296 | result[path] = FoundFile(self.base_path, path) 297 | 298 | def clear(self, force: bool = False) -> None: 299 | """ 300 | Delete all the files in the base directory. 301 | """ 302 | marker_file = os.path.join(self.base_path, ".scripttest-test-dir.txt") 303 | if os.path.exists(self.base_path): 304 | if not force and not os.path.exists(marker_file): 305 | sys.stderr.write( 306 | "The directory %s does not appear to have been created by " 307 | "ScriptTest\n" % self.base_path 308 | ) 309 | sys.stderr.write( 310 | "The directory %s must be a scratch directory; it will be " 311 | "wiped after every test run\n" % self.base_path 312 | ) 313 | sys.stderr.write("Please delete this directory manually\n") 314 | raise AssertionError( 315 | "The directory %s was not created by ScriptTest; it must " 316 | "be deleted manually" % self.base_path 317 | ) 318 | shutil.rmtree(self.base_path, onerror=onerror) 319 | os.mkdir(self.base_path) 320 | f = open(marker_file, "w") 321 | f.write("placeholder") 322 | f.close() 323 | if self.temp_path and not os.path.exists(self.temp_path): 324 | os.makedirs(self.temp_path) 325 | 326 | def writefile( 327 | self, 328 | path: str, 329 | content: Optional[bytes] = None, 330 | frompath: Optional[str] = None, 331 | ) -> "FoundFile": 332 | """ 333 | Write a file to the given path. If ``content`` is given then 334 | that text is written, otherwise the file in ``frompath`` is 335 | used. ``frompath`` is relative to ``self.template_path`` 336 | """ 337 | full = os.path.join(self.base_path, path) 338 | if not os.path.exists(os.path.dirname(full)): 339 | os.makedirs(os.path.dirname(full)) 340 | f = open(full, "wb") 341 | if content is not None: 342 | f.write(content) 343 | if frompath is not None: 344 | if self.template_path: 345 | frompath = os.path.join(self.template_path, frompath) 346 | f2 = open(frompath, "rb") 347 | f.write(f2.read()) 348 | f2.close() 349 | f.close() 350 | return FoundFile(self.base_path, path) 351 | 352 | def assert_no_temp(self) -> None: 353 | """If you use ``capture_temp`` then you can use this to make 354 | sure no files have been left in the temporary directory""" 355 | __tracebackhide__ = True 356 | if not self.temp_path: 357 | raise Exception( 358 | "You cannot use assert_no_error unless you " 359 | "instantiate " 360 | "TestFileEnvironment(capture_temp=True)" 361 | ) 362 | names = os.listdir(self.temp_path) 363 | if not names: 364 | return 365 | new_names = [] 366 | for name in names: 367 | if name in self.ignore_temp_paths: 368 | continue 369 | if os.path.isdir(os.path.join(self.temp_path, name)): 370 | name += "/" 371 | new_names.append(name) 372 | raise AssertionError("Temporary files left over: %s" % ", ".join(sorted(names))) 373 | 374 | 375 | class ProcResult: 376 | """ 377 | Represents the results of running a command in 378 | `TestFileEnvironment 379 | `_. 380 | 381 | Attributes to pay particular attention to: 382 | 383 | ``stdout``, ``stderr``: 384 | What is produced on those streams. 385 | 386 | ``returncode``: 387 | The return code of the script. 388 | 389 | ``files_created``, ``files_deleted``, ``files_updated``: 390 | Dictionaries mapping filenames (relative to the ``base_path``) 391 | to `FoundFile `_ or 392 | `FoundDir `_ objects. 393 | """ 394 | 395 | def __init__( 396 | self, 397 | test_env: TestFileEnvironment, 398 | args: List[str], 399 | stdin: bytes, 400 | stdout: str, 401 | stderr: str, 402 | returncode: int, 403 | files_before: Dict[str, Union["FoundDir", "FoundFile"]], 404 | files_after: Dict[str, Union["FoundDir", "FoundFile"]], 405 | ) -> None: 406 | self.test_env = test_env 407 | self.args = args 408 | self.stdin = stdin 409 | self.stdout = stdout 410 | self.stderr = stderr 411 | self.returncode = returncode 412 | self.files_before = files_before 413 | self.files_after = files_after 414 | self.files_deleted = {} 415 | self.files_updated = {} 416 | self.files_created = files_after.copy() 417 | for path, f in files_before.items(): 418 | if path not in files_after: 419 | self.files_deleted[path] = f 420 | continue 421 | del self.files_created[path] 422 | if f != files_after[path]: 423 | self.files_updated[path] = files_after[path] 424 | if sys.platform == "win32": 425 | self.stdout = self.stdout.replace("\n\r", "\n") 426 | self.stderr = self.stderr.replace("\n\r", "\n") 427 | 428 | def assert_no_error(self, quiet: bool) -> None: 429 | __tracebackhide__ = True 430 | if self.returncode != 0: 431 | if not quiet: 432 | print(self) 433 | raise AssertionError("Script returned code: %s" % self.returncode) 434 | 435 | def assert_no_stderr(self, quiet: bool) -> None: 436 | __tracebackhide__ = True 437 | if self.stderr: 438 | if not quiet: 439 | print(self) 440 | else: 441 | print("Error output:") 442 | print(self.stderr) 443 | raise AssertionError("stderr output not expected") 444 | 445 | def assert_no_temp(self, quiet: bool) -> None: 446 | __tracebackhide__ = True 447 | files = self.wildcard_matches("tmp/**") 448 | if files: 449 | if not quiet: 450 | print(self) 451 | else: 452 | print("Temp files:") 453 | print( 454 | ", ".join( 455 | sorted(f.path for f in sorted(files, key=lambda x: x.path)) 456 | ) 457 | ) 458 | raise AssertionError("temp files not expected") 459 | 460 | def wildcard_matches(self, wildcard: str) -> List[Union["FoundDir", "FoundFile"]]: 461 | """Return all the file objects whose path matches the given wildcard. 462 | 463 | You can use ``*`` to match any portion of a filename, and 464 | ``**`` to match multiple segments/directories. 465 | """ 466 | regex_parts = [] 467 | for index, part in enumerate(wildcard.split("**")): 468 | if index: 469 | regex_parts.append(".*") 470 | for internal_index, internal_part in enumerate(part.split("*")): 471 | if internal_index: 472 | regex_parts.append("[^/\\\\]*") 473 | regex_parts.append(re.escape(internal_part)) 474 | pattern = "".join(regex_parts) + "$" 475 | regex = re.compile(pattern) 476 | results = [] 477 | for container in self.files_updated, self.files_created: 478 | for key, value in sorted(container.items()): 479 | if regex.match(key): 480 | results.append(value) 481 | return results 482 | 483 | def __str__(self) -> str: 484 | s = ["Script result: %s" % " ".join(self.args)] 485 | if self.returncode: 486 | s.append(" return code: %s" % self.returncode) 487 | if self.stderr: 488 | s.append("-- stderr: --------------------") 489 | s.append(self.stderr) 490 | if self.stdout: 491 | s.append("-- stdout: --------------------") 492 | s.append(self.stdout) 493 | for name, files, show_size in [ 494 | ("created", self.files_created, True), 495 | ("deleted", self.files_deleted, True), 496 | ("updated", self.files_updated, True), 497 | ]: 498 | if files: 499 | s.append("-- %s: -------------------" % name) 500 | last = "" 501 | for path, f in sorted(files.items()): 502 | t = " %s" % _space_prefix(last, path, indent=4, include_sep=False) 503 | last = path 504 | if f.invalid: 505 | t += " (invalid link)" 506 | else: 507 | if show_size and f.size != "N/A": 508 | t += " (%s bytes)" % f.size 509 | s.append(t) 510 | return "\n".join(s) 511 | 512 | 513 | class FoundFile: 514 | """ 515 | Represents a single file found as the result of a command. 516 | 517 | Has attributes: 518 | 519 | ``path``: 520 | The path of the file, relative to the ``base_path`` 521 | 522 | ``full``: 523 | The full path 524 | 525 | ``bytes``: 526 | The contents of the file. 527 | 528 | ``stat``: 529 | The results of ``os.stat``. Also ``mtime`` and ``size`` 530 | contain the ``.st_mtime`` and ``.st_size`` of the stat. 531 | 532 | ``mtime``: 533 | The modification time of the file. 534 | 535 | ``size``: 536 | The size (in bytes) of the file. 537 | 538 | You may use the ``in`` operator with these objects (tested against 539 | the contents of the file), and the ``.mustcontain()`` method. 540 | """ 541 | 542 | file = True 543 | dir = False 544 | invalid = False 545 | 546 | def __init__(self, base_path: str, path: str) -> None: 547 | self.base_path = base_path 548 | self.path = path 549 | self.full = os.path.join(base_path, path) 550 | self.stat: Optional[os.stat_result] 551 | self.mtime: Optional[float] 552 | self.size: Union[int, str] 553 | if os.path.exists(self.full): 554 | self.stat = os.stat(self.full) 555 | self.mtime = self.stat.st_mtime 556 | self.size = self.stat.st_size 557 | if stat.S_ISFIFO(os.stat(self.full).st_mode): 558 | self.hash = None # it's a pipe 559 | else: 560 | with open(self.full, "rb") as fp: 561 | self.hash = zlib.crc32(fp.read()) 562 | else: 563 | self.invalid = True 564 | self.stat = self.mtime = None 565 | self.size = "N/A" 566 | self.hash = None 567 | self._bytes: Optional[str] = None 568 | 569 | def bytes__get(self) -> str: 570 | if self._bytes is None: 571 | f = open(self.full, "rb") 572 | self._bytes = string(f.read()) 573 | f.close() 574 | return self._bytes 575 | 576 | bytes = property(bytes__get) 577 | 578 | def __contains__(self, s: str) -> bool: 579 | return s in self.bytes 580 | 581 | def mustcontain(self, s: str) -> None: 582 | __tracebackhide__ = True 583 | bytes = self.bytes 584 | if s not in bytes: 585 | print("Could not find %r in:" % s) 586 | print(bytes) 587 | assert s in bytes 588 | 589 | def __repr__(self) -> str: 590 | return f"<{self.__class__.__name__} {self.base_path}:{self.path}>" 591 | 592 | def __eq__(self, other: object) -> bool: 593 | if not isinstance(other, FoundFile): 594 | return NotImplemented 595 | 596 | return ( 597 | self.hash == other.hash # noqa: W504 598 | and self.mtime == other.mtime # noqa: W504 599 | and self.size == other.size 600 | ) 601 | 602 | 603 | class FoundDir: 604 | """ 605 | Represents a directory created by a command. 606 | """ 607 | 608 | file = False 609 | dir = True 610 | invalid = False 611 | 612 | def __init__(self, base_path: str, path: str) -> None: 613 | self.base_path = base_path 614 | self.path = path 615 | self.full = os.path.join(base_path, path) 616 | self.stat = os.stat(self.full) 617 | self.size = "N/A" 618 | self.mtime = self.stat.st_mtime 619 | 620 | def __repr__(self) -> str: 621 | return f"<{self.__class__.__name__} {self.base_path}:{self.path}>" 622 | 623 | def __eq__(self, other: object) -> bool: 624 | if not isinstance(other, FoundDir): 625 | return NotImplemented 626 | 627 | return self.mtime == other.mtime 628 | 629 | 630 | def _space_prefix( 631 | pref: str, 632 | full: str, 633 | sep: Optional[str] = None, 634 | indent: Optional[int] = None, 635 | include_sep: bool = True, 636 | ) -> str: 637 | """ 638 | Anything shared by pref and full will be replaced with spaces 639 | in full, and full returned. 640 | """ 641 | if sep is None: 642 | sep = os.path.sep 643 | pref_parts = pref.split(sep) 644 | full_parts = full.split(sep) 645 | padding = [] 646 | while pref_parts and full_parts and pref_parts[0] == full_parts[0]: 647 | if indent is None: 648 | padding.append(" " * (len(full_parts[0]) + len(sep))) 649 | else: 650 | padding.append(" " * indent) 651 | full_parts.pop(0) 652 | pref_parts.pop(0) 653 | if padding: 654 | if include_sep: 655 | return "".join(padding) + sep + sep.join(full_parts) 656 | else: 657 | return "".join(padding) + sep.join(full_parts) 658 | else: 659 | return sep.join(full_parts) 660 | -------------------------------------------------------------------------------- /scripttest/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/scripttest/481f7dfa0c4515820c28379fef67a88953ceb3e9/scripttest/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/scripttest/481f7dfa0c4515820c28379fef67a88953ceb3e9/tests/__init__.py -------------------------------------------------------------------------------- /tests/a_script.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | def main(args): 5 | if args == ["error"]: 6 | sys.stderr.write("stderr output\n") 7 | return 8 | if args[:1] == ["exit"]: 9 | sys.exit(int(args[1])) 10 | if args == ["stdin"]: 11 | print(sys.stdin.read().upper()) 12 | return 13 | for arg in args: 14 | print("Writing %s" % arg) 15 | open(arg, "w").write("test") 16 | 17 | 18 | if __name__ == "__main__": 19 | main(sys.argv[1:]) 20 | -------------------------------------------------------------------------------- /tests/test_string.py: -------------------------------------------------------------------------------- 1 | from scripttest import string 2 | 3 | 4 | ascii_str = "".join( 5 | [ 6 | "This is just some plain ol' ASCII text with none of those ", 7 | 'funny "Unicode" characters.\n\nJust good ol\' ASCII text.', 8 | ] 9 | ) 10 | 11 | utf8_str = "Björk Guðmundsdóttir [ˈpjœr̥k ˈkvʏðmʏntsˌtoʊhtɪr]" 12 | 13 | 14 | def test_string_with_ascii_bytes(): 15 | ascii_bytes = ascii_str.encode("utf-8") 16 | assert isinstance(ascii_bytes, bytes) 17 | 18 | result = string(ascii_bytes) 19 | 20 | assert isinstance(result, str) 21 | assert result == ascii_bytes.decode("utf-8") 22 | 23 | 24 | def test_string_with_utf8_bytes(): 25 | utf8_bytes = utf8_str.encode("utf-8") 26 | 27 | assert isinstance(utf8_bytes, bytes) 28 | 29 | result = string(utf8_bytes) 30 | 31 | assert isinstance(result, str) 32 | assert result == utf8_bytes.decode("utf-8") 33 | 34 | 35 | def test_string_with_ascii_str(): 36 | assert isinstance(ascii_str, str) 37 | 38 | result = string(ascii_str) 39 | 40 | assert isinstance(result, str) 41 | assert result == ascii_str 42 | 43 | 44 | def test_string_with_utf8_str(): 45 | assert isinstance(utf8_str, str) 46 | 47 | result = string(utf8_str) 48 | 49 | assert isinstance(result, str) 50 | assert result == utf8_str 51 | -------------------------------------------------------------------------------- /tests/test_testscript.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | from scripttest import TestFileEnvironment 5 | 6 | here = os.path.dirname(__file__) 7 | script = os.path.join(here, "a_script.py") 8 | 9 | 10 | def test_testscript(tmpdir): 11 | env = TestFileEnvironment(str(tmpdir), start_clear=False) 12 | res = env.run(sys.executable, script, "test-file.txt") 13 | assert res.stdout == "Writing test-file.txt\n" 14 | assert not res.stderr 15 | assert "test-file.txt" in res.files_created 16 | assert not res.files_deleted 17 | assert not res.files_updated 18 | assert len(res.files_created) == 1 19 | f = res.files_created["test-file.txt"] 20 | assert f.path == "test-file.txt" 21 | assert f.full.endswith(os.path.join(str(tmpdir), "test-file.txt")) 22 | assert f.stat.st_size == f.size 23 | assert f.stat.st_mtime == f.mtime 24 | assert f.bytes == "test" 25 | assert "es" in f 26 | assert "foo" not in f 27 | f.mustcontain("test") 28 | try: 29 | f.mustcontain("foobar") 30 | except AssertionError: 31 | pass 32 | else: 33 | assert 0 34 | # because modification time is in seconds and the tests are too fast 35 | time.sleep(1) 36 | res = env.run(sys.executable, script, "test-file.txt") 37 | assert not res.files_created 38 | assert "test-file.txt" in res.files_updated, res.files_updated 39 | res = env.run(sys.executable, script, "error", expect_stderr=True) 40 | assert res.stderr == "stderr output\n" 41 | try: 42 | env.run(sys.executable, script, "error") 43 | except AssertionError: 44 | pass 45 | else: 46 | assert 0 47 | res = env.run(sys.executable, script, "exit", "10", expect_error=True) 48 | assert res.returncode == 10 49 | try: 50 | env.run(sys.executable, script, "exit", "10") 51 | except AssertionError: 52 | pass 53 | else: 54 | assert 0 55 | 56 | 57 | def test_bad_symlink(tmpdir): 58 | """ 59 | symlinks only work in UNIX 60 | """ 61 | if sys.platform == "win32": 62 | return 63 | env = TestFileEnvironment(str(tmpdir), start_clear=False) 64 | res = env.run( 65 | sys.executable, 66 | "-c", 67 | """\ 68 | import os 69 | os.symlink(os.path.join('does', 'not', 'exist.txt'), "does-not-exist.txt") 70 | """, 71 | ) 72 | assert "does-not-exist.txt" in res.files_created, res.files_created 73 | assert res.files_created["does-not-exist.txt"].invalid 74 | # Just make sure there's no error: 75 | str(res) 76 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 1.9 3 | envlist = py{38,39,310,311,312,313},pypy3,docs 4 | 5 | [testenv] 6 | deps = pytest 7 | commands = pytest tests 8 | 9 | [testenv:docs] 10 | deps = 11 | sphinx 12 | readme_renderer 13 | commands = 14 | sphinx-build -W -b html -d {envtmpdir}/doctrees docs docs/_build/html 15 | sphinx-build -W -b doctest -d {envtmpdir}/doctrees docs docs/_build/html 16 | 17 | [testenv:mypy] 18 | deps = mypy 19 | commands = mypy --strict scripttest 20 | skip_install = true 21 | --------------------------------------------------------------------------------