├── .codeclimate.yml ├── .coveragerc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── dev-requirements.txt ├── docs ├── api.rst ├── conf.py ├── getting_started.rst └── index.rst ├── examples ├── http.py ├── pyserial.py └── unicode.py ├── setup.cfg ├── setup.py ├── streamexpect.py ├── test ├── __init__.py └── test_streamexpect.py └── tox.ini /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | # 2 | # ---Choose Your Languages--- 3 | # To disable analysis for a certain language, set the language to `false`. 4 | # For help setting your languages: 5 | # http://docs.codeclimate.com/article/169-configuring-analysis-languages 6 | # 7 | languages: 8 | Ruby: false 9 | Javascript: false 10 | PHP: false 11 | Python: true 12 | 13 | # 14 | # ---Exclude Files or Directories--- 15 | # List the files or directories you would like excluded from analysis. 16 | # For help setting your exclude paths: 17 | # http://docs.codeclimate.com/article/166-excluding-files-folders 18 | # 19 | exclude_paths: 20 | - test/** 21 | - examples/** 22 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | */python?.?/* 4 | */site-packages/nose/* 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/master/Python.gitignore 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | 63 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | # Version of Python used to run tox 4 | python: 3.5 5 | 6 | # tox environments to run 7 | env: 8 | - TOX_ENV=py27 9 | - TOX_ENV=py33 10 | - TOX_ENV=py34 11 | - TOX_ENV=py35 12 | - TOX_ENV=py37 13 | - TOX_ENV=py39 14 | - TOX_ENV=py310 15 | - TOX_ENV=pypy 16 | - TOX_ENV=pypy3 17 | - TOX_ENV=coverage 18 | - TOX_ENV=docs 19 | - TOX_ENV=style 20 | 21 | # Prep and run build 22 | install: 23 | - pip install tox 24 | 25 | script: 26 | - tox -e $TOX_ENV 27 | 28 | # Allow Travis to use container-based infrastructure 29 | sudo: false 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [Unreleased] 4 | 5 | 6 | ## [0.2.0] - 2015-12-16 7 | - Initial public release of the library 8 | 9 | [Unreleased]: https://github.com/digidotcom/python-streamexpect/compare/v0.2.0...HEAD 10 | [0.2.0]: https://github.com/digidotcom/python-streamexpect/commits/v0.2.0 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Mozilla Public License, version 2.0 2 | 3 | 1. Definitions 4 | 5 | 1.1. "Contributor" 6 | 7 | means each individual or legal entity that creates, contributes to the 8 | creation of, or owns Covered Software. 9 | 10 | 1.2. "Contributor Version" 11 | 12 | means the combination of the Contributions of others (if any) used by a 13 | Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | 17 | means Covered Software of a particular Contributor. 18 | 19 | 1.4. "Covered Software" 20 | 21 | means Source Code Form to which the initial Contributor has attached the 22 | notice in Exhibit A, the Executable Form of such Source Code Form, and 23 | Modifications of such Source Code Form, in each case including portions 24 | thereof. 25 | 26 | 1.5. "Incompatible With Secondary Licenses" 27 | means 28 | 29 | a. that the initial Contributor has attached the notice described in 30 | Exhibit B to the Covered Software; or 31 | 32 | b. that the Covered Software was made available under the terms of 33 | version 1.1 or earlier of the License, but not also under the terms of 34 | a Secondary License. 35 | 36 | 1.6. "Executable Form" 37 | 38 | means any form of the work other than Source Code Form. 39 | 40 | 1.7. "Larger Work" 41 | 42 | means a work that combines Covered Software with other material, in a 43 | separate file or files, that is not Covered Software. 44 | 45 | 1.8. "License" 46 | 47 | means this document. 48 | 49 | 1.9. "Licensable" 50 | 51 | means having the right to grant, to the maximum extent possible, whether 52 | at the time of the initial grant or subsequently, any and all of the 53 | rights conveyed by this License. 54 | 55 | 1.10. "Modifications" 56 | 57 | means any of the following: 58 | 59 | a. any file in Source Code Form that results from an addition to, 60 | deletion from, or modification of the contents of Covered Software; or 61 | 62 | b. any new file in Source Code Form that contains any Covered Software. 63 | 64 | 1.11. "Patent Claims" of a Contributor 65 | 66 | means any patent claim(s), including without limitation, method, 67 | process, and apparatus claims, in any patent Licensable by such 68 | Contributor that would be infringed, but for the grant of the License, 69 | by the making, using, selling, offering for sale, having made, import, 70 | or transfer of either its Contributions or its Contributor Version. 71 | 72 | 1.12. "Secondary License" 73 | 74 | means either the GNU General Public License, Version 2.0, the GNU Lesser 75 | General Public License, Version 2.1, the GNU Affero General Public 76 | License, Version 3.0, or any later versions of those licenses. 77 | 78 | 1.13. "Source Code Form" 79 | 80 | means the form of the work preferred for making modifications. 81 | 82 | 1.14. "You" (or "Your") 83 | 84 | means an individual or a legal entity exercising rights under this 85 | License. For legal entities, "You" includes any entity that controls, is 86 | controlled by, or is under common control with You. For purposes of this 87 | definition, "control" means (a) the power, direct or indirect, to cause 88 | the direction or management of such entity, whether by contract or 89 | otherwise, or (b) ownership of more than fifty percent (50%) of the 90 | outstanding shares or beneficial ownership of such entity. 91 | 92 | 93 | 2. License Grants and Conditions 94 | 95 | 2.1. Grants 96 | 97 | Each Contributor hereby grants You a world-wide, royalty-free, 98 | non-exclusive license: 99 | 100 | a. under intellectual property rights (other than patent or trademark) 101 | Licensable by such Contributor to use, reproduce, make available, 102 | modify, display, perform, distribute, and otherwise exploit its 103 | Contributions, either on an unmodified basis, with Modifications, or 104 | as part of a Larger Work; and 105 | 106 | b. under Patent Claims of such Contributor to make, use, sell, offer for 107 | sale, have made, import, and otherwise transfer either its 108 | Contributions or its Contributor Version. 109 | 110 | 2.2. Effective Date 111 | 112 | The licenses granted in Section 2.1 with respect to any Contribution 113 | become effective for each Contribution on the date the Contributor first 114 | distributes such Contribution. 115 | 116 | 2.3. Limitations on Grant Scope 117 | 118 | The licenses granted in this Section 2 are the only rights granted under 119 | this License. No additional rights or licenses will be implied from the 120 | distribution or licensing of Covered Software under this License. 121 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 122 | Contributor: 123 | 124 | a. for any code that a Contributor has removed from Covered Software; or 125 | 126 | b. for infringements caused by: (i) Your and any other third party's 127 | modifications of Covered Software, or (ii) the combination of its 128 | Contributions with other software (except as part of its Contributor 129 | Version); or 130 | 131 | c. under Patent Claims infringed by Covered Software in the absence of 132 | its Contributions. 133 | 134 | This License does not grant any rights in the trademarks, service marks, 135 | or logos of any Contributor (except as may be necessary to comply with 136 | the notice requirements in Section 3.4). 137 | 138 | 2.4. Subsequent Licenses 139 | 140 | No Contributor makes additional grants as a result of Your choice to 141 | distribute the Covered Software under a subsequent version of this 142 | License (see Section 10.2) or under the terms of a Secondary License (if 143 | permitted under the terms of Section 3.3). 144 | 145 | 2.5. Representation 146 | 147 | Each Contributor represents that the Contributor believes its 148 | Contributions are its original creation(s) or it has sufficient rights to 149 | grant the rights to its Contributions conveyed by this License. 150 | 151 | 2.6. Fair Use 152 | 153 | This License is not intended to limit any rights You have under 154 | applicable copyright doctrines of fair use, fair dealing, or other 155 | equivalents. 156 | 157 | 2.7. Conditions 158 | 159 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 160 | Section 2.1. 161 | 162 | 163 | 3. Responsibilities 164 | 165 | 3.1. Distribution of Source Form 166 | 167 | All distribution of Covered Software in Source Code Form, including any 168 | Modifications that You create or to which You contribute, must be under 169 | the terms of this License. You must inform recipients that the Source 170 | Code Form of the Covered Software is governed by the terms of this 171 | License, and how they can obtain a copy of this License. You may not 172 | attempt to alter or restrict the recipients' rights in the Source Code 173 | Form. 174 | 175 | 3.2. Distribution of Executable Form 176 | 177 | If You distribute Covered Software in Executable Form then: 178 | 179 | a. such Covered Software must also be made available in Source Code Form, 180 | as described in Section 3.1, and You must inform recipients of the 181 | Executable Form how they can obtain a copy of such Source Code Form by 182 | reasonable means in a timely manner, at a charge no more than the cost 183 | of distribution to the recipient; and 184 | 185 | b. You may distribute such Executable Form under the terms of this 186 | License, or sublicense it under different terms, provided that the 187 | license for the Executable Form does not attempt to limit or alter the 188 | recipients' rights in the Source Code Form under this License. 189 | 190 | 3.3. Distribution of a Larger Work 191 | 192 | You may create and distribute a Larger Work under terms of Your choice, 193 | provided that You also comply with the requirements of this License for 194 | the Covered Software. If the Larger Work is a combination of Covered 195 | Software with a work governed by one or more Secondary Licenses, and the 196 | Covered Software is not Incompatible With Secondary Licenses, this 197 | License permits You to additionally distribute such Covered Software 198 | under the terms of such Secondary License(s), so that the recipient of 199 | the Larger Work may, at their option, further distribute the Covered 200 | Software under the terms of either this License or such Secondary 201 | License(s). 202 | 203 | 3.4. Notices 204 | 205 | You may not remove or alter the substance of any license notices 206 | (including copyright notices, patent notices, disclaimers of warranty, or 207 | limitations of liability) contained within the Source Code Form of the 208 | Covered Software, except that You may alter any license notices to the 209 | extent required to remedy known factual inaccuracies. 210 | 211 | 3.5. Application of Additional Terms 212 | 213 | You may choose to offer, and to charge a fee for, warranty, support, 214 | indemnity or liability obligations to one or more recipients of Covered 215 | Software. However, You may do so only on Your own behalf, and not on 216 | behalf of any Contributor. You must make it absolutely clear that any 217 | such warranty, support, indemnity, or liability obligation is offered by 218 | You alone, and You hereby agree to indemnify every Contributor for any 219 | liability incurred by such Contributor as a result of warranty, support, 220 | indemnity or liability terms You offer. You may include additional 221 | disclaimers of warranty and limitations of liability specific to any 222 | jurisdiction. 223 | 224 | 4. Inability to Comply Due to Statute or Regulation 225 | 226 | If it is impossible for You to comply with any of the terms of this License 227 | with respect to some or all of the Covered Software due to statute, 228 | judicial order, or regulation then You must: (a) comply with the terms of 229 | this License to the maximum extent possible; and (b) describe the 230 | limitations and the code they affect. Such description must be placed in a 231 | text file included with all distributions of the Covered Software under 232 | this License. Except to the extent prohibited by statute or regulation, 233 | such description must be sufficiently detailed for a recipient of ordinary 234 | skill to be able to understand it. 235 | 236 | 5. Termination 237 | 238 | 5.1. The rights granted under this License will terminate automatically if You 239 | fail to comply with any of its terms. However, if You become compliant, 240 | then the rights granted under this License from a particular Contributor 241 | are reinstated (a) provisionally, unless and until such Contributor 242 | explicitly and finally terminates Your grants, and (b) on an ongoing 243 | basis, if such Contributor fails to notify You of the non-compliance by 244 | some reasonable means prior to 60 days after You have come back into 245 | compliance. Moreover, Your grants from a particular Contributor are 246 | reinstated on an ongoing basis if such Contributor notifies You of the 247 | non-compliance by some reasonable means, this is the first time You have 248 | received notice of non-compliance with this License from such 249 | Contributor, and You become compliant prior to 30 days after Your receipt 250 | of the notice. 251 | 252 | 5.2. If You initiate litigation against any entity by asserting a patent 253 | infringement claim (excluding declaratory judgment actions, 254 | counter-claims, and cross-claims) alleging that a Contributor Version 255 | directly or indirectly infringes any patent, then the rights granted to 256 | You by any and all Contributors for the Covered Software under Section 257 | 2.1 of this License shall terminate. 258 | 259 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 260 | license agreements (excluding distributors and resellers) which have been 261 | validly granted by You or Your distributors under this License prior to 262 | termination shall survive termination. 263 | 264 | 6. Disclaimer of Warranty 265 | 266 | Covered Software is provided under this License on an "as is" basis, 267 | without warranty of any kind, either expressed, implied, or statutory, 268 | including, without limitation, warranties that the Covered Software is free 269 | of defects, merchantable, fit for a particular purpose or non-infringing. 270 | The entire risk as to the quality and performance of the Covered Software 271 | is with You. Should any Covered Software prove defective in any respect, 272 | You (not any Contributor) assume the cost of any necessary servicing, 273 | repair, or correction. This disclaimer of warranty constitutes an essential 274 | part of this License. No use of any Covered Software is authorized under 275 | this License except under this disclaimer. 276 | 277 | 7. Limitation of Liability 278 | 279 | Under no circumstances and under no legal theory, whether tort (including 280 | negligence), contract, or otherwise, shall any Contributor, or anyone who 281 | distributes Covered Software as permitted above, be liable to You for any 282 | direct, indirect, special, incidental, or consequential damages of any 283 | character including, without limitation, damages for lost profits, loss of 284 | goodwill, work stoppage, computer failure or malfunction, or any and all 285 | other commercial damages or losses, even if such party shall have been 286 | informed of the possibility of such damages. This limitation of liability 287 | shall not apply to liability for death or personal injury resulting from 288 | such party's negligence to the extent applicable law prohibits such 289 | limitation. Some jurisdictions do not allow the exclusion or limitation of 290 | incidental or consequential damages, so this exclusion and limitation may 291 | not apply to You. 292 | 293 | 8. Litigation 294 | 295 | Any litigation relating to this License may be brought only in the courts 296 | of a jurisdiction where the defendant maintains its principal place of 297 | business and such litigation shall be governed by laws of that 298 | jurisdiction, without reference to its conflict-of-law provisions. Nothing 299 | in this Section shall prevent a party's ability to bring cross-claims or 300 | counter-claims. 301 | 302 | 9. Miscellaneous 303 | 304 | This License represents the complete agreement concerning the subject 305 | matter hereof. If any provision of this License is held to be 306 | unenforceable, such provision shall be reformed only to the extent 307 | necessary to make it enforceable. Any law or regulation which provides that 308 | the language of a contract shall be construed against the drafter shall not 309 | be used to construe this License against a Contributor. 310 | 311 | 312 | 10. Versions of the License 313 | 314 | 10.1. New Versions 315 | 316 | Mozilla Foundation is the license steward. Except as provided in Section 317 | 10.3, no one other than the license steward has the right to modify or 318 | publish new versions of this License. Each version will be given a 319 | distinguishing version number. 320 | 321 | 10.2. Effect of New Versions 322 | 323 | You may distribute the Covered Software under the terms of the version 324 | of the License under which You originally received the Covered Software, 325 | or under the terms of any subsequent version published by the license 326 | steward. 327 | 328 | 10.3. Modified Versions 329 | 330 | If you create software not governed by this License, and you want to 331 | create a new license for such software, you may create and use a 332 | modified version of this License if you rename the license and remove 333 | any references to the name of the license steward (except to note that 334 | such modified license differs from this License). 335 | 336 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 337 | Licenses If You choose to distribute Source Code Form that is 338 | Incompatible With Secondary Licenses under the terms of this version of 339 | the License, the notice described in Exhibit B of this License must be 340 | attached. 341 | 342 | Exhibit A - Source Code Form License Notice 343 | 344 | This Source Code Form is subject to the 345 | terms of the Mozilla Public License, v. 346 | 2.0. If a copy of the MPL was not 347 | distributed with this file, You can 348 | obtain one at 349 | http://mozilla.org/MPL/2.0/. 350 | 351 | If it is not possible or desirable to put the notice in a particular file, 352 | then You may include the notice in a location (such as a LICENSE file in a 353 | relevant directory) where a recipient would be likely to look for such a 354 | notice. 355 | 356 | You may add additional accurate notices of copyright ownership. 357 | 358 | Exhibit B - "Incompatible With Secondary Licenses" Notice 359 | 360 | This Source Code Form is "Incompatible 361 | With Secondary Licenses", as defined by 362 | the Mozilla Public License, v. 2.0. 363 | 364 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include VERSION 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | streamexpect 2 | ============ 3 | 4 | [![Build Status](https://travis-ci.org/digidotcom/python-streamexpect.svg?branch=master)](https://travis-ci.org/digidotcom/python-streamexpect) 5 | [![Coverage Status](https://img.shields.io/coveralls/digidotcom/python-streamexpect.svg)](https://coveralls.io/r/digidotcom/python-streamexpect) 6 | [![Code Climate](https://img.shields.io/codeclimate/github/digidotcom/python-streamexpect.svg)](https://codeclimate.com/github/digidotcom/python-streamexpect) 7 | [![GitHub Issues](https://img.shields.io/github/issues/digidotcom/python-streamexpect.svg)](https://github.com/digidotcom/python-streamexpect/issues) 8 | [![PyPI](https://img.shields.io/pypi/v/streamexpect.svg)](https://pypi.python.org/pypi/streamexpect/) 9 | [![License](https://img.shields.io/badge/license-MPL%202.0-blue.svg)](https://github.com/digidotcom/python-streamexpect/blob/master/LICENSE.txt) 10 | 11 | streamexpect is a library providing cross-platform "expect-like" functionality 12 | for generic Python streams and sockets . It is similar to the 13 | [Pexpect](https://pexpect.readthedocs.org) library, except where Pexpect 14 | explicitly requires an underlying file (usually a TTY), streamexpect uses 15 | duck-typing and requires only a `read` or `recv` method. 16 | 17 | [View the Full Documentation](https://digidotcom.github.io/python-streamexpect) 18 | 19 | The original version of streamexpect was generously donated by 20 | [Digi](http://www.digi.com) [Wireless Design Services](http://www.digi.com/wds). 21 | The software is provided as Alpha software and has not undergone formal 22 | testing. It does, however, ship with extensive unit testing. 23 | 24 | [View the Changelog](https://github.com/digidotcom/python-streamexpect/blob/master/CHANGELOG.md) 25 | 26 | Installation 27 | ============ 28 | 29 | Installation is performed using pip. The latest released version of 30 | streamexpect can be obtained with the following command: 31 | 32 | ```sh 33 | $ pip install streamexpect 34 | ``` 35 | 36 | To install the development version from GitHub: 37 | 38 | ```sh 39 | $ pip install -U -e 'git+https://github.com/digidotcom/python-streamexpect#egg=streamexpect' 40 | ``` 41 | 42 | Example 43 | ======= 44 | 45 | The following example shows opening a serial port (on a Windows PC), sending 46 | the `uname` command, and verifying that _Linux_ is in the returned data. 47 | 48 | ```python 49 | import serial 50 | import streamexpect 51 | 52 | # timeout=0 is essential, as streams are required to be non-blocking 53 | ser = serial.Serial('COM1', baudrate=115200, timeout=0) 54 | 55 | with streamexpect.wrap(ser) as stream: 56 | stream.write('\r\nuname -a\r\n') 57 | match = stream.expect_bytes('Linux', timeout=1.0) 58 | print(u'Found Linux at index {}'.format(match.start)) 59 | ``` 60 | 61 | 62 | Design Goals 63 | ============ 64 | 65 | * Be Cross-Platform 66 | 67 | The library should not depend on any features (besides Python) that exclude a 68 | platform. Yes, that means Windows is a first-class citizen. 69 | 70 | * Be Explicit In Encoding 71 | 72 | When dealing with streams of data, the distinction between when the stream 73 | goes from being a series of binary bytes to a set of encoded characters can 74 | be unclear. The library should be explicit in the handling of binary versus 75 | characters, such that mixing the two types is not allowed without explicit 76 | options to enable encoding and decoding. 77 | 78 | * Common Use Cases Should Be Simple 79 | 80 | For 95% of users, the `streamexpect.wrap` function should accomplish the 81 | desired goals. Intelligent default options should be used so the library just 82 | "does the right thing". 83 | 84 | * Complicated Use Cases Should Be Possible 85 | 86 | The objects returned by the `streamexpect.wrap` function should themselves be 87 | easy to use and extend. Protocol requirements between classes should be 88 | explicit and documented. 89 | 90 | 91 | Development 92 | =========== 93 | 94 | Development of streamexpect takes place in the open on GitHub. Please use pull 95 | requests to submit changes to code and documentation. 96 | 97 | The process for building and testing streamexpect has been automated as much as 98 | possible. [tox](https://testrun.org/tox/) handles building and testing the 99 | code, as well as generating documentation and automatically testing for code 100 | style issues. tox can be installed with pip: 101 | 102 | pip install tox 103 | 104 | The generic tox command looks like: 105 | 106 | tox 107 | 108 | This will attempt to build and test streamexpect against multiple different 109 | versions of Python, and will error on versions not found. To test against only 110 | a single version of Python, specify the version at the tox command line. For 111 | example, to test only Python 2.7: 112 | 113 | tox -e py27 114 | 115 | Multiple versions may be specified, separated by a comma: 116 | 117 | tox -e py27,py35 118 | 119 | Documentation generation and code style checking are not performed by default, 120 | and so must be explicitly provided to the tox command. Documentation generation 121 | requires either Python 2.7, or Python 3.3 or greater. 122 | 123 | tox -e docs,style 124 | 125 | 126 | License 127 | ======= 128 | 129 | This software is open-source software. Copyright Digi International, 2015. 130 | 131 | This Source Code Form is subject to the terms of the Mozilla Public 132 | License, v. 2.0. If a copy of the MPL was not distributed with this file, 133 | you can obtain one at http://mozilla.org/MPL/2.0/. 134 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | mccabe 3 | pep8-naming 4 | sphinx 5 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | API Documentation 3 | ================= 4 | 5 | .. contents:: Table of Contents 6 | 7 | .. currentmodule:: streamexpect 8 | 9 | 10 | --------- 11 | Functions 12 | --------- 13 | 14 | .. autosummary:: 15 | wrap 16 | 17 | .. autofunction:: wrap 18 | 19 | 20 | -------------- 21 | Expecter Types 22 | -------------- 23 | 24 | .. autosummary:: 25 | Expecter 26 | BytesExpecter 27 | TextExpecter 28 | 29 | .. autoclass:: Expecter 30 | :members: 31 | 32 | .. autoclass:: BytesExpecter 33 | :members: 34 | :inherited-members: 35 | 36 | .. autoclass:: TextExpecter 37 | :members: 38 | :inherited-members: 39 | 40 | 41 | -------------- 42 | Searcher Types 43 | -------------- 44 | 45 | .. autosummary:: 46 | Searcher 47 | BytesSearcher 48 | TextSearcher 49 | RegexSearcher 50 | SearcherCollection 51 | 52 | .. autoclass:: Searcher 53 | :members: 54 | :private-members: 55 | 56 | .. autoclass:: BytesSearcher 57 | :members: 58 | 59 | .. autoclass:: TextSearcher 60 | :members: 61 | 62 | .. autoclass:: RegexSearcher 63 | :members: 64 | 65 | .. autoclass:: SearcherCollection 66 | :members: 67 | 68 | 69 | ----------- 70 | Match types 71 | ----------- 72 | 73 | .. autosummary:: 74 | SequenceMatch 75 | RegexMatch 76 | 77 | .. autoclass:: SequenceMatch 78 | :members: 79 | 80 | .. autoclass:: RegexMatch 81 | :members: 82 | 83 | 84 | ------------------- 85 | StreamAdapter Types 86 | ------------------- 87 | 88 | .. autosummary:: 89 | StreamAdapter 90 | PollingStreamAdapter 91 | PollingSocketStreamAdapter 92 | PollingStreamAdapterMixin 93 | 94 | .. autoclass:: StreamAdapter 95 | :members: 96 | 97 | .. autoclass:: PollingStreamAdapter 98 | :members: 99 | 100 | .. autoclass:: PollingSocketStreamAdapter 101 | :members: 102 | 103 | .. autoclass:: PollingStreamAdapterMixin 104 | :members: 105 | 106 | 107 | ---------- 108 | Exceptions 109 | ---------- 110 | 111 | .. autosummary:: 112 | ExpectTimeout 113 | 114 | .. autoclass:: ExpectTimeout 115 | :members: 116 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import ast 4 | import os 5 | import re 6 | import shlex 7 | import sys 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | sys.path.insert(0, os.path.abspath('..')) 13 | 14 | # -- General configuration ------------------------------------------------ 15 | 16 | # Using :private-members:, which requires Sphinx 1.1 17 | needs_sphinx = '1.1' 18 | 19 | # Add any Sphinx extension module names here, as strings. They can be 20 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 21 | # ones. 22 | extensions = [ 23 | 'sphinx.ext.autodoc', 24 | 'sphinx.ext.autosummary', 25 | 'sphinx.ext.intersphinx', 26 | 'sphinx.ext.viewcode', 27 | 'alabaster', 28 | ] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix(es) of source filenames. 34 | source_suffix = ['.rst'] 35 | 36 | # The encoding of source files. 37 | source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'streamexpect' 44 | copyright = u'2015, Digi International, Inc.' 45 | author = u'Nick Stevens' 46 | 47 | # Version information, for |version| and |release| 48 | def read_version(filename): 49 | regex = re.compile(r'__version__\s+=\s+(.*)') 50 | with open(filename, 'rb') as f: 51 | return str(ast.literal_eval(regex.search( 52 | f.read().decode('utf-8')).group(1))) 53 | version = release = read_version('../streamexpect.py') 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | language = None 58 | 59 | # List of patterns, relative to source directory, that match files and 60 | # directories to ignore when looking for source files. 61 | exclude_patterns = ['_build'] 62 | 63 | # The reST default role (used for this markup: `text`) to use for all 64 | # documents. 65 | #default_role = None 66 | 67 | # If true, '()' will be appended to :func: etc. cross-reference text. 68 | add_function_parentheses = True 69 | 70 | # If true, the current module name will be prepended to all description 71 | # unit titles (such as .. function::). 72 | add_module_names = True 73 | 74 | # If true, sectionauthor and moduleauthor directives will be shown in the 75 | # output. They are ignored by default. 76 | show_authors = False 77 | 78 | # The name of the Pygments (syntax highlighting) style to use. 79 | pygments_style = 'sphinx' 80 | 81 | # If true, `todo` and `todoList` produce output, else they produce nothing. 82 | todo_include_todos = False 83 | 84 | # Include __init__ documents in class documentation 85 | autoclass_content = 'both' 86 | 87 | # Example configuration for intersphinx: refer to the Python standard library. 88 | intersphinx_mapping = {'https://docs.python.org/': None} 89 | 90 | # -- Options for HTML output ---------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | html_theme = 'alabaster' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | html_theme_options = { 100 | 'github_user': 'digidotcom', 101 | 'github_repo': 'python-streamexpect', 102 | 'github_button': True, 103 | 'github_banner': False, 104 | 'show_powered_by': True, 105 | 'extra_nav_links': { 106 | 'Digi Wireless Design': 'http://www.digi.com/wds/', 107 | 'Streamexpect on Github': 'https://github.com/digidotcom/python-streamexpect', 108 | } 109 | } 110 | 111 | # Custom sidebar templates, maps document names to template names. 112 | html_sidebars = { 113 | '**': [ 114 | 'about.html', 115 | 'navigation.html', 116 | 'searchbox.html', 117 | ] 118 | } 119 | 120 | # If true, "Created using Sphinx" is shown in the HTML footer. 121 | html_show_sphinx = True 122 | 123 | # If true, "(C) Copyright ..." is shown in the HTML footer. 124 | html_show_copyright = True 125 | 126 | # Output file base name for HTML help builder. 127 | htmlhelp_basename = 'streamexpectdoc' 128 | 129 | # -- Options for LaTeX output --------------------------------------------- 130 | 131 | latex_elements = { 132 | # The paper size ('letterpaper' or 'a4paper'). 133 | 'papersize': 'letterpaper', 134 | 135 | # The font size ('10pt', '11pt' or '12pt'). 136 | 'pointsize': '10pt', 137 | 138 | # Additional stuff for the LaTeX preamble. 139 | 'preamble': '', 140 | 141 | # Latex figure (float) alignment 142 | 'figure_align': 'htbp', 143 | } 144 | 145 | # Grouping the document tree into LaTeX files. List of tuples 146 | # (source start file, target name, title, 147 | # author, documentclass [howto, manual, or own class]). 148 | latex_documents = [ 149 | (master_doc, 'streamexpect.tex', u'streamexpect Documentation', 150 | u'Digi International, Inc.', 'manual'), 151 | ] 152 | 153 | 154 | # -- Options for manual page output --------------------------------------- 155 | 156 | # One entry per manual page. List of tuples 157 | # (source start file, name, description, authors, manual section). 158 | man_pages = [ 159 | (master_doc, 'streamexpect', u'streamexpect Documentation', 160 | [author], 1) 161 | ] 162 | 163 | # If true, show URL addresses after external links. 164 | man_show_urls = True 165 | 166 | 167 | # -- Options for Texinfo output ------------------------------------------- 168 | 169 | # Grouping the document tree into Texinfo files. List of tuples 170 | # (source start file, target name, title, author, 171 | # dir menu entry, description, category) 172 | texinfo_documents = [ 173 | (master_doc, 'streamexpect', u'streamexpect Documentation', 174 | author, 'streamexpect', 'A library providing cross-platform expect' 175 | 'functionality for generic Python streams and sockets', 176 | 'Miscellaneous'), 177 | ] 178 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting Started 3 | =============== 4 | 5 | .. contents:: 6 | 7 | ------------ 8 | Installation 9 | ------------ 10 | 11 | streamexpect may be installed directly from `PyPi 12 | `__ using `pip 13 | `__:: 14 | 15 | pip install streamexpect 16 | 17 | This will install the library as well as any dependency libraries. 18 | 19 | The library is tested to work on the following Python versions: 20 | 21 | * Python 2.7 22 | * Python 3.3 23 | * Python 3.4 24 | * Python 3.5 25 | * PyPy 26 | * PyPy3 27 | 28 | 29 | -------------------------------- 30 | The *streamexpect.wrap* Function 31 | -------------------------------- 32 | 33 | streamexpect provides a convenience function that does much of the setup and 34 | configuration of the streamexpect types, returning to the user an easy-to-use 35 | wrapper over the provided stream. A common use case is communicating with a 36 | remote socket using a simple text-based protocol. In this case, we'll use 37 | streamexpect to talk to Google: 38 | 39 | .. literalinclude:: ../examples/http.py 40 | :language: python 41 | :lines: 8- 42 | 43 | streamexpect also provides intelligent support for Unicode in both Python 2 44 | and Python 3. When using the :func:`streamexpect.wrap` function, all that is 45 | needed to enable Unicode support is the ``unicode=True`` flag: 46 | 47 | .. literalinclude:: ../examples/unicode.py 48 | :language: python 49 | :lines: 8- 50 | 51 | Another common streamexpect use case is for interacting with PySerial. Since 52 | streamexpect does not require that the underlying stream define an actual 53 | file, it is even possible to use streamexpect with PySerial on Windows! 54 | 55 | .. literalinclude:: ../examples/pyserial.py 56 | :language: python 57 | :lines: 8- 58 | 59 | 60 | --------------- 61 | Text vs. Binary 62 | --------------- 63 | 64 | Much like Python 3, streamexpect takes a very explicit stance on what is text 65 | and what is binary. This position carries over to streamexpect even when used 66 | with Python 2, and therefore may catch some users unaware. 67 | 68 | Simply stated, if a stream type returns the ``str`` type in Python 2 or the 69 | ``bytes`` type in Python 3, it is treated as a binary stream. In this case, 70 | only binary may be provided to the :func:`Expecter.expect` method. Likewise, 71 | if a stream type returns the ``unicode`` type in Python 2 or the ``str`` type 72 | in Python 3, only text may be provided :func:`Expecter.expect` method. 73 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | streamexpect 3 | ============ 4 | 5 | Version: |version| 6 | 7 | streamexpect is a library providing cross-platform "expect-like" functionality 8 | for generic Python streams and sockets . It is similar to the `Pexpect 9 | `__ library, except where Pexpect explicitly 10 | requires an underlying file (usually a TTY), streamexpect uses duck-typing and 11 | requires only a `read` or `recv` method. 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | 16 | getting_started 17 | api 18 | -------------------------------------------------------------------------------- /examples/http.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | # 6 | # Copyright (c) 2015, Digi International Inc., All Rights Reserved. 7 | 8 | import socket 9 | import streamexpect 10 | 11 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 12 | sock.connect(('www.google.com', 80)) 13 | 14 | # By default, this will open in binary mode. To read a non-ASCII text stream, 15 | # the unicode option needs to be enabled. 16 | with streamexpect.wrap(sock) as stream: 17 | 18 | # Send the request. This is passed to the underlying socket. 19 | stream.sendall(b'GET / HTTP/1.1\r\nHost: www.google.com\r\n\r\n') 20 | 21 | # Find the start of the response. 22 | stream.expect_bytes(b'HTTP/1.1 200 OK\r\n') 23 | 24 | # Find the "Date" header using regex groups and print it. 25 | match = stream.expect_regex(br'Date: ([^\r\n]+)\r\n') 26 | print(u'Google says the date/time is ' + match.groups[0].decode('ascii')) 27 | -------------------------------------------------------------------------------- /examples/pyserial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | # 6 | # Copyright (c) 2015, Digi International Inc., All Rights Reserved. 7 | 8 | # Send the uname command to a Linux PC connected to a Windows PC over serial 9 | import serial 10 | import streamexpect 11 | 12 | # timeout=0 is essential, as streams are required to be non-blocking 13 | ser = serial.Serial('COM1', baudrate=115200, timeout=0) 14 | 15 | with streamexpect.wrap(ser) as stream: 16 | stream.write(b'\r\nuname -a\r\n') 17 | match = stream.expect_bytes(b'Linux', timeout=1.0) 18 | print(u'Found Linux at index {}'.format(match.start)) 19 | -------------------------------------------------------------------------------- /examples/unicode.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | # 6 | # Copyright (c) 2015, Digi International Inc., All Rights Reserved. 7 | 8 | import io 9 | import streamexpect 10 | 11 | unicode_stream = io.StringIO(u'¡Se puede con español!') 12 | 13 | with streamexpect.wrap(unicode_stream, unicode=True) as stream: 14 | match = stream.expect_text(u'español') 15 | assert match is not None 16 | print(u'Found {} at index {}'.format(match.match, match.start)) 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [build_sphinx] 2 | source-dir = docs 3 | build-dir = docs/_build 4 | all_files = 1 5 | builder = html 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | # 5 | # Copyright (c) 2015 Digi International Inc. All Rights Reserved. 6 | import ast 7 | import re 8 | from setuptools import setup 9 | 10 | 11 | # Original code snippet from Flask project 12 | # https://github.com/mitsuhiko/flask 13 | def read_version(filename): 14 | regex = re.compile(r'__version__\s+=\s+(.*)') 15 | with open(filename, 'rb') as f: 16 | return str(ast.literal_eval(regex.search( 17 | f.read().decode('utf-8')).group(1))) 18 | 19 | 20 | setup( 21 | name='streamexpect', 22 | version=read_version('streamexpect.py'), 23 | url='https://github.com/digidotcom/python-streamexpect', 24 | description='expect-like tools over a Python stream', 25 | author='Digi International Inc.', 26 | author_email='noreply@digi.com', 27 | keywords='expect pexpect search stream serial pyserial socket', 28 | classifiers=[ 29 | 'Development Status :: 3 - Alpha', 30 | 'Environment :: Console', 31 | 'Intended Audience :: Developers', 32 | 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', 33 | 'Operating System :: OS Independent', 34 | 'Programming Language :: Python :: 2.7', 35 | 'Programming Language :: Python :: 3', 36 | 'Programming Language :: Python :: 3.3', 37 | 'Programming Language :: Python :: 3.4', 38 | 'Programming Language :: Python :: 3.5', 39 | 'Programming Language :: Python :: 3.9', 40 | 'Programming Language :: Python :: 3.10', 41 | 'Topic :: Software Development :: Libraries', 42 | ], 43 | py_modules=['streamexpect'], 44 | install_requires=[ 45 | 'six>=1.10' 46 | ], 47 | long_description=open("README.md", "r").read(), 48 | long_description_content_type='text/markdown' 49 | ) 50 | -------------------------------------------------------------------------------- /streamexpect.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | # 6 | # Copyright (c) 2015 Digi International Inc. All Rights Reserved. 7 | 8 | try: 9 | from collections.abc import Sequence 10 | except ImportError: 11 | # For backward compatibility with Python2 12 | from collections import Sequence 13 | import re 14 | import six 15 | import socket 16 | import sys 17 | import time 18 | import unicodedata 19 | 20 | 21 | __version__ = '0.3.0' 22 | 23 | 24 | class SequenceMatch(object): 25 | """Information about a match that has a concept of ordering.""" 26 | 27 | def __init__(self, searcher, match, start, end): 28 | """ 29 | :param Searcher searcher: The :class:`Searcher` that found the match 30 | :param match: Portion of sequence that triggered the match 31 | :param int start: Index of start of match 32 | :param int end: Index of item directly after match 33 | """ 34 | self.searcher = searcher 35 | self.match = match 36 | self.start = int(start) 37 | self.end = int(end) 38 | 39 | def __repr__(self): 40 | return '{}({!r}, match={!r}, start={}, end={})'.format( 41 | self.__class__.__name__, self.searcher, self.match, self.start, 42 | self.end) 43 | 44 | 45 | class RegexMatch(SequenceMatch): 46 | """Information about a match from a regex.""" 47 | 48 | def __init__(self, searcher, match, start, end, groups): 49 | """ 50 | :param Searcher searcher: The :class:`Searcher` that found the match 51 | :param match: Portion of sequence that triggered the match 52 | :param int start: Index of start of match 53 | :param int end: Index of item directly after match 54 | :param tuple groups: Contains the matched subgroups if the regex 55 | contained groups, otherwise ``None`` 56 | """ 57 | super(RegexMatch, self).__init__(searcher, match, start, end) 58 | self.groups = groups 59 | 60 | def __repr__(self): 61 | return '{}({!r}, match={!r}, start={}), end={}, groups={!r}'.format( 62 | self.__class__.__name__, self.searcher, self.match, self.start, 63 | self.end, self.groups) 64 | 65 | 66 | class ExpectTimeout(Exception): 67 | """Exception raised when *expect* call exceeds a timeout.""" 68 | 69 | 70 | class Searcher(object): 71 | """Base class for searching buffers. 72 | 73 | Implements the base class for *Searcher* types, which are used by the 74 | library to determine whether or not a particular buffer contains a *match*. 75 | The type of the match is determined by the *Searcher* implementation: it 76 | may be bytes, text, or something else entirely. 77 | 78 | To conform to the *Searcher* interface, a class must implement one method 79 | *search* and one read-only property *match_type*. The buffer passed to 80 | the *search* method must match the type returned by the *match_type* 81 | property, and *search* must raise a `TypeError` if it does not. The 82 | member function :func:`_check_type` exists to provide this functionality 83 | for subclass implementations. 84 | """ 85 | 86 | def __repr__(self): 87 | return '{}()'.format(self.__class__.__name__) 88 | 89 | def search(self, buf): 90 | """Search the provided buffer for a *match*. 91 | 92 | Search the provided buffer for a *match*. What exactly a *match* means 93 | is defined by the *Searcher* implementation. If the *match* is found, 94 | returns an `SequenceMatch` object, otherwise returns ``None``. 95 | 96 | :param buf: Buffer to search for a match. 97 | """ 98 | raise NotImplementedError('search function must be provided') 99 | 100 | @property 101 | def match_type(self): 102 | """Read-only property that returns type matched by this *Searcher*""" 103 | raise NotImplementedError('match_type must be provided') 104 | 105 | def _check_type(self, value): 106 | """Checks that *value* matches the type of this *Searcher*. 107 | 108 | Checks that *value* matches the type of this *Searcher*, returning the 109 | value if it does and raising a `TypeError` if it does not. 110 | 111 | :return: *value* if type of *value* matches type of this *Searcher*. 112 | :raises TypeError: if type of *value* does not match the type of this 113 | *Searcher* 114 | """ 115 | if not isinstance(value, self.match_type): 116 | raise TypeError('Type ' + str(type(value)) + ' does not match ' 117 | 'expected type ' + str(self.match_type)) 118 | else: 119 | return value 120 | 121 | 122 | class BytesSearcher(Searcher): 123 | """Binary/ASCII searcher. 124 | 125 | A binary/ASCII searcher. Matches when the pattern passed to the 126 | constructor is found in the input buffer. 127 | 128 | Note that this class only operates on binary types. That means that in 129 | Python 3, it will fail on strings, as strings are Unicode by default. In 130 | Python 2 this class will fail on the Unicode type, as strings are ASCII by 131 | default. 132 | """ 133 | 134 | def __init__(self, b): 135 | """ 136 | :param b: Bytes to search for. Must be a binary type (i.e. bytes) 137 | """ 138 | self._bytes = self._check_type(b) 139 | 140 | def __repr__(self): 141 | return '{}({!r})'.format(self.__class__.__name__, self._bytes) 142 | 143 | @property 144 | def match_type(self): 145 | return six.binary_type 146 | 147 | def search(self, buf): 148 | """Search the provided buffer for matching bytes. 149 | 150 | Search the provided buffer for matching bytes. If the *match* is found, 151 | returns a :class:`SequenceMatch` object, otherwise returns ``None``. 152 | 153 | :param buf: Buffer to search for a match. 154 | :return: :class:`SequenceMatch` if matched, None if no match was found. 155 | """ 156 | idx = self._check_type(buf).find(self._bytes) 157 | if idx < 0: 158 | return None 159 | else: 160 | start = idx 161 | end = idx + len(self._bytes) 162 | return SequenceMatch(self, buf[start:end], start, end) 163 | 164 | 165 | class TextSearcher(Searcher): 166 | """Plain text searcher. 167 | 168 | A plain-text searcher. Matches when the text passed to the constructor is 169 | found in the input buffer. 170 | 171 | Note that this class operates only on text types (i.e. Unicode) and raises 172 | a TypeError if used with binary data. Use the :class:`BytesSearcher` type 173 | to search binary or ASCII text. 174 | 175 | To make sure that modified (accented, grave, etc.) characters are matched 176 | accurately, the input text is converted to the Unicode canonical composed 177 | form before being used to match. 178 | """ 179 | 180 | FORM = 'NFKC' 181 | 182 | def __init__(self, text): 183 | """ 184 | :param text: Text to search for. Must be a text type (i.e. Unicode) 185 | """ 186 | super(TextSearcher, self).__init__() 187 | self._check_type(text) 188 | self._text = unicodedata.normalize(self.FORM, text) 189 | 190 | def __repr__(self): 191 | return '{}({!r})'.format(self.__class__.__name__, self._text) 192 | 193 | @property 194 | def match_type(self): 195 | return six.text_type 196 | 197 | def search(self, buf): 198 | """Search the provided buffer for matching text. 199 | 200 | Search the provided buffer for matching text. If the *match* is found, 201 | returns a :class:`SequenceMatch` object, otherwise returns ``None``. 202 | 203 | :param buf: Buffer to search for a match. 204 | :return: :class:`SequenceMatch` if matched, None if no match was found. 205 | """ 206 | self._check_type(buf) 207 | normalized = unicodedata.normalize(self.FORM, buf) 208 | idx = normalized.find(self._text) 209 | if idx < 0: 210 | return None 211 | start = idx 212 | end = idx + len(self._text) 213 | return SequenceMatch(self, normalized[start:end], start, end) 214 | 215 | 216 | class RegexSearcher(Searcher): 217 | """Regular expression searcher. 218 | 219 | Searches for a match in the stream that matches the provided regular 220 | expression. 221 | 222 | This class follows the Python 3 model for dealing with binary versus text 223 | patterns, raising a `TypeError` if mixed binary/text is used. This means 224 | that a *RegexSearcher* that is instantiated with binary data will raise a 225 | `TypeError` if used on text, and a *RegexSearcher* instantiated with text 226 | will raise a `TypeError` on binary data. 227 | """ 228 | 229 | def __init__(self, pattern, regex_options=0): 230 | """ 231 | :param pattern: The regex to search for, as a single compiled regex 232 | or a string that will be processed as a regex. 233 | :param regex_options: Options passed to the regex engine. 234 | """ 235 | super(RegexSearcher, self).__init__() 236 | self._regex = re.compile(pattern, regex_options) 237 | 238 | def __repr__(self): 239 | return '{}(re.compile({!r}))'.format(self.__class__.__name__, 240 | self._regex.pattern) 241 | 242 | @property 243 | def match_type(self): 244 | return type(self._regex.pattern) 245 | 246 | def search(self, buf): 247 | """Search the provided buffer for a match to the object's regex. 248 | 249 | Search the provided buffer for a match to the object's regex. If the 250 | *match* is found, returns a :class:`RegexMatch` object, otherwise 251 | returns ``None``. 252 | 253 | :param buf: Buffer to search for a match. 254 | :return: :class:`RegexMatch` if matched, None if no match was found. 255 | """ 256 | match = self._regex.search(self._check_type(buf)) 257 | if match is not None: 258 | start = match.start() 259 | end = match.end() 260 | return RegexMatch(self, buf[start:end], start, end, match.groups()) 261 | 262 | 263 | def _flatten(n): 264 | """Recursively flatten a mixed sequence of sub-sequences and items""" 265 | if isinstance(n, Sequence): 266 | for x in n: 267 | for y in _flatten(x): 268 | yield y 269 | else: 270 | yield n 271 | 272 | 273 | class SearcherCollection(Searcher, list): 274 | """Collect multiple `Searcher` objects into one. 275 | 276 | Collect multiple `Searcher` instances into a single `Searcher` instance. 277 | This is different than simply looping over a list of searchers, as this 278 | class will always find the earliest match from any of its sub-searchers 279 | (i.e. the match with the smallest index). 280 | 281 | Note that this class requires that all of its sub-searchers have the same 282 | *match_type*. 283 | """ 284 | 285 | def __init__(self, *searchers): 286 | """ 287 | :param searchers: One or more :class:`Searcher` implementations. 288 | """ 289 | super(SearcherCollection, self).__init__() 290 | self.extend(_flatten(searchers)) 291 | if not self: 292 | raise ValueError(self.__class__.__name__ + ' requires at least ' 293 | 'one sub-searcher to be specified') 294 | 295 | # Check that all searchers are valid 296 | for searcher in self: 297 | try: 298 | getattr(searcher, 'search') 299 | except AttributeError: 300 | raise TypeError('missing required attribute "search"') 301 | try: 302 | getattr(searcher, 'match_type') 303 | except AttributeError: 304 | raise TypeError('missing required attribute "match_type"') 305 | 306 | # Check that all searchers are the same match type 307 | match_type = self[0].match_type 308 | if not all(map(lambda x: x.match_type == match_type, self)): 309 | raise ValueError(self.__class__.__name__ + ' requires that all ' 310 | 'sub-searchers implement the same match_type') 311 | self._match_type = match_type 312 | 313 | def __repr__(self): 314 | return '{}({!r})'.format(self.__class__.__name__, list(self)) 315 | 316 | @property 317 | def match_type(self): 318 | return self._match_type 319 | 320 | def search(self, buf): 321 | """Search the provided buffer for a match to any sub-searchers. 322 | 323 | Search the provided buffer for a match to any of this collection's 324 | sub-searchers. If a single matching sub-searcher is found, returns that 325 | sub-searcher's *match* object. If multiple matches are found, the match 326 | with the smallest index is returned. If no matches are found, returns 327 | ``None``. 328 | 329 | :param buf: Buffer to search for a match. 330 | :return: :class:`RegexMatch` if matched, None if no match was found. 331 | """ 332 | self._check_type(buf) 333 | best_match = None 334 | best_index = sys.maxsize 335 | for searcher in self: 336 | match = searcher.search(buf) 337 | if match and match.start < best_index: 338 | best_match = match 339 | best_index = match.start 340 | return best_match 341 | 342 | 343 | class StreamAdapter(object): 344 | """Adapter to match varying stream objects to a single interface. 345 | 346 | Despite the existence of the Python stream interface and file-like objects, 347 | there are actually a number of subtly different implementations of streams 348 | within Python. In addition, there are stream-like constructs like sockets 349 | that use a different interface entirely (*send*/*recv* versus 350 | *read*/*write*). 351 | 352 | This class provides a base adapter that can be used to convert anything 353 | even remotely stream-like into a form that can consistently be used by 354 | implementations of `Expecter`. The key method is :func:`poll`, which must 355 | *always* provide a blocking interface to the underlying stream, and must 356 | *also* provide a reliable timeout mechanism. The exact method to achieve 357 | these two goals is implementation dependent, and a particular 358 | implementation may be used to meet the need at hand. 359 | 360 | This class also automatically delegates any non-existent attributes to the 361 | underlying stream object. This allows the adapter to be used identically to 362 | the stream. 363 | """ 364 | def __init__(self, stream): 365 | """:param stream: Stream object to wrap over.""" 366 | self.stream = stream 367 | 368 | def __getattr__(self, attr): 369 | return getattr(self.stream, attr) 370 | 371 | def __repr__(self): 372 | return '{}({!r})'.format(self.__class__.__name__, self.stream) 373 | 374 | def poll(self, timeout): 375 | """Unified blocking read access to the underlying stream. 376 | 377 | All subclasses of :class:`StreamAdapter` must implement this method. 378 | Once called, the method must either: 379 | 380 | - Return new read data whenever it becomes available, or 381 | - Raise an `ExpectTimeout` exception if timeout is exceeded. 382 | 383 | The amount of data to return from each call is implementation 384 | dependent, but it is important that either all data is returned from 385 | the function, or that the data be somehow returned to the stream. In 386 | other words, any data not returned must still be available the next 387 | time the `poll` method is called. 388 | 389 | Note that there is no "wait forever" functionality: either some new 390 | data must be returned or an exception must occur in a finite amount of 391 | time. It is also important that, if there is a timeout, the method 392 | raise the exception as soon after the timeout occurred as is reasonably 393 | possible. 394 | """ 395 | raise NotImplementedError(self.__class__.__name__ + 396 | '.poll must be implemented') 397 | 398 | 399 | class PollingStreamAdapterMixin(object): 400 | """Add *poll_period* and *max_read* properties to a `StreamAdapter`""" 401 | 402 | @property 403 | def poll_period(self): 404 | return self._poll_period 405 | 406 | @poll_period.setter 407 | def poll_period(self, value): 408 | value = float(value) 409 | if value <= 0: 410 | raise ValueError('poll_period must be greater than 0') 411 | self._poll_period = value 412 | 413 | @property 414 | def max_read(self): 415 | return self._max_read 416 | 417 | @max_read.setter 418 | def max_read(self, value): 419 | value = int(value) 420 | if value < 0: 421 | raise ValueError('max_read must be greater than or equal to 0') 422 | self._max_read = value 423 | 424 | 425 | class PollingStreamAdapter(StreamAdapter, PollingStreamAdapterMixin): 426 | """A :class:`StreamAdapter` that polls a non-blocking stream. 427 | 428 | Polls a non-blocking stream of data until new data is available or a 429 | timeout is exceeded. It is *VERY IMPORTANT* that the underlying stream be 430 | non-blocking. 431 | """ 432 | 433 | def __init__(self, stream, poll_period=0.1, max_read=1024): 434 | """ 435 | :param stream: Stream to poll for data. 436 | :param float poll_period: Time (in seconds) between polls of the 437 | stream. 438 | :param int max_read: The maximum number of bytes/characters to read 439 | from the stream at one time. 440 | """ 441 | super(PollingStreamAdapter, self).__init__(stream) 442 | self.poll_period = poll_period 443 | self.max_read = max_read 444 | 445 | def poll(self, timeout): 446 | """ 447 | :param float timeout: Timeout in seconds. 448 | """ 449 | timeout = float(timeout) 450 | end_time = time.time() + timeout 451 | while True: 452 | # Keep reading until data is received or timeout 453 | incoming = self.stream.read(self._max_read) 454 | if incoming: 455 | return incoming 456 | if (end_time - time.time()) < 0: 457 | raise ExpectTimeout() 458 | time.sleep(self._poll_period) 459 | 460 | 461 | class PollingSocketStreamAdapter(StreamAdapter, PollingStreamAdapterMixin): 462 | """A :class:`StreamAdapter` that polls a non-blocking socket. 463 | 464 | Polls a non-blocking socket for data until new data is available or a 465 | timeout is exceeded. 466 | """ 467 | 468 | def __init__(self, sock, poll_period=0.1, max_read=1024): 469 | """ 470 | :param sock: Socket to poll for data. 471 | :param float poll_period: Time (in seconds) between poll of the socket. 472 | :param int max_read: The maximum number of bytes/characters to read 473 | from the socket at one time. 474 | """ 475 | super(PollingSocketStreamAdapter, self).__init__(sock) 476 | self.poll_period = poll_period 477 | self.max_read = max_read 478 | 479 | def poll(self, timeout): 480 | """ 481 | :param float timeout: Timeout in seconds. A timeout that is less than 482 | the poll_period will still cause a single read that may take up to 483 | poll_period seconds. 484 | """ 485 | now = time.time() 486 | end_time = now + float(timeout) 487 | prev_timeout = self.stream.gettimeout() 488 | self.stream.settimeout(self._poll_period) 489 | incoming = None 490 | try: 491 | while (end_time - now) >= 0: 492 | try: 493 | incoming = self.stream.recv(self._max_read) 494 | except socket.timeout: 495 | pass 496 | if incoming: 497 | return incoming 498 | now = time.time() 499 | raise ExpectTimeout() 500 | finally: 501 | self.stream.settimeout(prev_timeout) 502 | 503 | 504 | class ExpectBytesMixin(object): 505 | 506 | def expect_bytes(self, b, timeout=3): 507 | """Wait for a match to the bytes in *b* to appear on the stream. 508 | 509 | Waits for input matching the bytes *b* for up to *timeout* seconds. 510 | If a match is found, a :class:`SequenceMatch` result is returned. If 511 | no match is found within *timeout* seconds, raise an 512 | :class:`ExpectTimeout` exception. 513 | 514 | :param b: The byte pattern to search for. 515 | :param float timeout: Timeout in seconds. 516 | :return: :class:`SequenceMatch` if matched, None if no match was found. 517 | """ 518 | return self.expect(BytesSearcher(b), timeout) 519 | 520 | 521 | class ExpectTextMixin(object): 522 | 523 | def expect_text(self, text, timeout=3): 524 | """Wait for a match to the text in *text* to appear on the stream. 525 | 526 | Waits for input matching the text *text* for up to *timeout* 527 | seconds. If a match is found, a :class:`SequenceMatch` result is 528 | returned. If no match is found within *timeout* seconds, raise an 529 | :class:`ExpectTimeout` exception. 530 | 531 | :param text: The plain-text pattern to search for. 532 | :param float timeout: Timeout in seconds. 533 | :return: :class:`SequenceMatch` if matched, None if no match was found. 534 | """ 535 | return self.expect(TextSearcher(text), timeout) 536 | 537 | 538 | class ExpectRegexMixin(object): 539 | 540 | def expect_regex(self, pattern, timeout=3, regex_options=0): 541 | """Wait for a match to the regex in *pattern* to appear on the stream. 542 | 543 | Waits for input matching the regex *pattern* for up to *timeout* 544 | seconds. If a match is found, a :class:`RegexMatch` result is returned. 545 | If no match is found within *timeout* seconds, raise an 546 | :class:`ExpectTimeout` exception. 547 | 548 | :param pattern: The pattern to search for, as a single compiled regex 549 | or a string that will be processed as a regex. 550 | :param float timeout: Timeout in seconds. 551 | :param regex_options: Options passed to the regex engine. 552 | :return: :class:`RegexMatch` if matched, None if no match was found. 553 | """ 554 | return self.expect(RegexSearcher(pattern, regex_options), timeout) 555 | 556 | 557 | class Expecter(object): 558 | """Base class for consuming input and waiting for a pattern to appear. 559 | 560 | Implements the base class for *Expecter* types, which wrap over a 561 | :class:`StreamAdapter` type and provide methods for applying a 562 | :class:`Searcher` to the received data. Any attributes not part of this 563 | class are delegated to the underlying :class:`StreamAdapter` type. 564 | """ 565 | 566 | def __init__(self, stream_adapter, input_callback, window, close_adapter): 567 | """ 568 | :param StreamAdapter stream_adapter: The :class:`StreamAdapter` object 569 | to receive data from. 570 | :param function input_callback: Callback function with one parameter 571 | that is called each time new data is read from the 572 | *stream_adapter*. 573 | :param int window: Number of historical objects (bytes, characters, 574 | etc.) to buffer. 575 | :param bool close_adapter: If ``True``, and the Expecter is used as a 576 | context manager, closes the adapter at the end of the context 577 | manager. 578 | """ 579 | self.stream_adapter = stream_adapter 580 | if not input_callback: 581 | self.input_callback = lambda _: None 582 | else: 583 | self.input_callback = input_callback 584 | self.window = window 585 | self.close_adapter = close_adapter 586 | 587 | # Delegate undefined methods to underlying stream 588 | def __getattr__(self, attr): 589 | return getattr(self._stream_adapter, attr) 590 | 591 | def __enter__(self): 592 | return self 593 | 594 | def __exit__(self, type_, value, traceback): 595 | if self.close_adapter: 596 | self._stream_adapter.close() 597 | return False 598 | 599 | @property 600 | def stream_adapter(self): 601 | return self._stream_adapter 602 | 603 | @stream_adapter.setter 604 | def stream_adapter(self, value): 605 | try: 606 | getattr(value, 'poll') 607 | except AttributeError: 608 | raise TypeError('stream_adapter must define "poll" method') 609 | self._stream_adapter = value 610 | 611 | @property 612 | def window(self): 613 | return self._window 614 | 615 | @window.setter 616 | def window(self, value): 617 | value = int(value) 618 | if value < 1: 619 | raise ValueError('window must be at least 1') 620 | self._window = value 621 | 622 | def expect(self, searcher, timeout): 623 | """Apply *searcher* to underlying :class:`StreamAdapter` 624 | 625 | :param Searcher searcher: :class:`Searcher` to apply to underlying 626 | stream. 627 | :param float timeout: Timeout in seconds. 628 | """ 629 | raise NotImplementedError('Expecter must implement "expect"') 630 | 631 | 632 | class BytesExpecter(Expecter, ExpectBytesMixin, ExpectRegexMixin): 633 | """:class:`Expecter` interface for searching a byte-oriented stream.""" 634 | 635 | def __init__(self, stream_adapter, input_callback=None, window=1024, 636 | close_adapter=True): 637 | """ 638 | :param StreamAdapter stream_adapter: The :class:`StreamAdapter` object 639 | to receive data from. 640 | :param function input_callback: Callback function with one parameter 641 | that is called each time new data is read from the 642 | *stream_adapter*. 643 | :param int window: Number of historical bytes to buffer. 644 | """ 645 | super(BytesExpecter, self).__init__(stream_adapter, input_callback, 646 | window, close_adapter) 647 | self._history = six.binary_type() 648 | self._start = 0 649 | 650 | def expect(self, searcher, timeout=3): 651 | """Wait for input matching *searcher* 652 | 653 | Waits for input matching *searcher* for up to *timeout* seconds. If 654 | a match is found, the match result is returned (the specific type of 655 | returned result depends on the :class:`Searcher` type). If no match is 656 | found within *timeout* seconds, raise an :class:`ExpectTimeout` 657 | exception. 658 | 659 | :param Searcher searcher: :class:`Searcher` to apply to underlying 660 | stream. 661 | :param float timeout: Timeout in seconds. 662 | """ 663 | timeout = float(timeout) 664 | end = time.time() + timeout 665 | match = searcher.search(self._history[self._start:]) 666 | while not match: 667 | # poll() will raise ExpectTimeout if time is exceeded 668 | incoming = self._stream_adapter.poll(end - time.time()) 669 | self.input_callback(incoming) 670 | self._history += incoming 671 | match = searcher.search(self._history[self._start:]) 672 | trimlength = len(self._history) - self._window 673 | if trimlength > 0: 674 | self._start -= trimlength 675 | self._history = self._history[trimlength:] 676 | 677 | self._start += match.end 678 | if (self._start < 0): 679 | self._start = 0 680 | 681 | return match 682 | 683 | 684 | class TextExpecter(Expecter, ExpectTextMixin, ExpectRegexMixin): 685 | """:class:`Expecter` interface for searching a text-oriented stream.""" 686 | 687 | def __init__(self, stream_adapter, input_callback=None, window=1024, 688 | close_adapter=True): 689 | """ 690 | :param StreamAdapter stream_adapter: The :class:`StreamAdapter` object 691 | to receive data from. 692 | :param function input_callback: Callback function with one parameter 693 | that is called each time new data is read from the 694 | *stream_adapter*. 695 | :param int window: Number of historical characters to buffer. 696 | """ 697 | super(TextExpecter, self).__init__(stream_adapter, input_callback, 698 | window, close_adapter) 699 | self._history = six.text_type() 700 | self._start = 0 701 | 702 | def expect(self, searcher, timeout=3): 703 | """Wait for input matching *searcher*. 704 | 705 | Waits for input matching *searcher* for up to *timeout* seconds. If 706 | a match is found, the match result is returned (the specific type of 707 | returned result depends on the :class:`Searcher` type). If no match is 708 | found within *timeout* seconds, raise an :class:`ExpectTimeout` 709 | exception. 710 | 711 | :param Searcher searcher: :class:`Searcher` to apply to underlying 712 | stream. 713 | :param float timeout: Timeout in seconds. 714 | """ 715 | timeout = float(timeout) 716 | end = time.time() + timeout 717 | match = searcher.search(self._history[self._start:]) 718 | while not match: 719 | # poll() will raise ExpectTimeout if time is exceeded 720 | incoming = self._stream_adapter.poll(end - time.time()) 721 | self.input_callback(incoming) 722 | self._history += incoming 723 | match = searcher.search(self._history[self._start:]) 724 | trimlength = len(self._history) - self._window 725 | if trimlength > 0: 726 | self._start -= trimlength 727 | self._history = self._history[trimlength:] 728 | 729 | self._start += match.end 730 | if (self._start < 0): 731 | self._start = 0 732 | 733 | return match 734 | 735 | 736 | def _echo_text(value): 737 | sys.stdout.write(value) 738 | 739 | 740 | def _echo_bytes(value): 741 | sys.stdout.write(value.decode('ascii', errors='backslashreplace')) 742 | 743 | 744 | def wrap(stream, unicode=False, window=1024, echo=False, close_stream=True): 745 | """Wrap a stream to implement expect functionality. 746 | 747 | This function provides a convenient way to wrap any Python stream (a 748 | file-like object) or socket with an appropriate :class:`Expecter` class for 749 | the stream type. The returned object adds an :func:`Expect.expect` method 750 | to the stream, while passing normal stream functions like *read*/*recv* 751 | and *write*/*send* through to the underlying stream. 752 | 753 | Here's an example of opening and wrapping a pair of network sockets:: 754 | 755 | import socket 756 | import streamexpect 757 | 758 | source, drain = socket.socketpair() 759 | expecter = streamexpect.wrap(drain) 760 | source.sendall(b'this is a test') 761 | match = expecter.expect_bytes(b'test', timeout=5) 762 | 763 | assert match is not None 764 | 765 | :param stream: The stream/socket to wrap. 766 | :param bool unicode: If ``True``, the wrapper will be configured for 767 | Unicode matching, otherwise matching will be done on binary. 768 | :param int window: Historical characters to buffer. 769 | :param bool echo: If ``True``, echoes received characters to stdout. 770 | :param bool close_stream: If ``True``, and the wrapper is used as a context 771 | manager, closes the stream at the end of the context manager. 772 | """ 773 | if hasattr(stream, 'read'): 774 | proxy = PollingStreamAdapter(stream) 775 | elif hasattr(stream, 'recv'): 776 | proxy = PollingSocketStreamAdapter(stream) 777 | else: 778 | raise TypeError('stream must have either read or recv method') 779 | 780 | if echo and unicode: 781 | callback = _echo_text 782 | elif echo and not unicode: 783 | callback = _echo_bytes 784 | else: 785 | callback = None 786 | 787 | if unicode: 788 | expecter = TextExpecter(proxy, input_callback=callback, window=window, 789 | close_adapter=close_stream) 790 | else: 791 | expecter = BytesExpecter(proxy, input_callback=callback, window=window, 792 | close_adapter=close_stream) 793 | 794 | return expecter 795 | 796 | 797 | __all__ = [ 798 | # Functions 799 | 'wrap', 800 | 801 | # Expecter types 802 | 'Expecter', 803 | 'BytesExpecter', 804 | 'TextExpecter', 805 | 806 | # Searcher types 807 | 'Searcher', 808 | 'BytesSearcher', 809 | 'TextSearcher', 810 | 'RegexSearcher', 811 | 'SearcherCollection', 812 | 813 | # Match types 814 | 'SequenceMatch', 815 | 'RegexMatch', 816 | 817 | # StreamAdapter types 818 | 'StreamAdapter', 819 | 'PollingStreamAdapter', 820 | 'PollingSocketStreamAdapter', 821 | 'PollingStreamAdapterMixin', 822 | 823 | # Exceptions 824 | 'ExpectTimeout', 825 | ] 826 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digidotcom/python-streamexpect/5fc1919e2115b16f65bda0386aae4c5440caacd3/test/__init__.py -------------------------------------------------------------------------------- /test/test_streamexpect.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | # 6 | # Copyright (c) 2015 Digi International Inc. All Rights Reserved. 7 | 8 | import io 9 | import re 10 | import six 11 | import streamexpect 12 | import socket 13 | import sys 14 | import testfixtures 15 | import unittest 16 | 17 | from six import u 18 | 19 | from streamexpect import BytesSearcher 20 | from streamexpect import Expecter 21 | from streamexpect import ExpectTimeout 22 | from streamexpect import PollingSocketStreamAdapter 23 | from streamexpect import PollingStreamAdapter 24 | from streamexpect import RegexMatch 25 | from streamexpect import RegexSearcher 26 | from streamexpect import Searcher 27 | from streamexpect import SearcherCollection 28 | from streamexpect import SequenceMatch 29 | from streamexpect import StreamAdapter 30 | from streamexpect import TextSearcher 31 | 32 | 33 | class TestSequenceMatch(unittest.TestCase): 34 | 35 | def test_repr(self): 36 | # Only check no exceptions thrown 37 | match = SequenceMatch(TextSearcher(u('rho')), 'rho', 0, 3) 38 | repr(match) 39 | 40 | 41 | class TestRegexMatch(unittest.TestCase): 42 | 43 | def test_repr(self): 44 | # Only check no exceptions thrown 45 | match = RegexMatch(RegexSearcher(u('rho')), 'rho', 0, 3, None) 46 | repr(match) 47 | 48 | 49 | class PiecewiseStream(io.RawIOBase): 50 | 51 | def __init__(self, string, max_chunk=None): 52 | self.max_chunk = max_chunk or sys.maxsize 53 | self.string = string 54 | self.idx = 0 55 | 56 | def read(self, n): 57 | if self.idx >= len(self.string): 58 | return '' 59 | else: 60 | take = min(n, self.max_chunk) 61 | chunk = self.string[self.idx:self.idx+take] 62 | self.idx += len(chunk) 63 | return chunk 64 | 65 | 66 | class EmptyStream(io.RawIOBase): 67 | 68 | def read(self, n): 69 | return '' 70 | 71 | 72 | class SearcherTest(unittest.TestCase): 73 | 74 | def setUp(self): 75 | self.searcher = Searcher() 76 | 77 | def test_repr(self): 78 | # Only check no exceptions thrown 79 | repr(self.searcher) 80 | 81 | def test_search(self): 82 | with self.assertRaises(NotImplementedError): 83 | self.searcher.search('') 84 | 85 | def test_match_type(self): 86 | with self.assertRaises(NotImplementedError): 87 | self.searcher.match_type 88 | 89 | def test_match_type_read_only(self): 90 | with self.assertRaises(AttributeError): 91 | self.searcher.match_type = 'foobar' 92 | 93 | 94 | class TestTextSearcher(unittest.TestCase): 95 | 96 | def test_text_constructor(self): 97 | searcher = TextSearcher(u('some unicode')) 98 | self.assertEqual(searcher.match_type, six.text_type) 99 | 100 | def test_fail_using_bytes(self): 101 | with self.assertRaises(TypeError): 102 | TextSearcher(b'bytes type') 103 | 104 | def test_not_patterns(self): 105 | with self.assertRaises(TypeError): 106 | TextSearcher(None) 107 | with self.assertRaises(TypeError): 108 | TextSearcher(5) 109 | 110 | def test_no_match(self): 111 | uut = TextSearcher(u('I will never match')) 112 | self.assertEqual(None, uut.search(u('alpha beta gamma'))) 113 | 114 | def test_single_match(self): 115 | uut = TextSearcher(u('one')) 116 | match = uut.search(u('the number one appears once')) 117 | self.assertIsNotNone(match) 118 | self.assertEqual(11, match.start) 119 | self.assertEqual(14, match.end) 120 | 121 | def test_multi_match(self): 122 | uut = TextSearcher(u('one')) 123 | match = uut.search(u('one two three two one')) 124 | self.assertIsNotNone(match) 125 | self.assertEqual(0, match.start) 126 | self.assertEqual(3, match.end) 127 | 128 | def test_unicode_combining_characters(self): 129 | # Some unicode characters can be represented in multiple ways - for 130 | # example, an accented character may be a single code point (with the 131 | # accent baked in), or it may be the "normal" letter with a combining 132 | # code point. See https://docs.python.org/2/library/unicodedata.html. 133 | # The points below are for a capital C with a cedilla, first as a 134 | # composite character, second as a pairing of C and the cedilla 135 | # combining character. 136 | composite = six.unichr(0xC7) 137 | combining = six.unichr(0x43) + six.unichr(0x0327) 138 | 139 | # Test combinations of search and character 140 | for text in composite, combining: 141 | searcher = TextSearcher(text) 142 | self.assertIsNotNone(searcher.search(composite)) 143 | self.assertIsNotNone(searcher.search(combining)) 144 | 145 | def test_repr(self): 146 | # Only check no exceptions thrown 147 | searcher = TextSearcher(u('rho')) 148 | repr(searcher) 149 | 150 | 151 | class TestBytesSearcher(unittest.TestCase): 152 | 153 | def test_binary_constructor(self): 154 | searcher = BytesSearcher(b'\x01\x02\x03\x04') 155 | self.assertEqual(searcher.match_type, six.binary_type) 156 | 157 | def test_fail_using_text(self): 158 | with self.assertRaises(TypeError): 159 | BytesSearcher(u('I am Unicode text')) 160 | 161 | def test_not_patterns(self): 162 | with self.assertRaises(TypeError): 163 | BytesSearcher(None) 164 | with self.assertRaises(TypeError): 165 | BytesSearcher(5) 166 | 167 | def test_no_match(self): 168 | uut = BytesSearcher(b'I will never match') 169 | self.assertEqual(None, uut.search(b'alpha beta gamma')) 170 | 171 | def test_single_match(self): 172 | uut = BytesSearcher(b'\x05\x05') 173 | match = uut.search(b'ascii with \x05\x05 bytes') 174 | self.assertIsNotNone(match) 175 | self.assertEqual(11, match.start) 176 | self.assertEqual(13, match.end) 177 | 178 | def test_multi_match(self): 179 | uut = BytesSearcher(b'one') 180 | match = uut.search(b'one two three two one') 181 | self.assertIsNotNone(match) 182 | self.assertEqual(0, match.start) 183 | self.assertEqual(3, match.end) 184 | 185 | def test_repr(self): 186 | # Only check no exceptions thrown 187 | searcher = BytesSearcher(b'\x00\x00') 188 | repr(searcher) 189 | 190 | 191 | class TestRegexSearcher(unittest.TestCase): 192 | 193 | def test_constructor_text_pattern(self): 194 | searcher = RegexSearcher(u('Unicode pattern')) 195 | self.assertEqual(searcher.match_type, six.text_type) 196 | searcher = RegexSearcher(b'ASCII pattern') 197 | self.assertEqual(searcher.match_type, six.binary_type) 198 | searcher = RegexSearcher(re.compile(u('Unicode precompiled'))) 199 | self.assertEqual(searcher.match_type, six.text_type) 200 | searcher = RegexSearcher(re.compile(b'ASCII precompiled')) 201 | self.assertEqual(searcher.match_type, six.binary_type) 202 | 203 | def test_no_patterns(self): 204 | with self.assertRaises(TypeError): 205 | RegexSearcher(None) 206 | with self.assertRaises(TypeError): 207 | RegexSearcher(5) 208 | 209 | def test_text(self): 210 | uut = RegexSearcher(u('omicron')) 211 | match = uut.search(u('omicron pi rho')) 212 | self.assertIsNotNone(match) 213 | self.assertEqual(0, match.start) 214 | self.assertEqual(7, match.end) 215 | 216 | def test_binary(self): 217 | uut = RegexSearcher(b'omicron') 218 | match = uut.search(b'omicron pi rho') 219 | self.assertIsNotNone(match) 220 | self.assertEqual(0, match.start) 221 | self.assertEqual(7, match.end) 222 | 223 | def test_mismatched_types(self): 224 | text_searcher = RegexSearcher(u('omicron')) 225 | with self.assertRaises(TypeError): 226 | text_searcher.search(b'omicron') 227 | binary_searcher = RegexSearcher(b'omicron') 228 | with self.assertRaises(TypeError): 229 | binary_searcher.search(u('omicron')) 230 | 231 | def test_single_regex_multi_match(self): 232 | uut = RegexSearcher('omicron') 233 | match = uut.search('pi delta omicron rho omicron') 234 | self.assertIsNotNone(match) 235 | self.assertEqual(9, match.start) 236 | self.assertEqual(16, match.end) 237 | 238 | def test_repr(self): 239 | # Only check no exceptions thrown 240 | searcher = RegexSearcher('[eu]psilon') 241 | repr(searcher) 242 | 243 | 244 | class TestSearcherCollection(unittest.TestCase): 245 | 246 | def test_constructor(self): 247 | s1, s2 = TextSearcher(u('')), TextSearcher(u('')) 248 | searcher = SearcherCollection(s1, s2) 249 | self.assertEqual(six.text_type, searcher.match_type) 250 | self.assertEqual([s1, s2], list(searcher)) 251 | searcher = SearcherCollection([s1, s2]) 252 | self.assertEqual(six.text_type, searcher.match_type) 253 | self.assertEqual([s1, s2], list(searcher)) 254 | searcher = SearcherCollection(s1) 255 | self.assertEqual([s1], list(searcher)) 256 | self.assertEqual(six.text_type, searcher.match_type) 257 | 258 | def test_constructor_invalid(self): 259 | with self.assertRaises(ValueError): 260 | SearcherCollection([]) 261 | with self.assertRaises(TypeError): 262 | SearcherCollection(1) 263 | with self.assertRaises(ValueError): 264 | SearcherCollection(TextSearcher(u('')), BytesSearcher(b'')) 265 | 266 | NoSearchSearcher = type('NoSearchSearcher', (object,), 267 | {'match_type': None}) 268 | with self.assertRaises(TypeError): 269 | SearcherCollection(NoSearchSearcher()) 270 | 271 | NoMatchTypeSearcher = type('NoMatchTypeSearcher', (object,), 272 | {'search': None}) 273 | with self.assertRaises(TypeError): 274 | SearcherCollection(NoMatchTypeSearcher()) 275 | 276 | def test_multi_regex_single_match(self): 277 | uut = SearcherCollection([ 278 | RegexSearcher('omicron'), 279 | RegexSearcher('[eu]psilon'), 280 | ]) 281 | match = uut.search('pi epsilon iota rho') 282 | self.assertIsNotNone(match) 283 | self.assertEqual(1, uut.index(match.searcher)) 284 | self.assertEqual(3, match.start) 285 | self.assertEqual(10, match.end) 286 | 287 | def test_multi_regex_multi_match(self): 288 | uut = SearcherCollection([ 289 | RegexSearcher(u('omicron')), 290 | RegexSearcher(u('[eu]psilon')), 291 | TextSearcher(u('pi')), 292 | TextSearcher(u('iota')), 293 | ]) 294 | match = uut.search(u('pi iota epsilon upsilon omicron')) 295 | self.assertIsNotNone(match) 296 | self.assertEqual(2, uut.index(match.searcher)) 297 | self.assertEqual(0, match.start) 298 | self.assertEqual(2, match.end) 299 | 300 | def test_search_wrong_type(self): 301 | uut = SearcherCollection([ 302 | RegexSearcher(b'omicron'), 303 | RegexSearcher(b'[eu]psilon'), 304 | ]) 305 | with self.assertRaises(TypeError): 306 | uut.search(u('pi omicron mu')) 307 | 308 | def test_repr(self): 309 | # Only check no exceptions thrown 310 | searcher = SearcherCollection([TextSearcher(u('epsilon')), 311 | RegexSearcher(u('[eu]psilon'))]) 312 | repr(searcher) 313 | 314 | 315 | class TestStreamAdapter(unittest.TestCase): 316 | 317 | def test_constructor(self): 318 | with self.assertRaises(NotImplementedError): 319 | StreamAdapter(None).poll(10) 320 | 321 | def test_delegation(self): 322 | stream = io.StringIO(u('hello')) 323 | adapter = StreamAdapter(stream) 324 | self.assertEqual('hello', adapter.getvalue()) 325 | 326 | def test_repr(self): 327 | # Only check no exceptions thrown 328 | adapter = StreamAdapter(None) 329 | repr(adapter) 330 | 331 | 332 | class TestPollingStreamAdapter(unittest.TestCase): 333 | 334 | def test_constructor(self): 335 | stream = io.StringIO() 336 | adapter = PollingStreamAdapter(stream, poll_period=1, max_read=32) 337 | self.assertEqual(1, adapter.poll_period) 338 | self.assertEqual(32, adapter.max_read) 339 | 340 | def test_bad_property_values(self): 341 | stream = io.StringIO() 342 | adapter = PollingStreamAdapter(stream, poll_period=1, max_read=32) 343 | with self.assertRaises(ValueError): 344 | adapter.poll_period = 0 345 | with self.assertRaises(ValueError): 346 | adapter.max_read = -1 347 | 348 | def test_poll(self): 349 | stream = PiecewiseStream(b'alpha beta gamma omega', max_chunk=5) 350 | with testfixtures.Replacer() as r: 351 | mock_time = testfixtures.test_time(delta=0.1, delta_type='seconds') 352 | r.replace('streamexpect.time.time', mock_time) 353 | adapter = PollingStreamAdapter(stream) 354 | for chunk in (b'alpha', b' beta', b' gamm', b'a ome'): 355 | self.assertEqual(chunk, adapter.poll(1.0)) 356 | 357 | def test_timeout(self): 358 | with testfixtures.Replacer() as r: 359 | mock_time = testfixtures.test_time(delta=0.1, delta_type='seconds') 360 | r.replace('streamexpect.time.time', mock_time) 361 | r.replace('streamexpect.time.sleep', lambda _: None) 362 | stream = EmptyStream() 363 | adapter = PollingStreamAdapter(stream) 364 | with self.assertRaises(ExpectTimeout): 365 | adapter.poll(1) 366 | 367 | 368 | class TestPollingSocketStreamAdapter(unittest.TestCase): 369 | 370 | def test_constructor(self): 371 | sock = socket.socket() 372 | try: 373 | adapter = PollingSocketStreamAdapter(sock, poll_period=1, 374 | max_read=32) 375 | self.assertEqual(1, adapter.poll_period) 376 | self.assertEqual(32, adapter.max_read) 377 | finally: 378 | sock.close() 379 | 380 | def test_bad_property_values(self): 381 | sock = socket.socket() 382 | adapter = PollingSocketStreamAdapter(sock, poll_period=1, max_read=32) 383 | with self.assertRaises(ValueError): 384 | adapter.poll_period = 0 385 | with self.assertRaises(ValueError): 386 | adapter.max_read = -1 387 | sock.close() 388 | 389 | def test_poll(self): 390 | source, drain = socket.socketpair() 391 | try: 392 | with testfixtures.Replacer() as r: 393 | mock_time = testfixtures.test_time(delta=0.1, 394 | delta_type='seconds') 395 | r.replace('streamexpect.time.time', mock_time) 396 | adapter = PollingSocketStreamAdapter(drain) 397 | for chunk in (b'alpha', b' beta', b' gamm', b'a ome'): 398 | source.send(chunk) 399 | self.assertEqual(chunk, adapter.poll(1.0)) 400 | finally: 401 | source.close() 402 | drain.close() 403 | 404 | def test_timeout(self): 405 | source, drain = socket.socketpair() 406 | try: 407 | with testfixtures.Replacer() as r: 408 | mock_time = testfixtures.test_time(delta=0.1, 409 | delta_type='seconds') 410 | r.replace('streamexpect.time.time', mock_time) 411 | adapter = PollingSocketStreamAdapter(drain) 412 | with self.assertRaises(ExpectTimeout): 413 | adapter.poll(0.01) 414 | finally: 415 | source.close() 416 | drain.close() 417 | 418 | 419 | class TestExpecter(unittest.TestCase): 420 | 421 | class NoPollMethod(object): 422 | pass 423 | 424 | def test_constructor(self): 425 | adapter = PollingStreamAdapter(PiecewiseStream(u(''))) 426 | expecter = Expecter(adapter, input_callback=None, window=1024, 427 | close_adapter=False) 428 | self.assertEqual(1024, expecter.window) 429 | self.assertTrue(expecter.stream_adapter is adapter) 430 | self.assertEqual('', expecter.read(10)) 431 | self.assertFalse(expecter.close_adapter) 432 | 433 | def test_bad_attributes(self): 434 | adapter = PollingStreamAdapter(PiecewiseStream(u('tau iota mu'))) 435 | with self.assertRaises(TypeError): 436 | Expecter(adapter, input_callback=None, window=None, 437 | close_adapter=False) 438 | with self.assertRaises(ValueError): 439 | Expecter(adapter, input_callback=None, window=-22, 440 | close_adapter=False) 441 | with self.assertRaises(NotImplementedError): 442 | Expecter(adapter, input_callback=None, window=1024, 443 | close_adapter=False).expect(None, 0) 444 | with self.assertRaises(TypeError): 445 | Expecter(TestExpecter.NoPollMethod(), input_callback=None, 446 | window=1024, close_adapter=False) 447 | 448 | 449 | class TestWrapper(unittest.TestCase): 450 | 451 | def test_expect_bytes(self): 452 | source, drain = socket.socketpair() 453 | try: 454 | wrapper = streamexpect.wrap(drain, unicode=False) 455 | source.sendall(b'tau iota mu') 456 | match = wrapper.expect_bytes(b'iota') 457 | self.assertTrue(match is not None) 458 | self.assertEqual(b'iota', match.match) 459 | finally: 460 | source.close() 461 | drain.close() 462 | 463 | def test_expect_text(self): 464 | stream = PiecewiseStream(u('tau iota mu'), max_chunk=3) 465 | wrapper = streamexpect.wrap(stream, unicode=True) 466 | match = wrapper.expect_text(u('iota')) 467 | self.assertTrue(match is not None) 468 | self.assertEqual(u('iota'), match.match) 469 | 470 | def test_expect_bytes_twice_on_one_buffer(self): 471 | source, drain = socket.socketpair() 472 | try: 473 | wrapper = streamexpect.wrap(drain, unicode=False) 474 | source.sendall(b'tau iota mu') 475 | match = wrapper.expect_bytes(b'iota') 476 | self.assertTrue(match is not None) 477 | self.assertEqual(b'iota', match.match) 478 | match = wrapper.expect_bytes(b'mu') 479 | self.assertTrue(match is not None) 480 | self.assertEqual(b'mu', match.match) 481 | finally: 482 | source.close() 483 | drain.close() 484 | 485 | def test_expect_bytes_twice_on_split_buffer_with_small_window(self): 486 | source, drain = socket.socketpair() 487 | try: 488 | wrapper = streamexpect.wrap(drain, unicode=False, window=8) 489 | source.sendall(b'tau iota m') 490 | match = wrapper.expect_bytes(b'iota') 491 | self.assertTrue(match is not None) 492 | self.assertEqual(b'iota', match.match) 493 | source.sendall(b'u tau iota') 494 | match = wrapper.expect_bytes(b'mu') 495 | self.assertTrue(match is not None) 496 | self.assertEqual(b'mu', match.match) 497 | finally: 498 | source.close() 499 | drain.close() 500 | 501 | def test_expect_text_twice(self): 502 | stream = PiecewiseStream(u('tau iota mu'), max_chunk=3) 503 | wrapper = streamexpect.wrap(stream, unicode=True) 504 | match = wrapper.expect_text(u('iota')) 505 | self.assertTrue(match is not None) 506 | self.assertEqual(u('iota'), match.match) 507 | match = wrapper.expect_text(u('mu')) 508 | self.assertTrue(match is not None) 509 | self.assertEqual(u('mu'), match.match) 510 | 511 | def test_expect_text_twice_with_small_window(self): 512 | stream = PiecewiseStream(u('tau iota epsilon mu'), max_chunk=20) 513 | wrapper = streamexpect.wrap(stream, unicode=True, window=8) 514 | match = wrapper.expect_text(u('iota')) 515 | self.assertTrue(match is not None) 516 | self.assertEqual(u('iota'), match.match) 517 | match = wrapper.expect_text(u('mu')) 518 | self.assertTrue(match is not None) 519 | self.assertEqual(u('mu'), match.match) 520 | 521 | def test_expect_unicode_regex(self): 522 | stream = PiecewiseStream(u('pi epsilon mu'), max_chunk=3) 523 | wrapper = streamexpect.wrap(stream, unicode=True) 524 | match = wrapper.expect_regex(u('[eu]psilon')) 525 | self.assertTrue(match is not None) 526 | self.assertEqual(u('epsilon'), match.match) 527 | 528 | def test_echo_bytes(self): 529 | stream = PiecewiseStream(b'pi epsilon mu') 530 | wrapper = streamexpect.wrap(stream, unicode=False, echo=True) 531 | with testfixtures.OutputCapture() as output: 532 | match = wrapper.expect_regex(b'[eu]psilon') 533 | output.compare('pi epsilon mu') 534 | self.assertTrue(match is not None) 535 | self.assertEqual(b'epsilon', match.match) 536 | 537 | def test_echo_text(self): 538 | stream = PiecewiseStream(u('pi epsilon mu')) 539 | wrapper = streamexpect.wrap(stream, unicode=True, echo=True) 540 | with testfixtures.OutputCapture() as output: 541 | match = wrapper.expect_regex(u('[eu]psilon')) 542 | output.compare('pi epsilon mu') 543 | self.assertTrue(match is not None) 544 | self.assertEqual(u('epsilon'), match.match) 545 | 546 | def test_context_manager(self): 547 | stream = EmptyStream() 548 | with streamexpect.wrap(stream, close_stream=True): 549 | pass 550 | self.assertTrue(stream.closed) 551 | 552 | def test_context_manager_no_close_stream(self): 553 | stream = EmptyStream() 554 | with streamexpect.wrap(stream, close_stream=False): 555 | pass 556 | self.assertFalse(stream.closed) 557 | 558 | def test_unhandled_type(self): 559 | with self.assertRaises(TypeError): 560 | streamexpect.wrap(b'') 561 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion=2.0 3 | envlist = py{27,33,34,35,37,39,310}, pypy, pypy3 4 | 5 | [testenv] 6 | deps = 7 | pytest 8 | testfixtures>=4.1 9 | commands = pytest 10 | passenv = TRAVIS* 11 | 12 | [testenv:coverage] 13 | deps = 14 | pytest 15 | pytest-cov 16 | testfixtures>=4.1 17 | coveralls 18 | commands = 19 | pytest --cov=streamexpect --cov-branch 20 | coveralls 21 | 22 | [testenv:docs] 23 | deps = 24 | sphinx 25 | commands = python setup.py build_sphinx 26 | 27 | [testenv:style] 28 | deps = 29 | flake8 30 | mccabe 31 | pep8-naming 32 | commands = python setup.py flake8 33 | --------------------------------------------------------------------------------