├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENCE.txt ├── MANIFEST.in ├── Makefile ├── README.md ├── loadimpactcli ├── __init__.py ├── client.py ├── config.py ├── datastore_commands.py ├── errors.py ├── loadimpact_cli.py ├── metric_commands.py ├── organization_commands.py ├── test_commands.py ├── userscenario_commands.py ├── util.py └── version.py ├── requirements.txt ├── requirements_dev.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── datastore.csv ├── script ├── test_datastore_commands.py ├── test_metric_commands.py ├── test_organization_commands.py ├── test_test_commands.py └── test_userscenario_commands.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .cache 3 | tests/.cache 4 | pytestdebug.log 5 | loadimpact_cli.egg-info/ 6 | build 7 | .eggs 8 | dist -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | - "3.4" 6 | - "3.5" 7 | install: 8 | - pip install -r requirements.txt 9 | - pip install -r requirements_dev.txt 10 | - python setup.py -q install 11 | - pip install coverage==3.7.1 12 | - pip install coveralls 13 | script: 14 | - coverage run --source=loadimpactcli setup.py test 15 | after_success: 16 | - coveralls 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.2.3 (2018-02-21) 4 | 5 | - Bump version of Load Impact SDK dependency that includes some fixes 6 | 7 | ## v1.2.2 (2018-02-15) 8 | 9 | - Bump version of Load Impact SDK dependency that includes some fixes 10 | 11 | ## v1.2.1 (2018-02-14) 12 | 13 | - When running test, pressing Ctrl+C will now abort the test run 14 | - When running test API errors will be ignored by default, option `--no-ignore-errors` has been added to `loadimpact test run` command to implement old behavior 15 | 16 | ## v1.2.0 (2017-10-23) 17 | 18 | - CLI will exit with non-zero exit code if test run (`loadimpact test run ...`) finishes with failure status 19 | 20 | ## v1.1.1 (2017-10-04) 21 | 22 | - Update requirements of dependency loadimpact-v3 23 | 24 | ## v1.1.0 (2017-10-04) 25 | 26 | - Add support for listing and running tests 27 | - Add support for listing metrics of a test run 28 | 29 | ## v1.0.4 (2016-03-21) 30 | 31 | More fixes to configs. 32 | 33 | ## v1.0.3 (2016-03-21) 34 | 35 | Fixes to configs 36 | 37 | ## v1.0.2 (2016-03-10) 38 | 39 | Fixes to running tests. 40 | 41 | ## v1.0.1 (2016-03-10) 42 | 43 | Fixes to setup.py 44 | 45 | ## v1.0.0 (2016-03-09) 46 | 47 | First BETA release. 48 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include loadimpactcli * 3 | global-exclude __pycache__ *.pyc -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-pyc clean-test test coverage 2 | define BROWSER_PYSCRIPT 3 | import os, webbrowser, sys 4 | try: 5 | from urllib import pathname2url 6 | except: 7 | from urllib.request import pathname2url 8 | 9 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 10 | endef 11 | export BROWSER_PYSCRIPT 12 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 13 | 14 | help: 15 | @echo "clean - remove all build, test, coverage and Python artifacts" 16 | @echo "clean-pyc - remove Python file artifacts" 17 | @echo "clean-test - remove test and coverage artifacts" 18 | @echo "test - run tests quickly with the default Python" 19 | @echo "coverage - check code coverage quickly with the default Python" 20 | 21 | clean: clean-pyc clean-test 22 | 23 | clean-pyc: 24 | find . -name '*.pyc' -exec rm -f {} + 25 | find . -name '*.pyo' -exec rm -f {} + 26 | find . -name '*~' -exec rm -f {} + 27 | find . -name '__pycache__' -exec rm -fr {} + 28 | 29 | clean-test: 30 | rm -f .coverage 31 | rm -fr htmlcov/ 32 | 33 | test: 34 | python setup.py test 35 | 36 | coverage: 37 | coverage run --source loadimpactcli setup.py test 38 | coverage report -m 39 | coverage html 40 | $(BROWSER) htmlcov/index.html 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Load Impact CLI [![Build Status](https://travis-ci.org/loadimpact/loadimpact-cli.png?branch=master,develop)](https://travis-ci.org/loadimpact/loadimpact-cli) [![Coverage Status](https://coveralls.io/repos/loadimpact/loadimpact-cli/badge.svg?branch=develop&service=github)](https://coveralls.io/github/loadimpact/loadimpact-cli?branch=develop) 2 | 3 | This is the Command line interface for Load Impact API version 3 and Load Impact Version 3.0 (Lua based scripting). If you are looking for the CLI for version 4.0, please refer to the [k6 github repo.](https://github.com/loadimpact/k6) 4 | 5 | The CLI can perform the following operations: 6 | - user scenarios: listing, creating, retrieving, validating, updating, deleting 7 | - data stores: listing, downloading, creating 8 | - tests: listing, running 9 | - metrics: list 10 | - For all other use cases, reference the REST API at http://developer.loadimpact.com/api/index.html 11 | 12 | ## Install 13 | 14 | [![PyPI](https://img.shields.io/pypi/v/loadimpact-cli.svg)](https://pypi.python.org/pypi/loadimpact-cli) [![PyPI](https://img.shields.io/pypi/dm/loadimpact-cli.svg)](https://pypi.python.org/pypi/loadimpact-cli) 15 | 16 | As a general recommendation, install `loadimpact-cli` in a `virtualenv` and make sure you use a recently upgraded `pip` install. See [issue #13](https://github.com/loadimpact/loadimpact-cli/issues/13) for some ideas if you are having trouble installing. 17 | 18 | ### Install using setup.py 19 | 20 | ``` 21 | cd loadimpact-cli 22 | python setup.py install 23 | ``` 24 | 25 | ### Install using pip 26 | 27 | ``` 28 | pip install loadimpact-cli 29 | ``` 30 | 31 | ## Configuration 32 | 33 | Before running the CLI you need to add your [Load Impact V3 API token](https://app.loadimpact.com/integrations/user-token) to the config file. You can generate a Load Impact V3 API token selecting CLI from the integrations page. 34 | 35 | The config file will be placed: 36 | 37 | For MacOSX: 38 | 39 | ``` 40 | /Users//Library/Application Support/LoadImpact/config.ini 41 | ``` 42 | 43 | For Linux: 44 | 45 | ``` 46 | ~/.config/LoadImpact/config.ini 47 | ``` 48 | 49 | For Windows: 50 | 51 | ``` 52 | \AppData\LoadImpact\config.ini 53 | ``` 54 | 55 | The config file should look like this: 56 | 57 | ``` 58 | [user_settings] 59 | api_token=your_api_token 60 | ``` 61 | 62 | You can also specify the default project you want to work with here by adding the id of the project you wish to work with: 63 | 64 | ``` 65 | [user_settings] 66 | api_token=your_api_token 67 | default_project=2 68 | ``` 69 | 70 | Optionally, if you don't want to edit the config-file you can set default project and api key as environment variables instead: 71 | 72 | 73 | ``` 74 | export LOADIMPACT_API_V3_TOKEN='your_api_token' 75 | export LOADIMPACT_DEFAULT_PROJECT=1 76 | ``` 77 | 78 | ## Running the cli 79 | 80 | ``` 81 | $ loadimpact 82 | Usage: loadimpact [OPTIONS] COMMAND [ARGS]... 83 | 84 | Options: 85 | --version Show the version and exit. 86 | --help Show this message and exit. 87 | 88 | Commands: 89 | data-store 90 | metric 91 | organization 92 | test 93 | user-scenario 94 | ``` 95 | 96 | ## Working with organizations and projects 97 | 98 | #### Listing organizations 99 | 100 | ``` 101 | $ loadimpact organization list 102 | ``` 103 | 104 | 105 | #### Listing the projects of an organization 106 | 107 | Listing the projects of an organization with id 1. This will help you find the project you want to use as default project. 108 | 109 | ``` 110 | $ loadimpact organization projects 1 111 | ``` 112 | 113 | ## Working with User Scenarios scripts 114 | 115 | A [User Scenario](http://support.loadimpact.com/knowledgebase/articles/174287-what-is-a-user-scenario) is a object that contains a script that defines your user behavior. This script should be written in Lua. 116 | 117 | #### Listing the User scenarios in a project. 118 | 119 | In order to list the User scenarios in a project you need to specify a project id. Either you add this to the config or export them as an environment variable as mentioned above. 120 | 121 | ``` 122 | $ loadimpact user-scenario list 123 | 124 | ``` 125 | 126 | Or you can add it using the project_id-flag. 127 | 128 | ``` 129 | $ loadimpact user-scenario list --project_id=1 130 | 131 | ``` 132 | 133 | Listing the user-scenarios prints the scripts of the user-scenarios of the specified projects. 134 | 135 | #### Creating a new User Scenario 136 | 137 | In order to create a new User Scenario you need to have specified the project you want the User scenario to belong to in some of the ways mentioned under "Listing the User scenarios in a project." 138 | 139 | You also need to specify the path to the script file you want to create the User scenario with. 140 | 141 | 142 | ``` 143 | $ loadimpact user-scenario create /path/to/script.lua 'script name' --project_id=1 144 | 145 | 146 | ``` 147 | 148 | You can also add a Data store to your user-scenario, either by using the id of an existing one: 149 | 150 | ``` 151 | $ loadimpact data-store list 152 | ID: NAME: 153 | 43 Load Impact Basic 154 | 23 Fake Customers 155 | $ loadimpact user-scenario create /path/to/script.lua 'script name' --project_id=1 --datastore_id=43 156 | 157 | 158 | ``` 159 | 160 | Or you can add a new Data store file using ```--datastore_file```, this will create a new Data store in the project and add it to the User scenario. 161 | 162 | ``` 163 | $ loadimpact user-scenario create /path/to/script.lua 'script name' --project_id=1 --datastore_file=/path/to/datastore.csv 164 | 165 | ``` 166 | 167 | #### Getting a User Scenario. 168 | 169 | To get a User scenario script you'll need the id of that user scenario. 170 | 171 | ``` 172 | $ loadimpact user-scenario get 1 173 | 174 | ``` 175 | #### Updating a User Scenario 176 | 177 | To update the script of a User scenario you need to specify the id of the scenario you wan't to update and the script you want to upload for it. 178 | 179 | ``` 180 | $ loadimpact user-scenario update 1 /path/to/script.lua 181 | 182 | ``` 183 | 184 | #### Validating a User Scenario 185 | 186 | In order to be able to use a script it has to be valid, you can check if a script is valid by using the command validate. This will validate your script row by row. Please note that this command can takes some time to finish as we actually fire the script up and send some requests. 187 | 188 | ``` 189 | $ loadimpact user-scenario validate 1 190 | 191 | ``` 192 | 193 | #### Deleting a User Scenario 194 | 195 | You can delete a User Scenario with the delete command. This will delete the entire User Scenario, not just remove the script. Since this is a destructive action you'll need to verify it. 196 | 197 | ``` 198 | $ loadimpact user-scenario delete 1 199 | 200 | ``` 201 | If you need to bypass the verifying you can add the ```--yes``` flag. 202 | 203 | ``` 204 | $ loadimpact user-scenario delete 1 --yes 205 | 206 | ``` 207 | 208 | ## Working with Data stores 209 | 210 | A Data store contains a .csv-file where you define values you want to use in your test. A Data store can be reused in many tests, making test-setups easier. For example a Data store .csv-file can contain [URL:s](http://support.loadimpact.com/knowledgebase/articles/174987-random-url-from-a-data-store) for a test. 211 | 212 | #### Listing Data stores 213 | 214 | The ```data-store list``` command lists the Data stores in the project. 215 | 216 | ``` 217 | $ loadimpact data-store list 218 | 219 | ``` 220 | 221 | #### Downloading a Data store 222 | 223 | The ```data-store download``` command will download the .csv-file of the specified Data store. If the ```--file_name```-flag is omitted the file will be saved in the current directory as DATA-STORE-ID.csv 224 | 225 | ``` 226 | $ loadimpact data-store download 1 --file_name /path/where/you/want/file.csv 227 | 228 | ``` 229 | 230 | #### Creating a Data store 231 | The ```data-store create``` command will create a new Data store containing the file specified. 232 | 233 | ``` 234 | $ loadimpact data-store create 'Your Data store name' /path/to/file.csv 235 | ``` 236 | 237 | #### Updating a Data store 238 | The ```data-store update``` command will update the file of the specified Data store. 239 | 240 | ``` 241 | $ loadimpact data-store update 1 /path/to/file.csv 242 | ``` 243 | 244 | #### Deleting a Data store 245 | 246 | The ```data-store delete``` command will delete an existing Data store. Since this is a destructive action you'll need to verify it. 247 | 248 | ``` 249 | $ loadimpact data-store delete 1 250 | 251 | ``` 252 | If you need to bypass the verifying you can add the ```--yes``` flag. 253 | 254 | ``` 255 | $ loadimpact data-store delete 1 --yes 256 | 257 | ``` 258 | 259 | ## Working with Tests 260 | 261 | #### Listing Tests 262 | 263 | The `test list` command lists the Tests you have access to: 264 | ``` 265 | $ loadimpact test list 266 | 267 | ID: NAME: LAST RUN DATE: LAST RUN STATUS: CONFIG: 268 | 123 My test name 2017-01-02 03:04:05 Finished 50 users 2s 269 | 456 My second test 2017-02-03 12:34:56 Aborted by user 10 users 5s; 100 users 60s 270 | ``` 271 | 272 | By default, it will display the first 20 Tests, ordered by their last run 273 | date, from all the Projects from all the Organizations you have access to. 274 | This can be narrowed down by the `--project_id` and the `--limit` flags. For 275 | example, the following command: 276 | ``` 277 | $ loadimpact test list --project_id 100 --project_id 200 --limit 5 278 | ``` 279 | 280 | Will display the five most recent Tests from projects 100 and 200. 281 | 282 | The output truncates the values of several columns (*Name* and *Config*) for 283 | the purposes of readability. This can be overriden by the `--full-width` 284 | argument, which will cause the information to be displayed fully and separated 285 | by tab characters (`\t`). 286 | 287 | #### Running Tests 288 | 289 | The `test run` command launches a Test Run from an existing Test: 290 | ``` 291 | $ loadimpact test run 123 292 | ``` 293 | 294 | The command will periodically print the metrics collected during the test run 295 | (defaulting to VUs, requests/second, bandwidth, VU load time and failure rate) 296 | until the test run is finished: 297 | 298 | ``` 299 | $ loadimpact test run 123 300 | 301 | TEST_RUN_ID: 302 | 456 303 | 304 | 'Initializing test ...' 305 | TIMESTAMP: VUs [1]: reqs/s [1]: bandwidth [1]: user load time [1]: failure rate [1]: 306 | 2017-01-02 03:04:00 5.0 6.626671 89340.2656 - - 307 | 2017-01-02 03:04:03 8.0 3.956092 53336.0410 - - 308 | 2017-01-02 03:04:06 10.0 2.644981 35659.6385 - - 309 | 2017-01-02 03:04:09 15.0 3.970042 53524.1070 - - 310 | 2017-01-02 03:04:12 17.0 2.646911 35685.6541 - - 311 | 2017-01-02 03:04:15 20.0 3.969141 53511.9623 - - 312 | 2017-01-02 03:04:18 22.0 6.613472 89162.8336 235.73 - 313 | 2017-01-02 03:04:21 25.0 5.289569 71313.9719 234.3 - 314 | ... 315 | ``` 316 | 317 | This output can be disabled by the `--quiet` flag. The metrics displayed can 318 | also be selected using the `--metric` flag (in the case of default metrics) or 319 | the `--raw_metric` flag (that allows the passing of parameters for the metrics 320 | as defined the [in the documentation][api-results] directly): 321 | 322 | ``` 323 | $ loadimpact test run 123 --metric bandwidth --raw_metric __li_url_XYZ:1:225:200:GET 324 | ``` 325 | 326 | Please note that the default metrics (the ones selected by using the 327 | `--metric` flag) will by default display the first aggregation function for 328 | the aggregated world load zone. 329 | 330 | The output displays the values using fixed width, truncating if needed, for 331 | the purposes of readability. This can be overriden by the `--full-width` 332 | argument, which will cause the information to be displayed fully and separated 333 | by tab characters (`\t`). 334 | 335 | If the test run finishes with a failure status then the CLI will exit with a 336 | non-zero exit code. This is helpful in combination with [thresholds](http://support.loadimpact.com/knowledgebase/articles/918699-thresholds) 337 | when using the CLI in an automation pipeline using tools and services like 338 | Jenkins, CircleCI, TeamCity etc. 339 | 340 | ## Working with Metrics 341 | 342 | #### Listing Metrics 343 | 344 | The `metric list` command lists the Metrics available for a Test Run: 345 | ``` 346 | $ loadimpact metric list 789 347 | 348 | NAME: ARGUMENT NAME: TYPE: 349 | __li_bandwidth:1 bandwidth common 350 | __li_bandwidth:13 bandwidth common 351 | __li_clients_active:1 clients_active common 352 | __li_clients_active:13 clients_active common 353 | __li_user_load_time:1 user_load_time common 354 | __li_user_load_time:13 user_load_time common 355 | __li_url_69e369c64ef5e40f6fd8566e4163860d:13:225:200:GET - url 356 | __li_url_69e369c64ef5e40f6fd8566e4163860d:1:225:200:GET - url 357 | ... 358 | ``` 359 | 360 | The list of metrics to output can be narrowed down by metric type by using the 361 | `--type` flag: 362 | 363 | ``` 364 | $ loadimpact metric list 789 --type common --type log 365 | ``` 366 | 367 | ## Contribute! 368 | 369 | If you wan't to contribute, please check out the repository and install the dependencys in a virtualenv using pip. The tests can be run with ```setup.py``` 370 | 371 | ``` 372 | $ python setup.py test 373 | 374 | ``` 375 | 376 | If you've found a bug or something isn't working properly, please don't hesitate to create a ticket for us! 377 | 378 | [api-results]: http://developers.loadimpact.com/api/#get-tests-id-results 379 | -------------------------------------------------------------------------------- /loadimpactcli/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | """ 4 | Copyright 2013 Load Impact 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | """ 18 | 19 | from __future__ import absolute_import 20 | 21 | from .loadimpact_cli import * 22 | from .userscenario_commands import * 23 | from .organization_commands import * 24 | from .version import __version__ 25 | -------------------------------------------------------------------------------- /loadimpactcli/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2016 Load Impact 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | import loadimpact3 17 | import click 18 | from loadimpact3.exceptions import UnauthorizedError 19 | 20 | from .version import __version__ 21 | from .config import LOADIMPACT_API_TOKEN 22 | 23 | 24 | try: 25 | client = loadimpact3.ApiTokenClient(api_token=LOADIMPACT_API_TOKEN) 26 | client.user_agent = "LoadImpactCLI/%s" % (__version__) 27 | except UnauthorizedError: 28 | click.echo("Authentication failed") 29 | -------------------------------------------------------------------------------- /loadimpactcli/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2016 Load Impact 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | from six.moves import configparser 18 | from six import raise_from 19 | import errno 20 | import os 21 | from sys import platform 22 | import click 23 | 24 | from .errors import CLIError 25 | 26 | config = configparser.ConfigParser() 27 | home = os.path.expanduser("~") 28 | config_file_path = '' 29 | 30 | # MacOSX 31 | if platform == "darwin": 32 | config_file_path = '{0}/Library/Application Support/LoadImpact/config.ini'.format(home) 33 | 34 | # Linux 35 | if platform == "linux" or platform == "linux2": 36 | config_file_path = '{0}/.config/LoadImpact/config.ini'.format(home) 37 | 38 | # Windows 39 | if platform == "win32": 40 | config_file_path = '{0}\AppData\LoadImpact\config.ini'.format(home) 41 | 42 | 43 | def build_config(): 44 | new_config = configparser.RawConfigParser() 45 | new_config.add_section('user_settings') 46 | return new_config 47 | 48 | 49 | def get_or_create_config_file_path(config_file_path): 50 | """Check if a config file exists globally, otherwise create it.""" 51 | if not os.path.isfile(config_file_path): 52 | print("Creating config file in {0}".format(config_file_path)) 53 | 54 | new_config = build_config() 55 | 56 | path = os.path.dirname(config_file_path) 57 | try: 58 | os.makedirs(path) 59 | except OSError as ex: 60 | if ex.errno == errno.EEXIST and os.path.isdir(path): 61 | pass 62 | else: 63 | raise_from(CLIError("Unable to create directory {0}".format(path)), ex) 64 | with open(config_file_path, 'w') as configfile: 65 | new_config.write(configfile) 66 | 67 | 68 | def get_required_value_from_usersettings(key, env_name): 69 | """Get value from config or env, if the value is not in the config, prompt the user for it.""" 70 | if os.getenv(env_name): 71 | return os.getenv(env_name) 72 | try: 73 | return config.get('user_settings', key) 74 | except configparser.Error: 75 | value = click.prompt('{0}'.format(env_name)) 76 | print("Adding key and value to config at {0}".format(config_file_path)) 77 | with open(config_file_path, 'a') as file: 78 | file.write('\n{0}={1}'.format(key, value)) 79 | return value 80 | 81 | 82 | def get_optional_value_from_usersettings(key, env_name): 83 | """Get value from config or env if it exists.""" 84 | if os.getenv(env_name): 85 | return os.getenv(env_name) 86 | try: 87 | return config.get('user_settings', key) 88 | except configparser.Error: 89 | pass 90 | 91 | 92 | get_or_create_config_file_path(config_file_path) 93 | config.read(config_file_path) 94 | 95 | DEFAULT_PROJECT = get_optional_value_from_usersettings('default_project', 'LOADIMPACT_DEFAULT_PROJECT') 96 | LOADIMPACT_API_TOKEN = get_required_value_from_usersettings('api_token', 'LOADIMPACT_API_V3_TOKEN') 97 | -------------------------------------------------------------------------------- /loadimpactcli/datastore_commands.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Copyright 2016 Load Impact 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | 18 | import click 19 | import requests 20 | import shutil 21 | from time import sleep 22 | 23 | from loadimpact3.exceptions import ConnectionError 24 | from loadimpact3 import DataStore 25 | 26 | from .client import client 27 | from .config import DEFAULT_PROJECT 28 | 29 | 30 | @click.group(name='data-store') 31 | @click.pass_context 32 | def data_store(ctx): 33 | pass 34 | 35 | 36 | @data_store.command('list', short_help='List datastore.') 37 | @click.option('--project_id', default=DEFAULT_PROJECT, envvar='DEFAULT_PROJECT', help='Id of the project to list data stores from.') 38 | def list_datastore(project_id): 39 | 40 | if not project_id: 41 | return click.echo('You need to provide a project id.') 42 | 43 | try: 44 | data_stores = client.list_data_stores(project_id) 45 | click.echo("ID:\tNAME:") 46 | for data_store in data_stores: 47 | click.echo(u"{0}\t{1}".format(data_store.id, data_store.name)) 48 | except ConnectionError: 49 | click.echo("Cannot connect to Load impact API") 50 | 51 | 52 | @data_store.command('download', short_help='Get datastore CSV.') 53 | @click.argument('datastore_id') 54 | @click.option('--file_name', help='Full path of file to save downloaded datastore in.') 55 | def download_csv(datastore_id, file_name): 56 | try: 57 | data_store = client.get_data_store(datastore_id) 58 | file_path = file_name if file_name else "{0}.csv".format(data_store.id) 59 | click.echo("Downloading CSV file, please wait.") 60 | _download_csv(data_store, file_path) 61 | click.echo("Finished download.") 62 | except ConnectionError: 63 | click.echo("Cannot connect to Load impact API") 64 | 65 | 66 | @data_store.command('create', short_help='Create datastore.') 67 | @click.argument('name') 68 | @click.argument('datastore_file', type=click.File('r')) 69 | @click.option('--delimiter', default='double', help='CSV file delimiter.') 70 | @click.option('--separator', default='comma', help='CSV file separator.') 71 | @click.option('--fromline', default=1, help='CSV file read from line') 72 | @click.option('--project_id', default=DEFAULT_PROJECT, envvar='DEFAULT_PROJECT', help='Id of the project to create the data store in.') 73 | def create_datastore(datastore_file, name, project_id, delimiter, separator, fromline): 74 | 75 | if not project_id: 76 | return click.echo('You need to provide a project id.') 77 | try: 78 | data_store_json = { 79 | 'name': name, 80 | 'project_id': project_id, 81 | 'delimiter': delimiter, 82 | 'separator': separator, 83 | 'fromline': fromline, 84 | } 85 | data_store = client.create_data_store(data_store_json, datastore_file) 86 | data_store = _wait_for_conversion(data_store) 87 | 88 | click.echo("Data store conversion completed with status '{0}'".format( 89 | (DataStore.status_code_to_text(data_store.status)))) 90 | 91 | except ConnectionError: 92 | click.echo("Cannot connect to Load impact API") 93 | 94 | 95 | @data_store.command('update', short_help='Update datastore.') 96 | @click.argument('id') 97 | @click.argument('datastore_file', type=click.File('r')) 98 | @click.option('--name', default=None) 99 | @click.option('--delimiter', default='double', help='CSV file delimiter.') 100 | @click.option('--separator', default='comma', help='CSV file separator.') 101 | @click.option('--fromline', default=1, help='CSV file read from line') 102 | @click.option('--project_id', default=DEFAULT_PROJECT, envvar='DEFAULT_PROJECT', help='Project id of the data store') 103 | def update_datastore(id, datastore_file, name, project_id, delimiter, separator, fromline): 104 | if not project_id: 105 | return click.echo('You need to provide a project id.') 106 | try: 107 | data_store = client.get_data_store(id) 108 | file_obj = datastore_file 109 | data_store_json = { 110 | 'name': name if name else data_store.name, 111 | 'project_id': project_id, 112 | 'delimiter': delimiter, 113 | 'separator': separator, 114 | 'fromline': fromline, 115 | } 116 | data_store = client.update_data_store(id, data_store_json, file_obj) 117 | data_store = _wait_for_conversion(data_store) 118 | 119 | click.echo("Data store conversion completed with status '{0}'".format( 120 | (DataStore.status_code_to_text(data_store.status)))) 121 | 122 | except ConnectionError: 123 | click.echo("Cannot connect to Load impact API") 124 | 125 | 126 | @data_store.command('delete', short_help='Delete data-store.') 127 | @click.confirmation_option(help='Are you sure you want to delete the data-store?') 128 | @click.argument('datastore_id') 129 | def delete_datastore(datastore_id): 130 | try: 131 | click.echo(delete_store(datastore_id)) 132 | except ConnectionError: 133 | click.echo("Cannot connect to Load impact API") 134 | 135 | 136 | def _wait_for_conversion(data_store): 137 | while not data_store.has_conversion_finished(): 138 | sleep(3) 139 | return data_store 140 | 141 | 142 | def _download_csv(user_scenario, file_path): 143 | response = requests.get(user_scenario.public_url, stream=True) 144 | if response.status_code == 200: 145 | with open(file_path, 'wb') as f: 146 | response.raw.decode_content = True 147 | shutil.copyfileobj(response.raw, f) 148 | 149 | 150 | def delete_store(datastore_id): 151 | datastore = client.get_data_store(datastore_id) 152 | return datastore.delete() 153 | -------------------------------------------------------------------------------- /loadimpactcli/errors.py: -------------------------------------------------------------------------------- 1 | class CLIError(Exception): 2 | """All Load Impact CLI exceptions derive from this class.""" 3 | -------------------------------------------------------------------------------- /loadimpactcli/loadimpact_cli.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Copyright 2016 Load Impact 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | 18 | import click 19 | 20 | from .userscenario_commands import userscenario 21 | from .organization_commands import organization 22 | from .datastore_commands import data_store 23 | from .test_commands import test 24 | from .metric_commands import metric 25 | from .version import __version__ 26 | 27 | 28 | @click.group() 29 | @click.pass_context 30 | @click.version_option(version=__version__) 31 | def cli(ctx): 32 | pass 33 | 34 | 35 | def run_cli(): 36 | cli.add_command(userscenario) 37 | cli.add_command(organization) 38 | cli.add_command(data_store) 39 | cli.add_command(test) 40 | cli.add_command(metric) 41 | cli() 42 | 43 | if __name__ == '__main__': 44 | run_cli() 45 | -------------------------------------------------------------------------------- /loadimpactcli/metric_commands.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Copyright 2016 Load Impact 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | from operator import attrgetter 18 | 19 | import click 20 | 21 | from loadimpact3.exceptions import ConnectionError 22 | from .client import client 23 | from .util import Metric 24 | 25 | # TestRunResults type codes. 26 | TEXT_TO_TYPE_CODE_MAP = { 27 | 'common': 1, 28 | 'url': 2, 29 | 'live_feedback': 3, 30 | 'log': 4, 31 | 'custom_metric': 5, 32 | 'page': 6, 33 | 'dtp': 7, 34 | 'system': 8, 35 | 'server_metric': 9, 36 | 'integration': 10 37 | } 38 | 39 | 40 | @click.group() 41 | @click.pass_context 42 | def metric(ctx): 43 | pass 44 | 45 | 46 | @metric.command('list', short_help='List metrics for a test run.') 47 | @click.argument('test_run_id') 48 | @click.option('--type', '-t', 'metric_types', multiple=True, type=click.Choice(TEXT_TO_TYPE_CODE_MAP.keys()), 49 | help='Metric type to include on the list.') 50 | def list_metrics(test_run_id, metric_types): 51 | try: 52 | types = ','.join(str(TEXT_TO_TYPE_CODE_MAP[k]) for k in metric_types) 53 | result_ids = client.list_test_run_result_ids(test_run_id, data={'types': types}) 54 | 55 | click.echo('NAME:\tARGUMENT NAME:\tTYPE:') 56 | for result_id in sorted(result_ids, key=attrgetter('type')): 57 | for key, _ in sorted(result_id.ids.items()): 58 | metric_ = Metric.from_raw(key) 59 | 60 | click.echo(u'{0}\t{1}\t{2}'.format(key, 61 | metric_.str_param(), 62 | result_id.results_type_code_to_text(result_id.type))) 63 | except ConnectionError: 64 | click.echo("Cannot connect to Load impact API") 65 | -------------------------------------------------------------------------------- /loadimpactcli/organization_commands.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Copyright 2016 Load Impact 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | 18 | import click 19 | 20 | from loadimpact3.exceptions import ConnectionError 21 | from .client import client 22 | 23 | 24 | @click.group() 25 | @click.pass_context 26 | def organization(ctx): 27 | pass 28 | 29 | 30 | @organization.command('list', short_help='List organizations that the user is a member of.') 31 | def list_organizations(): 32 | try: 33 | orgs = client.list_organizations() 34 | for org in orgs: 35 | click.echo('{0}\t{1}'.format(org.id, org.name)) 36 | except ConnectionError: 37 | click.echo("Cannot connect to Load impact API") 38 | 39 | 40 | @organization.command('projects', short_help='List the projects of an organization the user is a member of.') 41 | @click.argument('organization_id') 42 | def list_organization_projects(organization_id): 43 | try: 44 | projects = client.list_organization_projects(organization_id) 45 | for project in projects: 46 | click.echo('{0}\t{1}'.format(project.id, project.name)) 47 | except ConnectionError: 48 | click.echo("Cannot connect to Load impact API") 49 | -------------------------------------------------------------------------------- /loadimpactcli/test_commands.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Copyright 2016 Load Impact 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | from operator import attrgetter, methodcaller 18 | 19 | import click 20 | import sys 21 | 22 | from loadimpact3.resources import TestRun 23 | from loadimpact3.exceptions import ConnectionError 24 | from .client import client 25 | from .util import TestRunStatus, Metric, DefaultMetricType, ColumnFormatter 26 | 27 | 28 | @click.group() 29 | @click.pass_context 30 | def test(ctx): 31 | pass 32 | 33 | 34 | @test.command('list', short_help='List tests.') 35 | @click.option('--project_id', 'project_ids', multiple=True, help='Id of the project to list tests from.') 36 | @click.option('--limit', 'display_limit', default=20, help='Maximum number of tests to display.') 37 | @click.option('--full_width', 'full_width', is_flag=True, help='Display the full contents of each column.') 38 | def list_tests(project_ids, display_limit, full_width): 39 | try: 40 | if not project_ids: 41 | # If no project_id is specified, retrieve all projects the user has access to. 42 | orgs = client.list_organizations() 43 | projs = [] 44 | for org in orgs: 45 | projs.extend(client.list_organization_projects(org_id=org.id)) 46 | project_ids = [proj.id for proj in projs] 47 | 48 | tests = [] 49 | for id_ in set(project_ids): 50 | tests.extend(client.list_tests(project_id=id_)) 51 | 52 | # Output formatting. 53 | formatter = get_list_tests_formatter(full_width, tests) 54 | click.echo(formatter.format('ID:', 'NAME:', 'LAST RUN DATE:', 'LAST RUN STATUS:', 'CONFIG:')) 55 | 56 | # Display the tests sorted by descending test_run ID, and limit the 57 | # list according to the display_limit argument. 58 | tests = sorted(tests, key=attrgetter('last_test_run_id'), reverse=True) 59 | for test_ in tests[:display_limit]: 60 | last_run_date = last_run_status = '-' 61 | if test_.last_test_run_id: 62 | last_run = client.get_test_run(test_.last_test_run_id) 63 | last_run_date = last_run.queued 64 | last_run_status_text = last_run.status_text if full_width else '{:22}'.format(last_run.status_text) 65 | last_run_status = click.style(last_run_status_text, fg=TestRunStatus(last_run.status).style.value) 66 | 67 | click.echo(formatter.format(test_.id, test_.name, last_run_date, last_run_status, 68 | summarize_config(test_.config))) 69 | 70 | if len(tests) > display_limit: 71 | click.echo("Only the first {0} tests (out of {1}) are displayed. This behaviour can be" 72 | "changed using the --limit argument.".format(display_limit, len(tests))) 73 | except ConnectionError: 74 | click.echo("Cannot connect to Load impact API") 75 | 76 | 77 | @test.command('run', short_help='Run a test.') 78 | @click.argument('test_id') 79 | @click.option('--no-ignore-errors', is_flag=True, default=False, 80 | help='Print any errors returned by API while streaming results.') 81 | @click.option('--quiet/--no-quiet', default=False, help='Disable streaming of metrics to stdout.') 82 | @click.option('--metric', 'standard_metrics', multiple=True, 83 | help='Name of the standard metric to stream (implies aggregated world load zone).', 84 | type=click.Choice([m.name.lower() for m in list(DefaultMetricType)])) 85 | @click.option('--raw_metric', 'raw_metrics', multiple=True, help='Raw name of the metric to stream.') 86 | @click.option('--full_width', 'full_width', is_flag=True, help='Display the full contents of each column.') 87 | def run_test(test_id, no_ignore_errors, quiet, standard_metrics, raw_metrics, full_width): 88 | try: 89 | test_ = client.get_test(test_id) 90 | test_run = test_.start_test_run() 91 | click.echo('TEST_RUN_ID:\n{0}'.format(test_run.id)) 92 | 93 | try: 94 | if not quiet: 95 | # Prepare metrics. 96 | metrics = sorted([Metric.from_raw(m) for m in standard_metrics + raw_metrics], 97 | key=methodcaller('str_raw', True)) 98 | if not metrics: 99 | metrics = [Metric(DefaultMetricType.CLIENTS_ACTIVE, ['1']), 100 | Metric(DefaultMetricType.REQUESTS_PER_SECOND, ['1']), 101 | Metric(DefaultMetricType.BANDWIDTH, ['1']), 102 | Metric(DefaultMetricType.USER_LOAD_TIME, ['1']), 103 | Metric(DefaultMetricType.FAILURE_RATE, ['1'])] 104 | 105 | # Output formatting. 106 | formatter = get_run_test_formatter(full_width, metrics) 107 | 108 | stream = test_run.result_stream([m.str_raw(True) for m in metrics], raise_api_errors=no_ignore_errors) 109 | click.echo('Initializing test ...') 110 | 111 | for i, data in enumerate(stream(poll_rate=3)): 112 | if i % 20 == 0: 113 | click.echo(pprint_header(formatter, metrics)) 114 | click.echo(pprint_row(formatter, data, metrics)) 115 | 116 | if test_run.status in [TestRun.STATUS_ABORTED_SYSTEM, TestRun.STATUS_ABORTED_SCRIPT_ERROR, 117 | TestRun.STATUS_FAILED_THRESHOLD, TestRun.STATUS_ABORTED_THRESHOLD]: 118 | sys.exit(test_run.status) # We return status as exit code 119 | except KeyboardInterrupt: 120 | click.echo("Aborting test run!") 121 | test_run.abort() 122 | 123 | except ConnectionError: 124 | click.echo("Cannot connect to Load impact API") 125 | sys.exit(1) 126 | 127 | 128 | def get_list_tests_formatter(full_width, tests): 129 | """ 130 | Returns a `ColumnFormatter` with sensible values for the column widths for 131 | the `tests list` command. 132 | """ 133 | if full_width: 134 | return ColumnFormatter([0] * 5, '\t') 135 | else: 136 | # Setup the formatter using sensible column widths for each field. 137 | column_widths = (max(len('ID:'), max([len(str(t.id)) for t in tests])), 138 | 32, # width for test name (arbitrary) 139 | 25, # last run date (YYYY-MM-DD HH:mm:ss+ZZ:zz) 140 | 22, # len('Aborted (by threshold)') 141 | 64 # width for configuration (arbitrary) 142 | ) 143 | return ColumnFormatter(column_widths, ' ') 144 | 145 | 146 | def get_run_test_formatter(full_width, metrics): 147 | """ 148 | Returns a `ColumnFormatter` with sensible values for the column widths for 149 | the `test list` command. 150 | """ 151 | if full_width: 152 | return ColumnFormatter([0] * (len(metrics) + 1), '\t') 153 | else: 154 | # Setup the formatter using sensible column widths for each field. 155 | column_widths = (25, # timestamp (YYYY-MM-DD HH:mm:ss+ZZ:zz) 156 | ) + tuple(max(16, len(m.str_ui(True)) + 1) for m in metrics) 157 | 158 | return ColumnFormatter(column_widths, ' ') 159 | 160 | 161 | def pprint_header(formatter, returned_metrics): 162 | """ 163 | Return a pretty printed string with the header of the metric streaming 164 | output, consisting of the name of the metrics including the parameters. 165 | """ 166 | return formatter.format(*([u'TIMESTAMP:'] + [u'{0}:'.format(m.str_ui(True)) for m in returned_metrics])) 167 | 168 | 169 | def pprint_row(formatter, data, returned_metrics): 170 | """ 171 | Return a pretty printed string with a row of the metric streaming 172 | output, consisting of the values of the metrics. 173 | """ 174 | try: 175 | parts = [u'{0}'.format(next(iter(data.items()))[1].timestamp)] 176 | except (StopIteration, AttributeError): 177 | return u'' 178 | 179 | for m in returned_metrics: 180 | try: 181 | parts.append(u'{0}'.format(data[m.str_raw(True)].value)) 182 | except KeyError: 183 | parts.append(u'-') 184 | 185 | return formatter.format(*parts) 186 | 187 | 188 | def summarize_config(config): 189 | try: 190 | str_schedules = [u'{0} users {1}s'.format(schedule[u'users'], schedule[u'duration']) 191 | for schedule in config[u'load_schedule']] 192 | 193 | return '; '.join(str_schedules) 194 | 195 | except (KeyError, IndexError, ValueError, TypeError): 196 | # Do not attempt to show the configuration, for UI-conciseness purposes. 197 | return '-' 198 | -------------------------------------------------------------------------------- /loadimpactcli/userscenario_commands.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Copyright 2016 Load Impact 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | 18 | from time import sleep 19 | import click 20 | from tzlocal import get_localzone 21 | 22 | from loadimpact3.exceptions import ConnectionError 23 | 24 | from .client import client 25 | from .config import DEFAULT_PROJECT 26 | 27 | 28 | @click.group(name='user-scenario') 29 | @click.pass_context 30 | def userscenario(ctx): 31 | pass 32 | 33 | 34 | @userscenario.command('get', short_help='Get user-scenario.') 35 | @click.argument('id') 36 | def get_scenario(id): 37 | try: 38 | user_scenario = client.get_user_scenario(id) 39 | click.echo(user_scenario.script) 40 | except ConnectionError: 41 | click.echo("Cannot connect to Load impact API") 42 | 43 | 44 | @userscenario.command('list', short_help='List user-scenarios.') 45 | @click.option('--project_id', default=DEFAULT_PROJECT, envvar='DEFAULT_PROJECT', help='Id of the project to list scenarios from.') 46 | def list_scenarios(project_id): 47 | if not project_id: 48 | return click.echo('You need to provide a project id.') 49 | try: 50 | userscenarios = client.list_user_scenarios(project_id) 51 | click.echo("ID:\tNAME:") 52 | for userscenario in userscenarios: 53 | click.echo(u'{0}\t{1}'.format(userscenario.id, userscenario.name)) 54 | except ConnectionError: 55 | click.echo("Cannot connect to Load impact API") 56 | 57 | 58 | @userscenario.command('create', short_help='Create user-scenario.') 59 | @click.argument('script_file', type=click.File('r')) 60 | @click.argument('name') 61 | @click.option('--project_id', default=DEFAULT_PROJECT, envvar='DEFAULT_PROJECT', help='Id of the project the scenario should be in.') 62 | @click.option('--datastore_file', type=click.File('r'), multiple=True, help='A CSV file to be used as a new data store for the user scenario. The file is read from line 1 expecting comma (,) as a separator and double quotes (") as a delimiter and the name of the file is used as a name for the data store. Multiple files can be provided by repeating the option.') 63 | @click.option('--datastore_id', type=int, multiple=True, help='The ID of an existing data store to be linked to the user scenario. Multiple IDs can be provided by repeating the option.') 64 | def create_scenario(script_file, name, project_id, datastore_file, datastore_id): 65 | if not project_id: 66 | return click.echo('You need to provide a project id.') 67 | script = read_file(script_file) 68 | data = { 69 | u"name": name, 70 | u"script": script, 71 | u"project_id": project_id 72 | } 73 | data_store_ids = [] 74 | 75 | if datastore_file: 76 | for data_store_file in datastore_file: 77 | data_store_json = { 78 | 'name': data_store_file.name, 79 | 'project_id': project_id, 80 | 'delimiter': 'double', 81 | 'separator': 'comma', 82 | 'fromline': 1, 83 | } 84 | try: 85 | data_store = client.create_data_store(data_store_json, data_store_file) 86 | data_store_ids.append(data_store.id) 87 | except ConnectionError: 88 | click.echo("Cannot connect to Load impact API") 89 | 90 | data_store_ids += datastore_id 91 | 92 | if data_store_ids: 93 | data[u"data_store_ids"] = data_store_ids 94 | 95 | try: 96 | click.echo(client.create_user_scenario(data=data).script) 97 | except ConnectionError: 98 | click.echo("Cannot connect to Load impact API") 99 | 100 | 101 | @userscenario.command('update', short_help='Update user-scenario script.') 102 | @click.argument('scenario_id') 103 | @click.argument('script_file', type=click.File('r')) 104 | def update_scenario(scenario_id, script_file): 105 | script = read_file(script_file) 106 | try: 107 | user_scenario = update_user_scenario_script(scenario_id, script) 108 | click.echo(user_scenario.script) 109 | except ConnectionError: 110 | click.echo("Cannot connect to Load impact API") 111 | 112 | 113 | @userscenario.command('delete', short_help='Delete user-scenario.') 114 | @click.confirmation_option(help='Are you sure you want to delete the user-scenario?') 115 | @click.argument('scenario_id') 116 | def delete_scenario(scenario_id): 117 | try: 118 | click.echo(delete_user_scenario(scenario_id)) 119 | except ConnectionError: 120 | click.echo("Cannot connect to Load impact API") 121 | 122 | 123 | @userscenario.command('validate', short_help='Validate user-scenario script.') 124 | @click.argument('scenario_id') 125 | def validate_scenario(scenario_id): 126 | try: 127 | user_scenario = client.get_user_scenario(scenario_id) 128 | validation = get_validation(user_scenario) 129 | validation_results = get_validation_results(validation) 130 | click.echo(get_formatted_validation_results(validation_results)) 131 | except ConnectionError: 132 | click.echo("Cannot connect to Load impact API") 133 | 134 | 135 | def delete_user_scenario(scenario_id): 136 | userscenario = client.get_user_scenario(scenario_id) 137 | return userscenario.delete() 138 | 139 | 140 | def update_user_scenario_script(scenario_id, script): 141 | userscenario = client.get_user_scenario(scenario_id) 142 | userscenario.update_scenario({'script': script}) 143 | return client.get_user_scenario(scenario_id) 144 | 145 | 146 | def get_validation(user_scenario): 147 | validation = user_scenario.validate() 148 | return validation 149 | 150 | 151 | def get_validation_results(validation): 152 | poll_rate = 10 153 | 154 | while not validation.is_done(): 155 | validation = client.get_user_scenario_validation(validation.id) 156 | sleep(poll_rate) 157 | 158 | validation_results = client.get_user_scenario_validation_result(validation.id) 159 | return validation_results 160 | 161 | 162 | def get_formatted_validation_results(validation_results): 163 | formatted_validations = '' 164 | for result in validation_results: 165 | result_in_local_time = get_timestamp_as_local_time(result.timestamp) 166 | result_level_formatted = '' 167 | if result.level: 168 | result_level_formatted = '{0} '.format(result.level) 169 | formatted_validations += u"{0}[{1}] {2}\n".format(result_level_formatted, result_in_local_time, result.message) 170 | return formatted_validations 171 | 172 | 173 | def get_timestamp_as_local_time(timestamp): 174 | return timestamp.astimezone(get_localzone()) 175 | 176 | 177 | def abort_if_false(ctx, param, value): 178 | if not value: 179 | ctx.abort() 180 | 181 | 182 | def read_file(script_file): 183 | read_data = "" 184 | with script_file as f: 185 | read_data = f.read() 186 | return read_data 187 | -------------------------------------------------------------------------------- /loadimpactcli/util.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Copyright 2017 Load Impact 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | 18 | import sys 19 | 20 | from click import unstyle 21 | from enum import Enum 22 | 23 | 24 | class Style(Enum): 25 | """ 26 | Foreground colors used in the UI. 27 | """ 28 | NONE = None 29 | SUCCESS = 'green' 30 | WARNING = 'yellow' 31 | ERROR = 'red' 32 | 33 | 34 | class TestRunStatus(Enum): 35 | """ 36 | Representation of a TestRun status. 37 | """ 38 | STATUS_CREATED = -1 39 | STATUS_QUEUED = 0 40 | STATUS_INITIALIZING = 1 41 | STATUS_RUNNING = 2 42 | STATUS_FINISHED = 3 43 | STATUS_TIMED_OUT = 4 44 | STATUS_ABORTING_USER = 5 45 | STATUS_ABORTED_USER = 6 46 | STATUS_ABORTING_SYSTEM = 7 47 | STATUS_ABORTED_SYSTEM = 8 48 | STATUS_ABORTED_SCRIPT_ERROR = 9 49 | STATUS_ABORTING_THRESHOLD = 10 50 | STATUS_ABORTED_THRESHOLD = 11 51 | STATUS_FAILED_THRESHOLD = 12 52 | 53 | @property 54 | def style(self): 55 | if self in (TestRunStatus.STATUS_CREATED, TestRunStatus.STATUS_QUEUED, 56 | TestRunStatus.STATUS_INITIALIZING, TestRunStatus.STATUS_RUNNING): 57 | return Style.WARNING 58 | elif self in (TestRunStatus.STATUS_TIMED_OUT, TestRunStatus.STATUS_ABORTING_USER, 59 | TestRunStatus.STATUS_ABORTED_USER, TestRunStatus.STATUS_ABORTING_SYSTEM, 60 | TestRunStatus.STATUS_ABORTED_SYSTEM, TestRunStatus.STATUS_ABORTED_SCRIPT_ERROR, 61 | TestRunStatus.STATUS_ABORTING_THRESHOLD, TestRunStatus.STATUS_ABORTED_THRESHOLD, 62 | TestRunStatus.STATUS_FAILED_THRESHOLD): 63 | return Style.ERROR 64 | elif self == TestRunStatus.STATUS_FINISHED: 65 | return Style.SUCCESS 66 | 67 | return Style.NONE 68 | 69 | 70 | class DefaultMetricType(Enum): 71 | ACCUMULATED_LOAD_TIME = 'acc load time' 72 | BANDWIDTH = 'bandwidth' 73 | CLIENTS_ACTIVE = 'VUs' 74 | CONNECTIONS_ACTIVE = 'connections' 75 | CONTENT_TYPE = 'content type' 76 | CONTENT_TYPE_LOAD_TIME = 'content type load time' 77 | FAILURE_RATE = 'failure rate' 78 | LIVE_FEEDBACK = 'feedback' 79 | LOADGEN_CPU_UTILIZATION = 'cpu' 80 | LOADGEN_MEMORY_UTILIZATION = 'mem' 81 | LOG = 'log' 82 | PROGRESS_PERCENT_TOTAL = 'progress' 83 | REPS_FAILED_PERCENT = 'failed' 84 | REPS_SUCCEEDED_PERCENT = 'successful' 85 | REQUESTS_PER_SECOND = 'reqs/s' 86 | TOTAL_RX_BYTES = 'rx' 87 | TOTAL_REQUESTS = 'reqs' 88 | USER_LOAD_TIME = 'user load time' 89 | 90 | @classmethod 91 | def from_raw(cls, str_raw): 92 | return cls.__members__[str_raw.replace('__li_', '', 1).upper()] 93 | 94 | def str_param(self): 95 | return self.name.lower() 96 | 97 | def str_raw(self): 98 | return u'__li_{0}'.format(self.name.lower()) 99 | 100 | def str_ui(self): 101 | return self.value 102 | 103 | 104 | class OtherMetricType(object): 105 | def __init__(self, metric_id): 106 | self.metric_id = metric_id 107 | 108 | def str_param(self): 109 | return '-' 110 | 111 | def str_raw(self): 112 | return self.metric_id 113 | 114 | def str_ui(self): 115 | if self.metric_id.startswith('__li_url') or self.metric_id.startswith('__li_page'): 116 | return self.metric_id.replace('__li_', '').split('_')[0] 117 | elif self.metric_id.startswith('__server_metric'): 118 | return 'server metric' 119 | elif self.metric_id.startswith('__custom_'): 120 | return 'custom' 121 | 122 | return self.metric_id 123 | 124 | def __eq__(self, other): 125 | return self.metric_id == other.metric_id 126 | 127 | 128 | class Metric(object): 129 | """ 130 | Utility class for representing a Metric, allowing to obtain the different 131 | string representations (`str_raw()`, `str_param()`, `str_ui()`) and access 132 | the metric type and parameters. 133 | """ 134 | def __init__(self, metric_type, params): 135 | self.metric_type = metric_type 136 | self.params = params 137 | 138 | @classmethod 139 | def from_raw(cls, str_raw): 140 | # Split the string into metric id and parameters. 141 | str_raw = str_raw.split('|')[0] 142 | str_parts = str_raw.split(':') 143 | str_raw, params = str_parts[0], str_parts[1:] 144 | 145 | try: 146 | metric_type = DefaultMetricType.from_raw(str_raw) 147 | if not params: 148 | # For standard metrics, append aggregated world load zone 149 | # parameter if no parameters are passed. 150 | params = ['1'] 151 | except KeyError: 152 | metric_type = OtherMetricType(str_raw) 153 | return cls(metric_type, params) 154 | 155 | def str_param(self): 156 | """ 157 | Return the user-friendly representation of the metric, as expected by 158 | the CLI arguments. 159 | """ 160 | return self.metric_type.str_param() 161 | 162 | def str_raw(self, with_params=False): 163 | """ 164 | Return the "raw" representation of the metric, as used by the API. 165 | """ 166 | if with_params: 167 | return u':'.join([self.metric_type.str_raw()] + self.params) 168 | else: 169 | return self.metric_type.str_raw() 170 | 171 | def str_ui(self, with_params=False): 172 | """ 173 | Return the representation of the metric for display purposes. 174 | """ 175 | if with_params and self.params: 176 | return u'{0} [{1}]'.format(self.metric_type.str_ui(), u' '.join(self.params)) 177 | else: 178 | return self.metric_type.str_ui() 179 | 180 | def __eq__(self, other): 181 | return self.metric_type == other.metric_type and self.params == other.params 182 | 183 | 184 | class ColumnFormatter(object): 185 | """ 186 | Helper class for formatting text into columns with fixed width. 187 | """ 188 | def __init__(self, widths, separator): 189 | """ 190 | :param widths: list with the width (in chars) of each column. A value 191 | of 0 is used for specifying unlimited width (ie. that column will not 192 | be processed and the value will be printed as-is). 193 | :param separator: string used as a separator between columns. 194 | """ 195 | self.widths = widths 196 | self.separator = separator 197 | 198 | def format(self, *args): 199 | """ 200 | Return a string that contains `args` formatted by columns, with each 201 | column having the width specified by `self.widths` and separated by 202 | `separator`. The values are truncated (replacing the 3 last characters 203 | by '...') if needed. For example: 204 | > f = ColumnFormatter(widths=(8,10,4), separator='|') 205 | > output = f.format('0123456789','0123456789','abc') 206 | > output == '01234...|0123456789|abc ' 207 | 208 | Note that `Click.style()`-d strings (using ANSII character codes) are 209 | not processed at all. 210 | 211 | :param args: list of strings, one for each column. Note that the 212 | number of arguments should match the number of items on the self.width 213 | attribute. 214 | :return: a string with the resulting row 215 | """ 216 | def format_cell(val_, width_): 217 | if len(val_) != len(unstyle(val_)) or width_ == 0: 218 | # For styled strings, assume they have been already prepared. 219 | # For width == 0, ignore formatting completely. 220 | return val_ 221 | 222 | if len(val_) > width_: 223 | val_ = val_[:width_ - 3] + '...' 224 | 225 | return u'{:{width}}'.format(val_, width=width_) 226 | 227 | if sys.version_info >= (3, 0): 228 | decode = str 229 | else: 230 | decode = unicode 231 | 232 | return self.separator.join([format_cell(decode(val), width) 233 | for width, val in zip(self.widths, args)]).rstrip() 234 | -------------------------------------------------------------------------------- /loadimpactcli/version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2017 Load Impact 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | __version__ = '1.2.3' 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | loadimpact-v3==0.1.4 2 | click==6.2 3 | tzlocal==1.2.2 4 | six==1.10.0 5 | enum34==1.1.6 6 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pytest==2.8.7 2 | mock==1.3.0 3 | 4 | # code style tests 5 | pycodestyle==2.4.0 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [pycodestyle] 5 | ignore = E121, E123, E126, E133, E226, E241, E242, E704, W503, W504, W505, W605 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2016 Load Impact 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | from setuptools import setup 18 | 19 | import os 20 | import sys 21 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'loadimpactcli')) 22 | 23 | from version import __version__ 24 | 25 | setup( 26 | name='loadimpact-cli', 27 | version=__version__, 28 | author='Load Impact', 29 | author_email='support@loadimpact.com', 30 | url='http://developers.loadimpact.com/', 31 | packages=['loadimpactcli'], 32 | py_modules=['loadimpactcli'], 33 | license='LICENSE.txt', 34 | description="The Load Impact CLI interfaces with Load Impact's cloud-based performance testing platform", 35 | include_package_data=True, 36 | data_files=[('', ['README.md'])], 37 | install_requires=[ 38 | 'setuptools>=18', 39 | 'click', 40 | 'loadimpact-v3', 41 | 'tzlocal', 42 | 'six', 43 | 'mock', 44 | 'enum34' 45 | ], 46 | test_requires=['coverage'], 47 | entry_points={ 48 | 'console_scripts': [ 49 | 'loadimpact=loadimpactcli.loadimpact_cli:run_cli', 50 | ], 51 | }, 52 | test_suite='tests', 53 | ) 54 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | # coding=utf-8 3 | 4 | """ 5 | Copyright 2013 Load Impact 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | """ 19 | 20 | from __future__ import absolute_import 21 | 22 | from . import * 23 | -------------------------------------------------------------------------------- /tests/datastore.csv: -------------------------------------------------------------------------------- 1 | Username,Password 2 | joe,secret 3 | bill,secret2 4 | anne,verysecret 5 | jim,topsecret 6 | sally,ohsosecret 7 | -------------------------------------------------------------------------------- /tests/script: -------------------------------------------------------------------------------- 1 | ----- -------------------------------------------------------------------------------- /tests/test_datastore_commands.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Copyright 2016 Load Impact 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | 18 | # Without this the config will prompt for a token 19 | import os 20 | os.environ['LOADIMPACT_API_V3_TOKEN'] = 'token' 21 | 22 | import unittest 23 | from collections import namedtuple 24 | 25 | from click.testing import CliRunner 26 | from loadimpactcli import datastore_commands 27 | 28 | try: 29 | from unittest.mock import MagicMock 30 | except ImportError: 31 | from mock import MagicMock 32 | 33 | 34 | class TestDataStores(unittest.TestCase): 35 | 36 | def setUp(self): 37 | self.runner = CliRunner() 38 | DataStore = namedtuple('DataStore', ['id', 'name', 'status', 'public_url']) 39 | self.datastore1 = DataStore(1, u'First datastore', 'status1', 'www.example.com') 40 | self.datastore2 = DataStore(2, u'Second datastore', 'status2', 'www.example.com') 41 | self.datastore3 = DataStore(3, u'ÅÄÖåäö', 'status3', 'www.example.com') 42 | 43 | def test_download_csv(self): 44 | client = datastore_commands.client 45 | datastore_commands._download_csv = MagicMock() 46 | client.get_data_store = MagicMock(return_value=self.datastore1) 47 | result = self.runner.invoke(datastore_commands.download_csv, ['1']) 48 | assert result.exit_code == 0 49 | assert result.output == "Downloading CSV file, please wait.\nFinished download.\n" 50 | 51 | def test_download_csv_no_params(self): 52 | result = self.runner.invoke(datastore_commands.download_csv, []) 53 | assert result.exit_code == 2 54 | 55 | def test_list_datastore(self): 56 | client = datastore_commands.client 57 | client.DEFAULT_PROJECT = 1 58 | client.list_data_stores = MagicMock(return_value=[self.datastore1, self.datastore2]) 59 | result = self.runner.invoke(datastore_commands.list_datastore, ['--project_id', '1']) 60 | 61 | assert result.exit_code == 0 62 | assert result.output == u"ID:\tNAME:\n1\tFirst datastore\n2\tSecond datastore\n" 63 | 64 | def test_list_datastore_non_ascii_name(self): 65 | client = datastore_commands.client 66 | client.DEFAULT_PROJECT = 1 67 | client.list_data_stores = MagicMock(return_value=[self.datastore1, self.datastore3]) 68 | result = self.runner.invoke(datastore_commands.list_datastore, ['--project_id', '1']) 69 | 70 | assert result.exit_code == 0 71 | assert result.output == u"ID:\tNAME:\n1\tFirst datastore\n3\tÅÄÖåäö\n" 72 | 73 | def test_list_datastore_missing_project_id(self): 74 | client = datastore_commands.client 75 | client.list_data_stores = MagicMock(return_value=[self.datastore1, self.datastore2]) 76 | result = self.runner.invoke(datastore_commands.list_datastore, []) 77 | assert result.exit_code == 0 78 | assert result.output == "You need to provide a project id.\n" 79 | 80 | def test_create_datastore(self): 81 | client = datastore_commands.client 82 | client.DEFAULT_PROJECT = 1 83 | 84 | client.create_data_store = MagicMock(return_value=self.datastore1) 85 | datastore_commands._wait_for_conversion = MagicMock(return_value=self.datastore1) 86 | result = self.runner.invoke(datastore_commands.create_datastore, ['NewDatastore', 87 | 'tests/script', 88 | '--project_id', 89 | '1']) 90 | assert result.exit_code == 0 91 | assert result.output == "{0}\n".format("Data store conversion completed with status 'unknown'") 92 | 93 | def test_create_datastore_missing_params(self): 94 | client = datastore_commands.client 95 | client.DEFAULT_PROJECT = 1 96 | 97 | client.create_data_store = MagicMock(return_value=self.datastore1) 98 | datastore_commands._wait_for_conversion = MagicMock(return_value=self.datastore1) 99 | result = self.runner.invoke(datastore_commands.create_datastore, ['tests/script']) 100 | assert result.exit_code == 2 101 | 102 | def test_update_datastore(self): 103 | client = datastore_commands.client 104 | client.DEFAULT_PROJECT = 1 105 | 106 | client.get_data_store = MagicMock(return_value=self.datastore2) 107 | client.update_data_store = MagicMock(return_value=self.datastore2) 108 | datastore_commands._wait_for_conversion = MagicMock(return_value=self.datastore2) 109 | 110 | result = self.runner.invoke(datastore_commands.update_datastore, ['1', 111 | 'tests/script', 112 | '--name', 113 | 'New name', 114 | '--project_id', 115 | '1']) 116 | assert result.exit_code == 0 117 | assert result.output == "{0}\n".format("Data store conversion completed with status 'unknown'") 118 | 119 | def test_update_datastore_missing_params(self): 120 | client = datastore_commands.client 121 | client.DEFAULT_PROJECT = 1 122 | 123 | client.get_data_store = MagicMock(return_value=self.datastore2) 124 | client.update_data_store = MagicMock(return_value=self.datastore2) 125 | datastore_commands._wait_for_conversion = MagicMock(return_value=self.datastore2) 126 | 127 | result = self.runner.invoke(datastore_commands.update_datastore, ['1']) 128 | assert result.exit_code == 2 129 | 130 | def test_delete_datastore(self): 131 | datastore_commands.delete_store = MagicMock(return_value="Datastore1") 132 | result = self.runner.invoke(datastore_commands.delete_datastore, ['1', '--yes']) 133 | assert result.exit_code == 0 134 | assert result.output == 'Datastore1\n' 135 | 136 | def test_delete_datastore_no_params(self): 137 | result = self.runner.invoke(datastore_commands.delete_datastore, []) 138 | assert result.exit_code == 2 139 | -------------------------------------------------------------------------------- /tests/test_metric_commands.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Copyright 2017 Load Impact 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | 18 | # Without this the config will prompt for a token 19 | import os 20 | os.environ['LOADIMPACT_API_V3_TOKEN'] = 'token' 21 | 22 | import unittest 23 | from collections import namedtuple 24 | 25 | from click.testing import CliRunner 26 | from loadimpactcli import metric_commands 27 | from loadimpactcli.util import Metric 28 | 29 | try: 30 | from unittest.mock import MagicMock 31 | except ImportError: 32 | from mock import MagicMock 33 | 34 | 35 | TestRunResultId = namedtuple('TestRunResultId', ['type', 'ids', 'results_type_code_to_text']) 36 | MetricRepresentation = namedtuple('MetricRepresentation', ['full', 'raw', 'as_param', 'args']) 37 | 38 | 39 | class TestMetric(unittest.TestCase): 40 | 41 | def setUp(self): 42 | self.runner = CliRunner() 43 | self.result_ids = [TestRunResultId(1, {'result_id_1_1': '', 'result_id_1_2': ''}, 44 | self._results_type_code_to_text), 45 | TestRunResultId(2, {'result_id_2_1': ''}, self._results_type_code_to_text)] 46 | 47 | def _assertExpectedMetric(self, metric_representation, metric, include_full=True): 48 | if include_full: 49 | self.assertEqual(metric_representation.full, metric.str_raw(True)) 50 | self.assertEqual(metric_representation.raw, metric.str_raw(False)) 51 | self.assertEqual(metric_representation.as_param, metric.str_param()) 52 | self.assertEqual(metric_representation.args, metric.params) 53 | 54 | def _results_type_code_to_text(self, type_): 55 | return 'text_for_type_{}'.format(type_) 56 | 57 | def test_list_metric_no_type(self): 58 | """ 59 | Test "test metric" without specifying metric type. 60 | """ 61 | client = metric_commands.client 62 | 63 | # Setup mockers. 64 | client.list_test_run_result_ids = MagicMock(return_value=self.result_ids) 65 | 66 | result = self.runner.invoke(metric_commands.list_metrics, ['1']) 67 | 68 | self.assertEqual(client.list_test_run_result_ids.call_count, 1) 69 | output = result.output.split('\n') 70 | self.assertEqual(len(output), 2 + 3) 71 | self.assertEqual(output[1], 'result_id_1_1\t-\ttext_for_type_1') 72 | self.assertEqual(output[2], 'result_id_1_2\t-\ttext_for_type_1') 73 | self.assertEqual(output[3], 'result_id_2_1\t-\ttext_for_type_2') 74 | 75 | def test_list_metric_with_types(self): 76 | """ 77 | Test "test metric" specifying metric type. 78 | """ 79 | client = metric_commands.client 80 | 81 | # Setup mockers. 82 | client.list_test_run_result_ids = MagicMock(return_value=self.result_ids) 83 | 84 | result = self.runner.invoke(metric_commands.list_metrics, ['1', '--type', 'common', '--type', 'url']) 85 | 86 | self.assertEqual(client.list_test_run_result_ids.call_count, 1) 87 | output = result.output.split('\n') 88 | self.assertEqual(len(output), 2 + 3) 89 | self.assertEqual(output[1], 'result_id_1_1\t-\ttext_for_type_1') 90 | self.assertEqual(output[2], 'result_id_1_2\t-\ttext_for_type_1') 91 | self.assertEqual(output[3], 'result_id_2_1\t-\ttext_for_type_2') 92 | 93 | def test_list_metric_invalid_type(self): 94 | """ 95 | Test "test metric" specifying an invalid metric type. 96 | """ 97 | client = metric_commands.client 98 | 99 | # Setup mockers. 100 | client.list_test_run_result_ids = MagicMock(return_value=self.result_ids) 101 | 102 | result = self.runner.invoke(metric_commands.list_metrics, ['1', '--type', 'INVALID']) 103 | 104 | self.assertEqual(client.list_test_run_result_ids.call_count, 0) 105 | self.assertEqual(result.exit_code, 2) 106 | 107 | def test_standard_metric(self): 108 | """ 109 | Test representation of standard metrics. 110 | """ 111 | # Metric with parameters from full raw string. 112 | expected_metric_1 = MetricRepresentation('__li_bandwidth:1', '__li_bandwidth', 'bandwidth', ['1']) 113 | metric_1 = Metric.from_raw(expected_metric_1.full) 114 | self._assertExpectedMetric(expected_metric_1, metric_1) 115 | 116 | # Metric with parameters from full raw string and slicing: slicing is dropped. 117 | expected_metric_2 = MetricRepresentation('__li_bandwidth:1|1:2', '__li_bandwidth', 'bandwidth', ['1']) 118 | metric_2 = Metric.from_raw(expected_metric_2.full) 119 | self._assertExpectedMetric(expected_metric_2, metric_2, False) 120 | self.assertEqual(expected_metric_2.full.split('|')[0], metric_2.str_raw(True)) 121 | 122 | # Metric with no parameters: load zone 1 is appended. 123 | expected_metric_3 = MetricRepresentation('__li_bandwidth', '__li_bandwidth', 'bandwidth', ['1']) 124 | metric_3 = Metric.from_raw(expected_metric_3.full) 125 | self._assertExpectedMetric(expected_metric_3, metric_3, False) 126 | self.assertEqual('{0}:1'.format(expected_metric_3.full), metric_3.str_raw(True)) 127 | 128 | # Construct the metrics from the parameter representation. 129 | metric_1_param = Metric.from_raw(expected_metric_1.as_param) 130 | metric_2_param = Metric.from_raw(expected_metric_1.as_param) 131 | metric_3_param = Metric.from_raw(expected_metric_1.as_param) 132 | self.assertEqual(metric_1, metric_1_param) 133 | self.assertEqual(metric_2, metric_2_param) 134 | self.assertEqual(metric_3, metric_3_param) 135 | 136 | # Check equivalence of the metrics. 137 | self.assertEqual(metric_1, metric_2) 138 | self.assertEqual(metric_2, metric_3) 139 | 140 | # Metric with different parameters from full raw string. 141 | expected_metric_4 = MetricRepresentation('__li_bandwidth:1:2:x', '__li_bandwidth', 'bandwidth', 142 | ['1', '2', 'x']) 143 | metric_4 = Metric.from_raw(expected_metric_4.full) 144 | self._assertExpectedMetric(expected_metric_4, metric_4) 145 | self.assertNotEqual(metric_1, metric_4) 146 | 147 | def test_non_standard_metric(self): 148 | """ 149 | Test representation of non standard metrics. 150 | """ 151 | # Metric with parameters from full raw string. 152 | expected_metric_1 = MetricRepresentation('__li_foo:1', '__li_foo', '-', ['1']) 153 | metric_1 = Metric.from_raw(expected_metric_1.full) 154 | self._assertExpectedMetric(expected_metric_1, metric_1) 155 | 156 | # Metric with parameters from full raw string and slicing: slicing is dropped. 157 | expected_metric_2 = MetricRepresentation('__li_foo:1|1:2', '__li_foo', '-', ['1']) 158 | metric_2 = Metric.from_raw(expected_metric_2.full) 159 | self._assertExpectedMetric(expected_metric_2, metric_2, False) 160 | self.assertEqual(expected_metric_2.full.split('|')[0], metric_2.str_raw(True)) 161 | 162 | # Metric with no parameters: load zone 1 is appended. 163 | expected_metric_3 = MetricRepresentation('__li_foo', '__li_foo', '-', []) 164 | metric_3 = Metric.from_raw(expected_metric_3.full) 165 | self._assertExpectedMetric(expected_metric_3, metric_3) 166 | 167 | # Check equivalence of the metrics. 168 | self.assertEqual(metric_1, metric_2) 169 | # Metrics 2 and 3 are not equal due to having different parameters. 170 | self.assertNotEqual(metric_2, metric_3) 171 | 172 | # Metric without __li_ prefix and unicode characters. 173 | expected_metric_4 = MetricRepresentation(u'foöbår:0:ßåŕ', u'foöbår', '-', ['0', u'ßåŕ']) 174 | metric_4 = Metric.from_raw(expected_metric_4.full) 175 | self._assertExpectedMetric(expected_metric_4, metric_4) 176 | -------------------------------------------------------------------------------- /tests/test_organization_commands.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2016 Load Impact 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | # Without this the config will prompt for a token 18 | import os 19 | os.environ['LOADIMPACT_API_V3_TOKEN'] = 'token' 20 | 21 | import unittest 22 | from collections import namedtuple 23 | 24 | from click.testing import CliRunner 25 | from loadimpactcli import organization_commands 26 | 27 | try: 28 | from unittest.mock import MagicMock 29 | except ImportError: 30 | from mock import MagicMock 31 | 32 | 33 | class TestOrganizations(unittest.TestCase): 34 | 35 | def setUp(self): 36 | self.runner = CliRunner() 37 | Organization = namedtuple('Organization', ['id', 'name']) 38 | self.org1 = Organization('1', 'org1') 39 | self.org2 = Organization('2', 'org2') 40 | Project = namedtuple('Project', ['id', 'name']) 41 | self.proj1 = Project('1', 'proj1') 42 | self.proj2 = Project('2', 'proj2') 43 | 44 | def test_list_organizations(self): 45 | client = organization_commands.client 46 | client.list_organizations = MagicMock(return_value=[self.org1, self.org2]) 47 | result = self.runner.invoke(organization_commands.list_organizations, []) 48 | 49 | assert result.exit_code == 0 50 | assert result.output == '1\torg1\n2\torg2\n' 51 | 52 | def test_list_projects(self): 53 | client = organization_commands.client 54 | client.list_organization_projects = MagicMock(return_value=[self.proj1, self.proj2]) 55 | result = self.runner.invoke(organization_commands.list_organization_projects, ['1']) 56 | 57 | assert result.exit_code == 0 58 | assert result.output == '1\tproj1\n2\tproj2\n' 59 | 60 | def test_list_projects_without_org_id(self): 61 | runner = CliRunner() 62 | result = runner.invoke(organization_commands.list_organization_projects, []) 63 | 64 | assert result.exit_code == 2 65 | -------------------------------------------------------------------------------- /tests/test_test_commands.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Copyright 2017 Load Impact 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | 18 | # Without this the config will prompt for a token 19 | import os 20 | os.environ['LOADIMPACT_API_V3_TOKEN'] = 'token' 21 | 22 | import unittest 23 | from collections import namedtuple 24 | from datetime import datetime 25 | 26 | from click.testing import CliRunner 27 | from loadimpactcli import test_commands 28 | from loadimpactcli.util import Metric, ColumnFormatter 29 | 30 | try: 31 | from unittest.mock import MagicMock 32 | except ImportError: 33 | from mock import MagicMock 34 | 35 | 36 | Test = namedtuple('Test', ['id', 'name', 'last_test_run_id', 'config']) 37 | TestRun = namedtuple('TestRun', ['id', 'queued', 'status', 'status_text']) 38 | Organization = namedtuple('Organization', ['id']) 39 | Project = namedtuple('Project', ['id']) 40 | StreamData = namedtuple('StreamData', ['timestamp', 'value']) 41 | 42 | 43 | class TestTestsList(unittest.TestCase): 44 | 45 | def setUp(self): 46 | self.runner = CliRunner() 47 | self.tests = [Test(1, 'Test1', 10003, ''), 48 | Test(2, 'Test2', 10002, ''), 49 | Test(3, 'Test3', 10001, '')] 50 | 51 | def test_list_tests_one_project_id(self): 52 | """ 53 | Test "test list" with 1 project id and 3 tests. 54 | """ 55 | client = test_commands.client 56 | 57 | # Setup mockers. 58 | client.list_tests = MagicMock(return_value=self.tests) 59 | client.get_test_run = MagicMock(return_value=TestRun(1, datetime.now(), 0, 'status')) 60 | result = self.runner.invoke(test_commands.list_tests, ['--project_id', '1', '--full_width']) 61 | 62 | self.assertEqual(client.list_tests.call_count, 1) 63 | self.assertEqual(client.get_test_run.call_count, 3) 64 | output = result.output.split('\n') 65 | self.assertEqual(len(output), 2 + 3) 66 | self.assertTrue(output[1].startswith('1\tTest1\t')) 67 | self.assertTrue(output[2].startswith('2\tTest2\t')) 68 | self.assertTrue(output[3].startswith('3\tTest3\t')) 69 | 70 | def test_list_tests_several_project_id(self): 71 | """ 72 | Test "test list" with 2 project id and 6 tests. 73 | """ 74 | client = test_commands.client 75 | 76 | # Setup mockers. 77 | client.list_tests = MagicMock(return_value=self.tests) 78 | client.get_test_run = MagicMock(return_value=TestRun(1, datetime.now(), 0, 'status')) 79 | result = self.runner.invoke(test_commands.list_tests, ['--project_id', '1', 80 | '--project_id', '2']) 81 | 82 | self.assertEqual(client.list_tests.call_count, 2) 83 | self.assertEqual(client.get_test_run.call_count, 6) 84 | output = result.output.split('\n') 85 | self.assertEqual(len(output), 2 + 2 * 3) 86 | 87 | def test_list_tests_no_project_id(self): 88 | """ 89 | Test "test list" with no project id. The test simulates 2 90 | organizations, with 2 projects per organization, with 3 tests per 91 | project. 92 | """ 93 | client = test_commands.client 94 | 95 | # Setup mockers. 96 | client.list_tests = MagicMock(return_value=self.tests) 97 | client.get_test_run = MagicMock(return_value=TestRun(1, datetime.now(), 0, 'status')) 98 | client.list_organizations = MagicMock(return_value=[Organization(1), Organization(2)]) 99 | client.list_organization_projects = MagicMock(side_effect=[[Project(1), Project(2)], 100 | [Project(3), Project(4)]]) 101 | result = self.runner.invoke(test_commands.list_tests) 102 | 103 | self.assertEqual(client.list_organizations.call_count, 1) 104 | self.assertEqual(client.list_organization_projects.call_count, 2) 105 | self.assertEqual(client.list_tests.call_count, 2 * 2) 106 | self.assertEqual(client.get_test_run.call_count, 2 * 2 * 3) 107 | output = result.output.split('\n') 108 | self.assertEqual(len(output), 2 + 2 * 2 * 3) 109 | 110 | def test_list_tests_limit_not_hit(self): 111 | """ 112 | Test "test list" with the limit flag. 113 | """ 114 | client = test_commands.client 115 | 116 | # Setup mockers. 117 | client.list_tests = MagicMock(return_value=self.tests) 118 | client.get_test_run = MagicMock(return_value=TestRun(1, datetime.now(), 0, 'status')) 119 | 120 | # Invokation without flag: limit is 20, all tests are listed, no extra line. 121 | result = self.runner.invoke(test_commands.list_tests, ['--project_id', '1']) 122 | 123 | self.assertEqual(client.list_tests.call_count, 1) 124 | self.assertEqual(client.get_test_run.call_count, 3) 125 | output = result.output.split('\n') 126 | self.assertEqual(len(output), 2 + 3) 127 | 128 | # Invokation with flag: limit is 1, 1 test listed, extra line. 129 | client.list_tests.reset_mock() 130 | client.get_test_run.reset_mock() 131 | result = self.runner.invoke(test_commands.list_tests, ['--project_id', '1', 132 | '--limit', '1']) 133 | 134 | self.assertEqual(client.list_tests.call_count, 1) 135 | self.assertEqual(client.get_test_run.call_count, 1) 136 | output = result.output.split('\n') 137 | self.assertEqual(len(output), 2 + 1 + 1) 138 | 139 | def test_summarize_valid_config(self): 140 | config = {u'new_relic_applications': [], 141 | u'network_emulation': {u'client': u'li', u'network': u'unlimited'}, 142 | u'user_type': u'sbu', 143 | u'server_metric_agents': [], 144 | u'tracks': [ 145 | {u'clips': [{u'user_scenario_id': 225, u'percent': 100}], 146 | u'loadzone': u'amazon:ie:dublin'}], 147 | u'url_groups': [], 148 | u'load_schedule': [ 149 | {u'duration': 1, u'users': 50}, 150 | {u'duration': 2, u'users': 100}], 151 | u'source_ips': 0} 152 | summary = test_commands.summarize_config(config) 153 | self.assertEqual(summary, '50 users 1s; 100 users 2s') 154 | 155 | def test_summarize_invalid_config(self): 156 | config = 'INVALID' 157 | summary = test_commands.summarize_config(config) 158 | self.assertEqual(summary, '-') 159 | 160 | 161 | class TestTestsRun(unittest.TestCase): 162 | def setUp(self): 163 | self.runner = CliRunner() 164 | 165 | def _assertMetricHeader(self, string, metrics): 166 | formatter = test_commands.get_run_test_formatter(True, metrics) 167 | self.assertEqual(test_commands.pprint_header(formatter, metrics), 168 | u'TIMESTAMP:\t{0}'.format(string)) 169 | 170 | def test_ui_header(self): 171 | """ 172 | Test the header of metric streaming. 173 | """ 174 | # Default metrics: they are automatically appended the load zone. 175 | default_metrics = [Metric.from_raw('clients_active'), 176 | Metric.from_raw('requests_per_second'), 177 | Metric.from_raw('bandwidth'), 178 | Metric.from_raw('user_load_time'), 179 | Metric.from_raw('failure_rate')] 180 | self._assertMetricHeader( 181 | u'VUs [1]:\treqs/s [1]:\tbandwidth [1]:\tuser load time [1]:\tfailure rate [1]:', 182 | default_metrics) 183 | 184 | # Same metrics, different parameters. 185 | repeated_metrics = [Metric.from_raw('clients_active'), 186 | Metric.from_raw('__li_clients_active:2')] 187 | self._assertMetricHeader(u'VUs [1]:\tVUs [2]:', repeated_metrics) 188 | 189 | # Unicode metrics. 190 | unicode_metrics = [Metric.from_raw(u'foöbår:0:ßåŕ')] 191 | self._assertMetricHeader(u'foöbår [0 ßåŕ]:', unicode_metrics) 192 | 193 | def test_ui_row(self): 194 | """ 195 | Test the row generation of metric streaming. 196 | """ 197 | metrics = [Metric.from_raw('clients_active'), 198 | Metric.from_raw(u'foöbår:1')] 199 | 200 | # No results returned. 201 | formatter = test_commands.get_run_test_formatter(True, metrics) 202 | data = {} 203 | self.assertEqual(test_commands.pprint_row(formatter, data, metrics), '') 204 | 205 | # Only timestamp (not one of the requested metrics) returned. 206 | data = {'somerandomkey': StreamData(datetime.now(), 1)} 207 | self.assertEqual(test_commands.pprint_row(formatter, data, metrics).split('\t', 1)[1], u'-\t-') 208 | 209 | # Data returned for one metric. 210 | data = {metrics[0].str_raw(True): StreamData(datetime.now(), 123)} 211 | self.assertEqual(test_commands.pprint_row(formatter, data, metrics).split('\t', 1)[1], u'123\t-') 212 | 213 | # Data returned for both metrics. 214 | data = {metrics[0].str_raw(True): StreamData(datetime.now(), 123), 215 | metrics[1].str_raw(True): StreamData(datetime.now(), 'xyz')} 216 | self.assertEqual(test_commands.pprint_row(formatter, data, metrics).split('\t', 1)[1], u'123\txyz') 217 | 218 | def test_run_no_streaming(self): 219 | """ 220 | Test `test run` with no streaming of the results. 221 | """ 222 | client = test_commands.client 223 | 224 | # Setup mockers. 225 | MockedTest = namedtuple('Test', ['id', 'name', 'last_test_run_id', 'config', 'start_test_run']) 226 | test_run = MagicMock(return_value=TestRun(222, datetime.now(), 0, 'status')) 227 | test = MockedTest(1, 'Test1', 10001, '', test_run) 228 | client.get_test = MagicMock(return_value=test) 229 | 230 | result = self.runner.invoke(test_commands.run_test, ['--quiet', '1']) 231 | 232 | # Client and test methods have been called. 233 | self.assertEqual(client.get_test.call_count, 1) 234 | self.assertEqual(test.start_test_run.call_count, 1) 235 | 236 | # Last line of the output contains new TestRun ID. 237 | output = result.output.split('\n') 238 | self.assertEqual(output[-2], u'222') 239 | 240 | def test_run_streaming(self): 241 | """ 242 | Test `test run` with streaming of the results using default metrics. 243 | """ 244 | def mocked_stream(*args, **kwargs): 245 | """ 246 | Mimic stream() generator, yielding one row of results. 247 | """ 248 | yield {'__li_clients_active:1': StreamData(datetime.now(), '1.23')} 249 | 250 | client = test_commands.client 251 | 252 | # Setup mockers. 253 | MockedTest = namedtuple('MockedTest', 254 | ['id', 'name', 'last_test_run_id', 'config', 'start_test_run']) 255 | MockedTestRun = namedtuple('MockedTestRun', 256 | ['id', 'queued', 'status', 'status_text', 'result_stream']) 257 | mocked_stream = MagicMock(return_value=mocked_stream) 258 | test_run = MagicMock(return_value=MockedTestRun(222, datetime.now(), 0, 'status', mocked_stream)) 259 | test = MockedTest(1, 'Test1', 10001, '', test_run) 260 | client.get_test = MagicMock(return_value=test) 261 | 262 | result = self.runner.invoke(test_commands.run_test, ['1', '--full_width']) 263 | 264 | # Client and test methods have been called. 265 | self.assertEqual(client.get_test.call_count, 1) 266 | self.assertEqual(test.start_test_run.call_count, 1) 267 | self.assertEqual(mocked_stream.call_count, 1) 268 | 269 | # Assertions on the output. 270 | output = result.output.split('\n') 271 | # Results table assertions (one row, one metric). 272 | self.assertEqual(len(output), 6) 273 | self.assertEqual(len(output[-2].split('\t')), 6) 274 | self.assertEqual(output[-2].split('\t')[1], '1.23') 275 | -------------------------------------------------------------------------------- /tests/test_userscenario_commands.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Copyright 2016 Load Impact 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | """ 17 | 18 | # Without this the config will prompt for a token 19 | import os 20 | os.environ['LOADIMPACT_API_V3_TOKEN'] = 'token' 21 | 22 | import unittest 23 | from collections import namedtuple 24 | 25 | from click.testing import CliRunner 26 | from loadimpactcli import userscenario_commands 27 | 28 | try: 29 | from unittest.mock import MagicMock 30 | except ImportError: 31 | from mock import MagicMock 32 | 33 | 34 | class MockValidation(object): 35 | 36 | def __init__(self, status_text): 37 | self.status_text = status_text 38 | 39 | 40 | class MockValidationResult(object): 41 | 42 | def __init__(self, timestamp, message): 43 | self.message = message 44 | self.timestamp = timestamp 45 | 46 | 47 | class TestUserScenarios(unittest.TestCase): 48 | 49 | def setUp(self): 50 | self.runner = CliRunner() 51 | Scenario = namedtuple('Scenario', ['id', 'name', 'script']) 52 | self.scenario1 = Scenario(1, u'Scen1', 'debug') 53 | self.scenario2 = Scenario(2, u'Scen2', 'info') 54 | self.scenario3 = Scenario(3, u'Scen3 åäö', 'info') 55 | 56 | def test_get_scenario(self): 57 | client = userscenario_commands.client 58 | client.get_user_scenario = MagicMock(return_value=self.scenario1) 59 | result = self.runner.invoke(userscenario_commands.get_scenario, ['1']) 60 | 61 | assert result.exit_code == 0 62 | assert result.output == "debug\n" 63 | 64 | def test_get_scenario_no_params(self): 65 | result = self.runner.invoke(userscenario_commands.get_scenario, []) 66 | assert result.exit_code == 2 67 | 68 | def test_list_scenario(self): 69 | client = userscenario_commands.client 70 | client.DEFAULT_PROJECT = 1 71 | client.list_user_scenarios = MagicMock(return_value=[self.scenario1, self.scenario2]) 72 | result = self.runner.invoke(userscenario_commands.list_scenarios, ['--project_id', '1']) 73 | 74 | assert result.exit_code == 0 75 | assert result.output == u"ID:\tNAME:\n1\tScen1\n2\tScen2\n" 76 | 77 | def test_list_scenario_non_ascii_name(self): 78 | client = userscenario_commands.client 79 | client.DEFAULT_PROJECT = 1 80 | client.list_user_scenarios = MagicMock(return_value=[self.scenario1, self.scenario3]) 81 | result = self.runner.invoke(userscenario_commands.list_scenarios, ['--project_id', '1']) 82 | 83 | assert result.exit_code == 0 84 | assert result.output == u"ID:\tNAME:\n1\tScen1\n3\tScen3 åäö\n" 85 | 86 | def test_create_scenario(self): 87 | client = userscenario_commands.client 88 | client.create_user_scenario = MagicMock(return_value=self.scenario1) 89 | result = self.runner.invoke(userscenario_commands.create_scenario, ['tests/script', 'my script', '--project_id', '1']) 90 | assert result.exit_code == 0 91 | assert result.output == "debug\n" 92 | 93 | def test_create_scenario_no_params(self): 94 | result = self.runner.invoke(userscenario_commands.create_scenario, []) 95 | assert result.exit_code == 2 96 | 97 | def test_create_scenario_with_datastore_files(self): 98 | client = userscenario_commands.client 99 | client.create_user_scenario = MagicMock(return_value=self.scenario1) 100 | result = self.runner.invoke(userscenario_commands.create_scenario, 101 | ['tests/script', 'my script', 102 | '--project_id', '1', 103 | '--datastore_file', 'tests/datastore.csv', 104 | '--datastore_file', 'tests/script']) 105 | assert result.exit_code == 0 106 | assert result.output == "debug\n" 107 | 108 | def test_create_scenario_with_existing_datastore(self): 109 | client = userscenario_commands.client 110 | client.create_user_scenario = MagicMock(return_value=self.scenario1) 111 | result = self.runner.invoke(userscenario_commands.create_scenario, 112 | ['tests/script', 'my script', 113 | '--project_id', '1', 114 | '--datastore_id', '1' 115 | ]) 116 | assert result.exit_code == 0 117 | assert result.output == "debug\n" 118 | 119 | def test_update_scenario(self): 120 | userscenario_commands.update_user_scenario_script = MagicMock(return_value=self.scenario1) 121 | result = self.runner.invoke(userscenario_commands.update_scenario, ['1', 'tests/script']) 122 | assert result.exit_code == 0 123 | assert result.output == 'debug\n' 124 | 125 | def test_update_scenario_no_params(self): 126 | result = self.runner.invoke(userscenario_commands.update_scenario, []) 127 | assert result.exit_code == 2 128 | 129 | def test_delete_scenario(self): 130 | userscenario_commands.delete_user_scenario = MagicMock(return_value="Userscenario1") 131 | result = self.runner.invoke(userscenario_commands.delete_scenario, ['1', '--yes']) 132 | assert result.exit_code == 0 133 | assert result.output == 'Userscenario1\n' 134 | 135 | def test_delete_scenario_no_params(self): 136 | result = self.runner.invoke(userscenario_commands.update_scenario, []) 137 | assert result.exit_code == 2 138 | 139 | def test_validate_scenario(self): 140 | userscenario_commands.client.get_user_scenario = MagicMock(return_value=1) 141 | userscenario_commands.get_validation = MagicMock(return_value=MockValidation('Success')) 142 | userscenario_commands.get_validation_results = MagicMock(return_value=[MockValidationResult(2, 'msg')]) 143 | 144 | userscenario_commands.get_formatted_validation_results = MagicMock(return_value='Validation 1') 145 | result = self.runner.invoke(userscenario_commands.validate_scenario, ['1']) 146 | 147 | assert result.exit_code == 0 148 | 149 | def test_validate_scenario_no_params(self): 150 | result = self.runner.invoke(userscenario_commands.delete_scenario, []) 151 | assert result.exit_code == 2 152 | 153 | def test_get_formatted_validation_results(self): 154 | 155 | MockValidationResult.level = None 156 | userscenario_commands.get_timestamp_as_local_time = MagicMock(return_value=2) 157 | 158 | unformatted_validations = [MockValidationResult(2, 'msg 1'), MockValidationResult(2, 'msg 2'), MockValidationResult(2, 'msg 3')] 159 | formatted_validations = userscenario_commands.get_formatted_validation_results(unformatted_validations) 160 | assert formatted_validations == "[2] msg 1\n[2] msg 2\n[2] msg 3\n" 161 | --------------------------------------------------------------------------------