├── .editorconfig ├── .gitignore ├── .pylintrc ├── .python-version ├── AUTHORS.rst ├── Dockerfile ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── TODO.md ├── docs ├── .nojekyll ├── Makefile ├── _config.yml ├── conf.py ├── index.html ├── index.rst ├── overview.rst ├── requests.rst ├── tutorial.rst ├── tutorial │ ├── secrecy.yaml │ └── wanda.env.yaml └── usage.rst ├── examples └── simple │ ├── config.sh │ ├── simple.collection.yaml │ └── simple.env.yaml ├── makeenv.sh ├── requirements.dev.txt ├── requirements.txt ├── restcli ├── __init__.py ├── app.py ├── cli.py ├── contrib │ ├── __init__.py │ └── scripts.py ├── exceptions.py ├── params.py ├── postman.py ├── reqmod │ ├── __init__.py │ ├── lexer.py │ ├── mods.py │ ├── parser.py │ └── updater.py ├── requestor.py ├── utils.py ├── workspace.py └── yaml_utils.py ├── setup.cfg ├── setup.py ├── tasks.py └── tests ├── __init__.py ├── helpers.py ├── random_gen.py ├── resources ├── test_collection.yaml └── test_env.yaml ├── test_reqmod ├── __init__.py ├── test_lexer.py ├── test_mods.py └── test_parser.py ├── test_requestor.py └── test_utils.py /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | root = true 3 | # Unix-style newlines 4 | end_of_line = lf 5 | # Newline ending every file 6 | insert_final_newline = true 7 | # Set max line length to 79 8 | max_line_length = 79 9 | 10 | [*.{py,yaml}] 11 | # Set default charset 12 | charset = utf-8 13 | # 4 space indentation 14 | indent_style = space 15 | indent_size = 4 16 | # Remove trailing whitespace 17 | trim_trailing_whitespace = true 18 | 19 | [Makefile] 20 | # Tab indentation 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | docs/api/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | .venv/ 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | # Editors 94 | .idea 95 | *.swp 96 | *.swo 97 | .vscode 98 | /.history/ 99 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-whitelist= 7 | 8 | # Specify a score threshold to be exceeded before program exits with error. 9 | fail-under=10.0 10 | 11 | # Add files or directories to the blacklist. They should be base names, not 12 | # paths. 13 | ignore=tasks.py 14 | 15 | # Add files or directories matching the regex patterns to the blacklist. The 16 | # regex matches against base names, not paths. 17 | ignore-patterns=test_.*?py 18 | 19 | # Python code to execute, usually for sys.path manipulation such as 20 | # pygtk.require(). 21 | #init-hook= 22 | 23 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 24 | # number of processors available to use. 25 | jobs=1 26 | 27 | # Control the amount of potential inferred values when inferring a single 28 | # object. This can help the performance when dealing with large functions or 29 | # complex, nested conditions. 30 | limit-inference-results=100 31 | 32 | # List of plugins (as comma separated values of python module names) to load, 33 | # usually to register additional checkers. 34 | load-plugins= 35 | 36 | # Pickle collected data for later comparisons. 37 | persistent=yes 38 | 39 | # When enabled, pylint would attempt to guess common misconfiguration and emit 40 | # user-friendly hints instead of false-positive error messages. 41 | suggestion-mode=yes 42 | 43 | # Allow loading of arbitrary C extensions. Extensions are imported into the 44 | # active Python interpreter and may run arbitrary code. 45 | unsafe-load-any-extension=no 46 | 47 | 48 | [MESSAGES CONTROL] 49 | 50 | # Only show warnings with the listed confidence levels. Leave empty to show 51 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 52 | confidence= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once). You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use "--disable=all --enable=classes 62 | # --disable=W". 63 | disable=print-statement, 64 | parameter-unpacking, 65 | unpacking-in-except, 66 | old-raise-syntax, 67 | backtick, 68 | long-suffix, 69 | old-ne-operator, 70 | old-octal-literal, 71 | import-star-module-level, 72 | non-ascii-bytes-literal, 73 | raw-checker-failed, 74 | bad-inline-option, 75 | locally-disabled, 76 | file-ignored, 77 | suppressed-message, 78 | useless-suppression, 79 | deprecated-pragma, 80 | use-symbolic-message-instead, 81 | apply-builtin, 82 | basestring-builtin, 83 | buffer-builtin, 84 | cmp-builtin, 85 | coerce-builtin, 86 | execfile-builtin, 87 | file-builtin, 88 | long-builtin, 89 | raw_input-builtin, 90 | reduce-builtin, 91 | standarderror-builtin, 92 | unicode-builtin, 93 | xrange-builtin, 94 | coerce-method, 95 | delslice-method, 96 | getslice-method, 97 | setslice-method, 98 | no-absolute-import, 99 | old-division, 100 | dict-iter-method, 101 | dict-view-method, 102 | next-method-called, 103 | metaclass-assignment, 104 | indexing-exception, 105 | raising-string, 106 | reload-builtin, 107 | oct-method, 108 | hex-method, 109 | nonzero-method, 110 | cmp-method, 111 | input-builtin, 112 | round-builtin, 113 | intern-builtin, 114 | unichr-builtin, 115 | map-builtin-not-iterating, 116 | zip-builtin-not-iterating, 117 | range-builtin-not-iterating, 118 | filter-builtin-not-iterating, 119 | using-cmp-argument, 120 | eq-without-hash, 121 | div-method, 122 | idiv-method, 123 | rdiv-method, 124 | exception-message-attribute, 125 | invalid-str-codec, 126 | sys-max-int, 127 | bad-python3-import, 128 | deprecated-string-function, 129 | deprecated-str-translate-call, 130 | deprecated-itertools-function, 131 | deprecated-types-field, 132 | next-method-defined, 133 | dict-items-not-iterating, 134 | dict-keys-not-iterating, 135 | dict-values-not-iterating, 136 | deprecated-operator-function, 137 | deprecated-urllib-function, 138 | xreadlines-attribute, 139 | deprecated-sys-function, 140 | exception-escape, 141 | comprehension-escape, 142 | # TODO: eventually remove these and add docstrings to everything 143 | missing-module-docstring, 144 | missing-class-docstring, 145 | missing-function-docstring, 146 | # /TODO 147 | too-few-public-methods, 148 | too-many-ancestors, 149 | too-many-arguments, 150 | too-many-instance-attributes, 151 | exec-used, 152 | fixme, 153 | keyword-arg-before-vararg, 154 | raise-missing-from, 155 | invalid-name 156 | 157 | # Enable the message, report, category or checker with the given id(s). You can 158 | # either give multiple identifier separated by comma (,) or put this option 159 | # multiple time (only on the command line, not in the configuration file where 160 | # it should appear only once). See also the "--disable" option for examples. 161 | enable=c-extension-no-member 162 | 163 | 164 | [REPORTS] 165 | 166 | # Python expression which should return a score less than or equal to 10. You 167 | # have access to the variables 'error', 'warning', 'refactor', and 'convention' 168 | # which contain the number of messages in each category, as well as 'statement' 169 | # which is the total number of statements analyzed. This score is used by the 170 | # global evaluation report (RP0004). 171 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 172 | 173 | # Template used to display messages. This is a python new-style format string 174 | # used to format the message information. See doc for all details. 175 | #msg-template= 176 | 177 | # Set the output format. Available formats are text, parseable, colorized, json 178 | # and msvs (visual studio). You can also give a reporter class, e.g. 179 | # mypackage.mymodule.MyReporterClass. 180 | output-format=text 181 | 182 | # Tells whether to display a full report or only the messages. 183 | reports=no 184 | 185 | # Activate the evaluation score. 186 | score=yes 187 | 188 | 189 | [REFACTORING] 190 | 191 | # Maximum number of nested blocks for function / method body 192 | max-nested-blocks=5 193 | 194 | # Complete name of functions that never returns. When checking for 195 | # inconsistent-return-statements if a never returning function is called then 196 | # it will be considered as an explicit return statement and no message will be 197 | # printed. 198 | never-returning-functions=sys.exit 199 | 200 | 201 | [LOGGING] 202 | 203 | # The type of string formatting that logging methods do. `old` means using % 204 | # formatting, `new` is for `{}` formatting. 205 | logging-format-style=old 206 | 207 | # Logging modules to check that the string format arguments are in logging 208 | # function parameter format. 209 | logging-modules=logging 210 | 211 | 212 | [SPELLING] 213 | 214 | # Limits count of emitted suggestions for spelling mistakes. 215 | max-spelling-suggestions=4 216 | 217 | # Spelling dictionary name. Available dictionaries: none. To make it work, 218 | # install the python-enchant package. 219 | spelling-dict= 220 | 221 | # List of comma separated words that should not be checked. 222 | spelling-ignore-words= 223 | 224 | # A path to a file that contains the private dictionary; one word per line. 225 | spelling-private-dict-file= 226 | 227 | # Tells whether to store unknown words to the private dictionary (see the 228 | # --spelling-private-dict-file option) instead of raising a message. 229 | spelling-store-unknown-words=no 230 | 231 | 232 | [MISCELLANEOUS] 233 | 234 | # List of note tags to take in consideration, separated by a comma. 235 | notes=FIXME, 236 | XXX, 237 | TODO 238 | 239 | # Regular expression of note tags to take in consideration. 240 | #notes-rgx= 241 | 242 | 243 | [TYPECHECK] 244 | 245 | # List of decorators that produce context managers, such as 246 | # contextlib.contextmanager. Add to this list to register other decorators that 247 | # produce valid context managers. 248 | contextmanager-decorators=contextlib.contextmanager 249 | 250 | # List of members which are set dynamically and missed by pylint inference 251 | # system, and so shouldn't trigger E1101 when accessed. Python regular 252 | # expressions are accepted. 253 | generated-members= 254 | 255 | # Tells whether missing members accessed in mixin class should be ignored. A 256 | # mixin class is detected if its name ends with "mixin" (case insensitive). 257 | ignore-mixin-members=yes 258 | 259 | # Tells whether to warn about missing members when the owner of the attribute 260 | # is inferred to be None. 261 | ignore-none=yes 262 | 263 | # This flag controls whether pylint should warn about no-member and similar 264 | # checks whenever an opaque object is returned when inferring. The inference 265 | # can return multiple potential results while evaluating a Python object, but 266 | # some branches might not be evaluated, which results in partial inference. In 267 | # that case, it might be useful to still emit no-member and other checks for 268 | # the rest of the inferred objects. 269 | ignore-on-opaque-inference=yes 270 | 271 | # List of class names for which member attributes should not be checked (useful 272 | # for classes with dynamically set attributes). This supports the use of 273 | # qualified names. 274 | ignored-classes=optparse.Values,thread._local,_thread._local 275 | 276 | # List of module names for which member attributes should not be checked 277 | # (useful for modules/projects where namespaces are manipulated during runtime 278 | # and thus existing member attributes cannot be deduced by static analysis). It 279 | # supports qualified module names, as well as Unix pattern matching. 280 | ignored-modules= 281 | 282 | # Show a hint with possible names when a member name was not found. The aspect 283 | # of finding the hint is based on edit distance. 284 | missing-member-hint=yes 285 | 286 | # The minimum edit distance a name should have in order to be considered a 287 | # similar match for a missing member name. 288 | missing-member-hint-distance=1 289 | 290 | # The total number of similar names that should be taken in consideration when 291 | # showing a hint for a missing member. 292 | missing-member-max-choices=1 293 | 294 | # List of decorators that change the signature of a decorated function. 295 | signature-mutators= 296 | 297 | 298 | [VARIABLES] 299 | 300 | # List of additional names supposed to be defined in builtins. Remember that 301 | # you should avoid defining new builtins when possible. 302 | additional-builtins= 303 | 304 | # Tells whether unused global variables should be treated as a violation. 305 | allow-global-unused-variables=yes 306 | 307 | # List of strings which can identify a callback function by name. A callback 308 | # name must start or end with one of those strings. 309 | callbacks=cb_, 310 | _cb 311 | 312 | # A regular expression matching the name of dummy variables (i.e. expected to 313 | # not be used). 314 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 315 | 316 | # Argument names that match this expression will be ignored. Default to name 317 | # with leading underscore. 318 | ignored-argument-names=_.*|^ignored_|^unused_ 319 | 320 | # Tells whether we should check for unused import in __init__ files. 321 | init-import=no 322 | 323 | # List of qualified module names which can have objects that can redefine 324 | # builtins. 325 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 326 | 327 | 328 | [FORMAT] 329 | 330 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 331 | expected-line-ending-format= 332 | 333 | # Regexp for a line that is allowed to be longer than the limit. 334 | ignore-long-lines=^\s*(# )??$ 335 | 336 | # Number of spaces of indent required inside a hanging or continued line. 337 | indent-after-paren=4 338 | 339 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 340 | # tab). 341 | indent-string=' ' 342 | 343 | # Maximum number of characters on a single line. 344 | max-line-length=100 345 | 346 | # Maximum number of lines in a module. 347 | max-module-lines=1000 348 | 349 | # Allow the body of a class to be on the same line as the declaration if body 350 | # contains single statement. 351 | single-line-class-stmt=no 352 | 353 | # Allow the body of an if to be on the same line as the test if there is no 354 | # else. 355 | single-line-if-stmt=no 356 | 357 | 358 | [SIMILARITIES] 359 | 360 | # Ignore comments when computing similarities. 361 | ignore-comments=yes 362 | 363 | # Ignore docstrings when computing similarities. 364 | ignore-docstrings=yes 365 | 366 | # Ignore imports when computing similarities. 367 | ignore-imports=no 368 | 369 | # Minimum lines number of a similarity. 370 | min-similarity-lines=4 371 | 372 | 373 | [BASIC] 374 | 375 | # Naming style matching correct argument names. 376 | argument-naming-style=snake_case 377 | 378 | # Regular expression matching correct argument names. Overrides argument- 379 | # naming-style. 380 | #argument-rgx= 381 | 382 | # Naming style matching correct attribute names. 383 | attr-naming-style=snake_case 384 | 385 | # Regular expression matching correct attribute names. Overrides attr-naming- 386 | # style. 387 | #attr-rgx= 388 | 389 | # Bad variable names which should always be refused, separated by a comma. 390 | bad-names=foo, 391 | bar, 392 | baz, 393 | toto, 394 | tutu, 395 | tata 396 | 397 | # Bad variable names regexes, separated by a comma. If names match any regex, 398 | # they will always be refused 399 | bad-names-rgxs= 400 | 401 | # Naming style matching correct class attribute names. 402 | class-attribute-naming-style=any 403 | 404 | # Regular expression matching correct class attribute names. Overrides class- 405 | # attribute-naming-style. 406 | #class-attribute-rgx= 407 | 408 | # Naming style matching correct class names. 409 | class-naming-style=PascalCase 410 | 411 | # Regular expression matching correct class names. Overrides class-naming- 412 | # style. 413 | #class-rgx= 414 | 415 | # Naming style matching correct constant names. 416 | const-naming-style=UPPER_CASE 417 | 418 | # Regular expression matching correct constant names. Overrides const-naming- 419 | # style. 420 | #const-rgx= 421 | 422 | # Minimum line length for functions/classes that require docstrings, shorter 423 | # ones are exempt. 424 | docstring-min-length=-1 425 | 426 | # Naming style matching correct function names. 427 | function-naming-style=snake_case 428 | 429 | # Regular expression matching correct function names. Overrides function- 430 | # naming-style. 431 | #function-rgx= 432 | 433 | # Good variable names which should always be accepted, separated by a comma. 434 | good-names=i, 435 | j, 436 | k, 437 | ex, 438 | Run, 439 | _ 440 | 441 | # Good variable names regexes, separated by a comma. If names match any regex, 442 | # they will always be accepted 443 | good-names-rgxs= 444 | 445 | # Include a hint for the correct naming format with invalid-name. 446 | include-naming-hint=no 447 | 448 | # Naming style matching correct inline iteration names. 449 | inlinevar-naming-style=any 450 | 451 | # Regular expression matching correct inline iteration names. Overrides 452 | # inlinevar-naming-style. 453 | #inlinevar-rgx= 454 | 455 | # Naming style matching correct method names. 456 | method-naming-style=snake_case 457 | 458 | # Regular expression matching correct method names. Overrides method-naming- 459 | # style. 460 | #method-rgx= 461 | 462 | # Naming style matching correct module names. 463 | module-naming-style=snake_case 464 | 465 | # Regular expression matching correct module names. Overrides module-naming- 466 | # style. 467 | #module-rgx= 468 | 469 | # Colon-delimited sets of names that determine each other's naming style when 470 | # the name regexes allow several styles. 471 | name-group= 472 | 473 | # Regular expression which should only match function or class names that do 474 | # not require a docstring. 475 | no-docstring-rgx=^_ 476 | 477 | # List of decorators that produce properties, such as abc.abstractproperty. Add 478 | # to this list to register other decorators that produce valid properties. 479 | # These decorators are taken in consideration only for invalid-name. 480 | property-classes=abc.abstractproperty 481 | 482 | # Naming style matching correct variable names. 483 | variable-naming-style=snake_case 484 | 485 | # Regular expression matching correct variable names. Overrides variable- 486 | # naming-style. 487 | #variable-rgx= 488 | 489 | 490 | [STRING] 491 | 492 | # This flag controls whether inconsistent-quotes generates a warning when the 493 | # character used as a quote delimiter is used inconsistently within a module. 494 | check-quote-consistency=no 495 | 496 | # This flag controls whether the implicit-str-concat should generate a warning 497 | # on implicit string concatenation in sequences defined over several lines. 498 | check-str-concat-over-line-jumps=no 499 | 500 | 501 | [IMPORTS] 502 | 503 | # List of modules that can be imported at any level, not just the top level 504 | # one. 505 | allow-any-import-level= 506 | 507 | # Allow wildcard imports from modules that define __all__. 508 | allow-wildcard-with-all=no 509 | 510 | # Analyse import fallback blocks. This can be used to support both Python 2 and 511 | # 3 compatible code, which means that the block might have code that exists 512 | # only in one or another interpreter, leading to false positives when analysed. 513 | analyse-fallback-blocks=no 514 | 515 | # Deprecated modules which should not be used, separated by a comma. 516 | deprecated-modules=optparse,tkinter.tix 517 | 518 | # Create a graph of external dependencies in the given file (report RP0402 must 519 | # not be disabled). 520 | ext-import-graph= 521 | 522 | # Create a graph of every (i.e. internal and external) dependencies in the 523 | # given file (report RP0402 must not be disabled). 524 | import-graph= 525 | 526 | # Create a graph of internal dependencies in the given file (report RP0402 must 527 | # not be disabled). 528 | int-import-graph= 529 | 530 | # Force import order to recognize a module as part of the standard 531 | # compatibility libraries. 532 | known-standard-library= 533 | 534 | # Force import order to recognize a module as part of a third party library. 535 | known-third-party=enchant 536 | 537 | # Couples of modules and preferred modules, separated by a comma. 538 | preferred-modules= 539 | 540 | 541 | [CLASSES] 542 | 543 | # Warn about protected attribute access inside special methods 544 | check-protected-access-in-special-methods=no 545 | 546 | # List of method names used to declare (i.e. assign) instance attributes. 547 | defining-attr-methods=__init__, 548 | __new__, 549 | setUp, 550 | __post_init__ 551 | 552 | # List of member names, which should be excluded from the protected access 553 | # warning. 554 | exclude-protected=_asdict, 555 | _fields, 556 | _replace, 557 | _source, 558 | _make 559 | 560 | # List of valid names for the first argument in a class method. 561 | valid-classmethod-first-arg=cls 562 | 563 | # List of valid names for the first argument in a metaclass class method. 564 | valid-metaclass-classmethod-first-arg=cls 565 | 566 | 567 | [DESIGN] 568 | 569 | # Maximum number of arguments for function / method. 570 | max-args=5 571 | 572 | # Maximum number of attributes for a class (see R0902). 573 | max-attributes=7 574 | 575 | # Maximum number of boolean expressions in an if statement (see R0916). 576 | max-bool-expr=5 577 | 578 | # Maximum number of branch for function / method body. 579 | max-branches=12 580 | 581 | # Maximum number of locals for function / method body. 582 | max-locals=15 583 | 584 | # Maximum number of parents for a class (see R0901). 585 | max-parents=7 586 | 587 | # Maximum number of public methods for a class (see R0904). 588 | max-public-methods=20 589 | 590 | # Maximum number of return / yield for function / method body. 591 | max-returns=6 592 | 593 | # Maximum number of statements in function / method body. 594 | max-statements=50 595 | 596 | # Minimum number of public methods for a class (see R0903). 597 | min-public-methods=2 598 | 599 | 600 | [EXCEPTIONS] 601 | 602 | # Exceptions that will emit a warning when being caught. Defaults to 603 | # "BaseException, Exception". 604 | overgeneral-exceptions=BaseException, 605 | Exception 606 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9.0 2 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Maintainer 6 | ---------- 7 | 8 | * Dustin Rohde 9 | 10 | Contributors 11 | ------------ 12 | 13 | * Dairon Medina C. 14 | * Lyle Scott, III 15 | * You? 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | 3 | ARG collection= 4 | ARG env= 5 | ARG autosave=0 6 | ARG quiet=0 7 | ARG raw=0 8 | 9 | ENV RESTCLI_COLLECTION ${collection} 10 | ENV RESTCLI_ENV ${env} 11 | ENV RESTCLI_AUTOSAVE ${autosave} 12 | ENV RESTCLI_QUIET ${quiet} 13 | ENV RESTCLI_RAW_OUTPUT ${raw} 14 | 15 | WORKDIR /usr/src/restcli 16 | 17 | ADD . /usr/src/restcli 18 | 19 | RUN pip install -r requirements.txt 20 | 21 | RUN pip install . 22 | 23 | ENTRYPOINT ["restcli"] 24 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ------- 5 | 6 | 7 | 0.0.1 (2017-03-15) 8 | ------------------ 9 | 10 | * Project has born. 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Dustin Rohde. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include HISTORY.rst 3 | include README.rst 4 | 5 | recursive-include tests * 6 | recursive-exclude * __pycache__ 7 | recursive-exclude * *.py[co] 8 | 9 | recursive-include docs *.rst conf.py Makefile make.bat 10 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | ✨ restcli ✨ 3 | ============= 4 | 5 | **restcli** is a terminal web API client written in Python. It draws 6 | inspiration from `Postman`_ and `HTTPie`_, and offers some of the best features 7 | of both. 8 | 9 | 10 | Features 11 | ======== 12 | 13 | * save requests as YAML files 14 | * scripting 15 | * parameterized requests using `Jinja2`_ templating 16 | * expressive commandline syntax, inspired by `HTTPie`_ 17 | * first-class JSON support 18 | * interactive prompt with autocomplete 19 | * colored output 20 | 21 | 22 | CLI Usage 23 | ========= 24 | 25 | Command-line usage is documented in the 26 | `Usage manual `_. 27 | 28 | 29 | Documentation 30 | ============= 31 | 32 | * `Overview `_ 33 | * `Usage `_ 34 | * `Making Requests `_ 35 | * `Tutorial `_ 36 | 37 | 38 | Installation 39 | ============ 40 | 41 | With ``pip``: 42 | 43 | .. code-block:: sh 44 | 45 | $ pip install -r requirements.txt 46 | $ pip install . 47 | 48 | With ``setup.py``: 49 | 50 | .. code-block:: sh 51 | 52 | $ python setup.py install 53 | 54 | With ``setup.py`` but allow edits to the files under ``restcli/`` and reflect 55 | those changes without having to reinstall ``restcli``: 56 | 57 | .. code-block:: sh 58 | 59 | $ python setup.py develop 60 | 61 | If you have ``invoke``, you can use it for running the tests and installation. 62 | If not, you can install it with ``pip install invoke``. 63 | 64 | .. code-block:: sh 65 | 66 | $ invoke test # Run the tests 67 | $ invoke install # Install it 68 | $ invoke build # Run the whole build workflow 69 | 70 | 71 | Docker 72 | ------ 73 | 74 | Assuming Docker is installed, **restcli** can run inside a container. To build 75 | the Docker container, run the following from the project root: 76 | 77 | .. code-block:: console 78 | 79 | $ docker build -t restcli . 80 | 81 | Then you can run commands from within the container: 82 | 83 | .. code-block:: console 84 | 85 | $ docker run -it restcli -c foobar.yaml run foo bar 86 | $ docker run -it restcli --save -c api.yaml -e env.yaml env foo:bar 87 | 88 | 89 | Roadmap 90 | ======= 91 | 92 | 93 | Short-term 94 | ---------- 95 | 96 | Here's what we have in store for the foreseeable future. 97 | 98 | * autocomplete Group and Request names in the command prompt 99 | * support for other formats (plaintext, forms, file uploads) 100 | * convert to/from Postman collections 101 | 102 | 103 | Long-term 104 | --------- 105 | 106 | Here are some longer-term feature concepts that may or may not get implemented. 107 | 108 | * full screen terminal UI via `python_prompt_toolkit`_ 109 | * in-app request editor (perhaps using `pyvim`_) 110 | 111 | 112 | License 113 | ======= 114 | 115 | This software is distributed under the `Apache License, Version 2.0`_. 116 | 117 | .. _Postman: https://www.getpostman.com/postman 118 | .. _HTTPie: https://httpie.org/ 119 | .. _Jinja2: http://jinja.pocoo.org/ 120 | .. _python_prompt_toolkit: https://github.com/jonathanslenders/python-prompt-toolkit 121 | .. _pyvim: https://github.com/jonathanslenders/pyvim 122 | .. _Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 123 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ## Short Term 4 | 5 | - [ ] Add more examples (using scripts, etc.). 6 | - [ ] Nested groups. 7 | - [ ] Handle errors gracefully. 8 | - [ ] Write more unit tests. 9 | - [ ] Automate grabbing --help output and inserting it into README. 10 | 11 | ## Roadmap 12 | 13 | - [ ] Implement localization using ugettext. 14 | - [ ] Write a terminal UI using `prompt_toolkit`. 15 | - [ ] Export Collections to/from Postman. 16 | - [ ] Support multiple locales. 17 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/augustawind/restcli/ca45cee01aca406b1219a64d03a77eda5c2516e3/docs/.nojekyll -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = restcli 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | mkdir -p api/ 21 | sphinx-apidoc --output api/ ../restcli/ 22 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 23 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-merlot -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # restcli documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Aug 11 14:29:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = ['sphinx.ext.todo', 35 | 'sphinx.ext.githubpages', 36 | 'sphinx.ext.autodoc', 37 | 'sphinx.ext.napoleon', 38 | 'sphinx.ext.viewcode'] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix(es) of source filenames. 44 | # You can specify multiple suffix as a list of string: 45 | # 46 | # source_suffix = ['.rst', '.md'] 47 | source_suffix = '.rst' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = 'restcli' 54 | copyright = '2017, Dustin Rohde' 55 | author = 'Dustin Rohde' 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | # The short X.Y version. 62 | version = '0.1.0' 63 | # The full version, including alpha/beta/rc tags. 64 | release = '0.1.0' 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | # 69 | # This is also used if you do content translation via gettext catalogs. 70 | # Usually you set "language" from the command line for these cases. 71 | language = None 72 | 73 | # List of patterns, relative to source directory, that match files and 74 | # directories to ignore when looking for source files. 75 | # This patterns also effect to html_static_path and html_extra_path 76 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 77 | 78 | # The name of the Pygments (syntax highlighting) style to use. 79 | pygments_style = 'sphinx' 80 | 81 | # If true, `todo` and `todoList` produce output, else they produce nothing. 82 | todo_include_todos = True 83 | 84 | 85 | # -- Options for HTML output ---------------------------------------------- 86 | 87 | # The theme to use for HTML and HTML Help pages. See the documentation for 88 | # a list of builtin themes. 89 | # 90 | html_theme = 'alabaster' 91 | 92 | # Theme options are theme-specific and customize the look and feel of a theme 93 | # further. For a list of options available for each theme, see the 94 | # documentation. 95 | # 96 | # html_theme_options = {} 97 | 98 | # Add any paths that contain custom static files (such as style sheets) here, 99 | # relative to this directory. They are copied after the builtin static files, 100 | # so a file named "default.css" will overwrite the builtin "default.css". 101 | # html_static_path = ['_static'] 102 | 103 | 104 | # -- Options for HTMLHelp output ------------------------------------------ 105 | 106 | # Output file base name for HTML help builder. 107 | htmlhelp_basename = 'restclidoc' 108 | 109 | 110 | # -- Options for LaTeX output --------------------------------------------- 111 | 112 | latex_elements = { 113 | # The paper size ('letterpaper' or 'a4paper'). 114 | # 115 | # 'papersize': 'letterpaper', 116 | 117 | # The font size ('10pt', '11pt' or '12pt'). 118 | # 119 | # 'pointsize': '10pt', 120 | 121 | # Additional stuff for the LaTeX preamble. 122 | # 123 | # 'preamble': '', 124 | 125 | # Latex figure (float) alignment 126 | # 127 | # 'figure_align': 'htbp', 128 | } 129 | 130 | # Grouping the document tree into LaTeX files. List of tuples 131 | # (source start file, target name, title, 132 | # author, documentclass [howto, manual, or own class]). 133 | latex_documents = [ 134 | (master_doc, 'restcli.tex', 'restcli Documentation', 135 | 'Dustin Rohde', 'manual'), 136 | ] 137 | 138 | 139 | # -- Options for manual page output --------------------------------------- 140 | 141 | # One entry per manual page. List of tuples 142 | # (source start file, name, description, authors, manual section). 143 | man_pages = [ 144 | (master_doc, 'restcli', 'restcli Documentation', 145 | [author], 1) 146 | ] 147 | 148 | 149 | # -- Options for Texinfo output ------------------------------------------- 150 | 151 | # Grouping the document tree into Texinfo files. List of tuples 152 | # (source start file, target name, title, author, 153 | # dir menu entry, description, category) 154 | texinfo_documents = [ 155 | (master_doc, 'restcli', 'restcli Documentation', 156 | author, 'restcli', 'One line description of project.', 157 | 'Miscellaneous'), 158 | ] 159 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. _index: 2 | .. restcli documentation master file, created by 3 | sphinx-quickstart on Fri Aug 11 14:29:02 2017. 4 | You can adapt this file completely to your liking, but it should at least 5 | contain the root `toctree` directive. 6 | 7 | **restcli** user manual 8 | ======================= 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | 13 | overview 14 | usage 15 | requests 16 | tutorial 17 | api/modules 18 | 19 | 20 | ---- 21 | 22 | 23 | .. include:: ../README.rst 24 | :start-line: 4 25 | -------------------------------------------------------------------------------- /docs/overview.rst: -------------------------------------------------------------------------------- 1 | ######## 2 | Overview 3 | ######## 4 | 5 | In this section we'll get a bird's eye view of **restcli**\'s core concepts. 6 | After reading this section, you should be ready for the 7 | :doc:`Tutorial `. 8 | 9 | .. _overview_collections: 10 | 11 | Collections 12 | =========== 13 | 14 | **restcli** understands your API through YAML files called *Collections*. 15 | Collections are objects composed of *Groups*, which are again objects composed 16 | of `Requests`_. A Collection is essentially just a bunch of 17 | Requests; Groups are purely organizational. 18 | 19 | .. code-block:: yaml 20 | 21 | --- 22 | weapons: 23 | equip: 24 | # <> 25 | info: 26 | # <> 27 | potions: 28 | drink: 29 | # <> 30 | 31 | This Collection has two Groups. The first Group, ``weapons``, has two Requests, 32 | ``equip`` and ``info``. The second has Group is called "potions" and has one 33 | Request called "drink". This is a good example of a well-organized Collection — 34 | Groups were used to provide context, and even though we're using placeholders, 35 | it's easy to infer the purpose of each Request. 36 | 37 | .. _overview_requests: 38 | 39 | Requests 40 | ======== 41 | 42 | A Request is a YAML object that describes a particular action against an API. 43 | Requests are the bread and butter of **restcli**. 44 | 45 | .. code-block:: yaml 46 | 47 | method: post 48 | url: "http://httpbin.org/post" 49 | headers: 50 | Content-Type: application/json 51 | Authorization: {{ password }} 52 | body: | 53 | name: bar 54 | age: {{ cool_number }} 55 | is_cool: true 56 | 57 | At a glance, we can get a rough idea of what's going on. This Request 58 | uses the POST ``method`` to send some data (``body``) to the ``url`` 59 | http://httpbin.org/post\, with the given Content-Type and Authorization 60 | ``headers``. 61 | 62 | Take note of the stuff in between the double curly brackets: ``{{ password }}``, 63 | ``{{ cool_number }}``. These are template variables, which must be interpolated 64 | with concrete values before executing the request, which brings us to our next 65 | topic... 66 | 67 | .. _overview_environments: 68 | 69 | Environments 70 | ============ 71 | 72 | An Environment is a YAML object that defines values which are used to 73 | interpolate template variables in a Collection. Environments can be be modified 74 | with :ref:`scripts `, which we cover in the :doc:`Tutorial 75 | `. 76 | 77 | This Environment could be used with the Request we looked at in the 78 | :ref:`previous section `: 79 | 80 | .. code-block:: yaml 81 | 82 | password: sup3rs3cr3t 83 | cool_number: 25 84 | 85 | Once the Environment is applied, the Request would look something like this: 86 | 87 | .. code-block:: yaml 88 | 89 | method: post 90 | url: "http://httpbin.org/post" 91 | headers: 92 | Content-Type: application/json 93 | Authorization: sup3rs3cr3t 94 | body: | 95 | name: bar 96 | age: 25 97 | is_cool: true 98 | 99 | ********** 100 | Next Steps 101 | ********** 102 | 103 | The recommended way to continue learning is the :doc:`Tutorial `. 104 | -------------------------------------------------------------------------------- /docs/requests.rst: -------------------------------------------------------------------------------- 1 | *************** 2 | Making Requests 3 | *************** 4 | 5 | .. code-block:: console 6 | 7 | $ restcli run --help 8 | 9 | Usage: restcli run [OPTIONS] GROUP REQUEST [MODIFIERS]... 10 | 11 | Run a Request. 12 | 13 | Options: 14 | -o, --override-env TEXT Override Environment variables. 15 | --help Show this message and exit. 16 | 17 | 18 | The ``run`` command runs Requests from a Collection, optionally within an 19 | Environment. It roughly executes the following steps: 20 | 21 | #. Find the given Request in the given Collection. 22 | #. If ``defaults`` are given in a Config Document, use it to fill in missing 23 | parameters in the Request. 24 | #. If an Environment is given, apply any overrides to it. 25 | #. Render the Request with Jinja2, using the Environment if given. 26 | #. Apply any modifiers to the Request. 27 | #. Execute the Request. 28 | #. If the Request has a ``script``, execute it. 29 | #. If ``save`` is true, write any Environment changes to disk. 30 | 31 | 32 | Examples: 33 | 34 | .. code-block:: console 35 | 36 | $ restcli -s -c food.yaml -e env.yaml run recipes add -o !foo 37 | 38 | $ restcli -c api.yaml run users list-all Authorization:abc123 39 | 40 | 41 | Environment overrides 42 | ~~~~~~~~~~~~~~~~~~~~~ 43 | 44 | When running a Request, the Environment can be overrided on-the-fly with the 45 | ``-o`` option. It supports two types of arguments: 46 | 47 | ``KEY:VALUE`` 48 | Set the key ``KEY`` to the value ``VALUE``. 49 | 50 | ``!KEY`` 51 | Delete the key ``KEY``. 52 | 53 | The ``-o`` option must be specified once for each argument. For example, the 54 | following ``run`` invocation will temporarily set the key ``name`` to the value 55 | ``donut`` and delete the key ``foo``: 56 | 57 | .. code-block:: console 58 | 59 | $ restcli -c food.yaml -e env.yaml run recipes add \ 60 | -o name:donut \ 61 | -o !foo 62 | 63 | 64 | Request modifiers 65 | ~~~~~~~~~~~~~~~~~ 66 | 67 | In addition to Environment overrides, the Request itself can be modified 68 | on-the-fly using a special modifier syntax. In cases where an Environment 69 | override changes the same Request parameter, modifiers always take precedence. 70 | They must appear later than other options. 71 | 72 | Each modifier has a `mode `_ and a `parameter 73 | `_. The *operation* describes the thing to be modified, 74 | and the *mode* describes the way in which it's modified. 75 | 76 | Generally, each modifier is written as a commandline flag, specifying the 77 | *mode*, followed by an argument, specifying the *operation*. In the following 78 | example modifier, its *mode* specified as ``-n`` (**assign**) and its 79 | *operation* specified as ``foo:bar``:: 80 | 81 | -n foo:bar 82 | 83 | Modifiers may omit the *mode* flag as well, in which case *mode* will default 84 | to **assign**. Thus, the following modifiers are equivalent:: 85 | 86 | -a foo:bar -n baz=quux 87 | -a foo:bar baz=quux 88 | 89 | Syntax 90 | ...... 91 | 92 | The general syntax of modifiers is described here: 93 | 94 | .. productionlist:: 95 | modifiers: (`mod_append` | `mod_assign` | `mod_delete`)* 96 | mod_assign: "-n" `operation` | `operation` 97 | mod_append: "-a" `operation` 98 | mod_delete: "-d" `operation` 99 | operation: "'" `op` "'" | '"' `op` '"' 100 | operation: `op_header` | `op_query` | `op_body_str` | `op_body_nostr` 101 | op_header: ":" [] 102 | op_query: "==" [] 103 | op_body_str: "=" [] 104 | op_body_nostr: ":=" [] 105 | 106 | 107 | Modifier modes 108 | .............. 109 | 110 | There are three modifier modes: 111 | 112 | **assign** 113 | Assign the specified value to the specified Request parameter, replacing it 114 | if it already exists. This is the default. If no *mode* is specified for a 115 | given *modifier*, its *mode* will default to **assign**. 116 | 117 | If a header ``X-Foo`` were set to ``bar``, the following would change it 118 | to ``quux``: 119 | 120 | .. code-block:: console 121 | 122 | $ restcli run actions get -n X-Foo:quux 123 | 124 | Since **assign** is the default mode, you can omit the ``-n``: 125 | 126 | .. code-block:: console 127 | 128 | $ restcli run actions get X-Foo:quux 129 | 130 | **append** 131 | Append the specified value to the specified Request parameter. This 132 | behavior differs depending the type of the Request parameter. 133 | 134 | If its a *string*, concenate the incoming value to it as a string. 135 | If a string field ``nickname`` were set to ``"foobar"``, the 136 | following would change it to ``"foobar:quux"``. 137 | 138 | .. code-block:: console 139 | :linenos: 140 | 141 | $ restcli run actions post -a nickname=':quux' 142 | 143 | If its a *number*, add the incoming value to it as a number. 144 | If a json field ``age`` were set to ``27``, the following would 145 | change it to ``33``. 146 | 147 | .. code-block:: console 148 | :linenos: 149 | 150 | $ restcli run actions post -a age:=6 151 | 152 | If its an *array*, concatenate the incoming value to it as an array. 153 | If a json field ``colors`` were set to ``["red", "yellow"]``, the 154 | following would change it to ``["red", "yellow", "blue"]``. 155 | 156 | .. code-block:: console 157 | :linenos: 158 | 159 | $ restcli run actions post -a colors:='["blue"]' 160 | 161 | Other types are not currently supported. 162 | 163 | .. todo:: Add validation for other types. 164 | 165 | **delete** 166 | Delete the specified Request parameter. This ignores the value completely. 167 | 168 | If a url parameter ``pageNumber`` were set to anything, the following would 169 | remove it from the url query completely. 170 | 171 | .. code-block:: console 172 | :linenos: 173 | 174 | $ restcli run actions get -d pageNumber== 175 | 176 | .. todo:: Rename ``append`` mode to ``add`` and maybe ``assign`` to ``set`` or 177 | ``replace``. 178 | 179 | .. table:: Table of modifier modes 180 | 181 | ========= ========= ================ 182 | Mode Flag Usage 183 | ========= ========= ================ 184 | assign ``-n`` ``-n OPERATION`` 185 | append ``-a`` ``-a OPERATION`` 186 | delete ``-d`` ``-d OPERATION`` 187 | ========= ========= ================ 188 | 189 | 190 | Modifier operations 191 | ................... 192 | 193 | Operations 194 | 195 | **header** 196 | Operators on a header key-value pair. The *key* and *value* must be valid 197 | ASCII. Delimited by ``:``. 198 | **url param** 199 | A URL query parameter. Delimited by ``==``. 200 | **string field** 201 | A JSON object key-value pair. The *value* will be interpreted as a string. 202 | Delimited by ``=``. 203 | **json field** 204 | A JSON object key-value pair. The *value* will be interpreted as a string. 205 | Delimited by ``:=``. 206 | 207 | 208 | .. table:: Table of modifier operations 209 | 210 | ============ ========= ==================== ======================= 211 | Operation Delimiter Usage Examples 212 | ============ ========= ==================== ======================= 213 | header ``:`` - ``KEY : VALUE`` - ``Authorization:abc`` 214 | - ``KEY :`` - ``Authorization:`` 215 | url param ``==`` - ``KEY == VALUE`` - ``locale==en_US`` 216 | - ``KEY ==`` - ``locale==`` 217 | string field ``=`` - ``KEY = VALUE`` - ``username=foobar`` 218 | - ``KEY =`` - ``username=`` 219 | json field ``:=`` - ``KEY := VALUE`` - ``age:=15`` 220 | - ``KEY :=`` - ``age:=`` 221 | ============ ========= ==================== ======================= 222 | 223 | Examples 224 | ........ 225 | 226 | To follow along with the examples, grab the `simple example project`_ from the 227 | **restcli** source. Then from the example directory, export some environment 228 | variables to use the example project's Collection and Environment files: 229 | 230 | .. code-block:: console 231 | 232 | $ export RESTCLI_COLLECTION="simple.collection.yaml" 233 | $ export RESTCLI_ENV="simple.env.yaml" 234 | 235 | To check your work after each **restcli run** invocation, just inspect the 236 | response. All the Requests in this Collection will respond with a JSON blob 237 | containing the information about your HTTP request, like this: 238 | 239 | .. code-block:: console 240 | 241 | $ restcli run actions get 242 | 243 | .. code-block:: javascript 244 | 245 | // HTTP response 246 | 247 | { 248 | "args": { 249 | "fooParam": "10" 250 | }, 251 | "headers": { 252 | "Accept": "application/json", 253 | "Accept-Encoding": "gzip, deflate", 254 | "Connection": "close", 255 | "Host": "httpbin.org", 256 | "User-Agent": "HTTPie/0.9.9", 257 | "X-Foo": "foo+bar+baz" 258 | }, 259 | "origin": "75.76.62.109", 260 | "url": "https://httpbin.org/get?fooParam=10" 261 | } 262 | 263 | **Example 1** 264 | 265 | Delete the header ``"Accept"``. 266 | 267 | .. code-block:: bash 268 | 269 | $ run actions get -d Accept: 270 | 271 | **Example 2** 272 | 273 | Append the string ``"420"`` to the body value ``"nickname"``. 274 | 275 | .. code-block:: bash 276 | 277 | $ run actions post -a time=420 278 | 279 | **Example 3** 280 | 281 | Assign the array ``'["red", "yellow", "blue"]'`` to the body value 282 | ``"colors"``. 283 | 284 | .. code-block:: bash 285 | 286 | $ run actions post -n colors:='["red", "yellow", "blue"]' 287 | 288 | 289 | .. _simple example project: https://github.com/dustinrohde/restcli/tree/master/examples/simple 290 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | ######################### 2 | Tutorial: Modeling an API 3 | ######################### 4 | 5 | .. note:: 6 | This tutorial assumes that you've read the :doc:`Overview ` and 7 | :doc:`Usage ` documentation. 8 | 9 | Throughout this tutorial we will be modeling an API with **restcli**, 10 | gradually adding to it as we learn new concepts, until we have a complete 11 | API client suite. While the final result will be pasted at the end, I 12 | encourage you to follow along and do it yourself as we go. This will give 13 | you many opportunities to experiment and learn things you may not have 14 | learned otherwise! 15 | 16 | .. _tutorial_debriefing: 17 | 18 | ********** 19 | Debriefing 20 | ********** 21 | 22 | You have been commissioned to build an API for the notorious secret society, 23 | the Sons of Secrecy. You were told the following information, in hushed 24 | whispers: 25 | 26 | #. New members can join by invite only. 27 | #. Each member has a rank within the Society. 28 | #. Your rank determines how many secrets you are told. 29 | #. Only the highest ranking members, called Whisperers, have the ability to 30 | recruit and promote members through the ranks. 31 | 32 | Your task is to create a membership service for the Whisperers to keep track of 33 | and manage their underlings. Using the service, Whisperers must be able to: 34 | 35 | #. Invite new members. 36 | #. Promote or demote members' ranks. 37 | #. Send "secrets" to members. 38 | 39 | In addition, the service must be guarded by a secret key, and no requests 40 | should go through if they do not contain the key. 41 | 42 | Let's get started! 43 | 44 | .. _tutorial_requests: 45 | 46 | Requests 47 | -------- 48 | 49 | We'll start by modeling the new member invitation service: 50 | 51 | .. code-block:: yaml 52 | 53 | # secrecy.yaml 54 | --- 55 | memberships: 56 | invite: 57 | method: post 58 | url: "{{ server }}/memberships/invite" 59 | headers: 60 | Content-Type: application/json 61 | X-Secret-Key: '{{ secret_key }}' 62 | body: | 63 | name: {{ member_name }} 64 | age: {{ member_age }} 65 | can_keep_secrets: true 66 | 67 | 68 | We made a new Collection and saved it as ``secrecy.yaml``. So far it has one 69 | Group called ``memberships`` with one Request called ``invite``. 70 | 71 | As requested, we've also added an ``X-Secret-Key`` header which holds the 72 | secret key. It's parameterized so that each Whisperer can have their own 73 | personal key. This will be explained later in the `templating`_ section. 74 | 75 | .. _tutorial_request_parameters: 76 | 77 | Request Parameters 78 | ~~~~~~~~~~~~~~~~~~ 79 | 80 | Let's zoom in a bit on Requests. While we're at it, we'll inspect our 81 | ``invite`` Request more closely as well. 82 | 83 | ``method`` (string, required) 84 | HTTP method to use. Case insensitive. 85 | 86 | We chose POST as our method for ``invite`` since POST is generally used for 87 | creating resources. Also, per `RFC 7231`_, the POST method should be used 88 | when the request is non-`idempotent`_. 89 | 90 | ``url`` (string, required, templating) 91 | Fully qualified URL that will receive the request. Supports `templating`_. 92 | 93 | We chose to parameterize the ``scheme://host`` portion of the URL as 94 | ``{{ server }}``. As we'll see later, this makes it easy to change the 95 | host without a lot of labor, and makes it clear that the path portion of 96 | the URL, ``/memberships/invite``, is the real subject of this Request. 97 | 98 | We'll learn more about template variables later, but for now we know that 99 | invitations happen at ``/send_invite``. 100 | 101 | ``headers`` (object, ~templating) 102 | HTTP headers to add. Keys and values must all be strings. Values support 103 | `templating`_, but keys don't. 104 | 105 | We're using the standard ``Content-Type`` header as well as a custom, 106 | parameterized header called ``X-Secret-Key``. We'll inspect this further 107 | in the `templating`_ section. 108 | 109 | ``body`` (string, templating) 110 | The request body. It must be encoded as a string, to facilitate the full 111 | power of `Jinja2`_ `templating`_. You'll probably want to read the section 112 | on :ref:`YAML block style ` at some point. 113 | 114 | The body string must contain valid YAML, which is converted to JSON before 115 | sending the request. Only JSON encoding is supported at this time. 116 | 117 | Our ``body`` parameter has 3 fields, ``name``, ``age``, and 118 | ``can_keep_secrets``. The first two are parameterized, but we just set the 119 | third to ``true`` since keeping secrets is pretty much required if you're 120 | gonna join the Sons of Secrecy. 121 | 122 | ``script`` (string) 123 | A Python script to be executed after the request finishes and a response is 124 | received. Scripts can be used to dynamically update the :ref:`Environment 125 | ` based on the response payload. We'll learn more 126 | about this later in `scripting`_. 127 | 128 | Our ``invite`` Request doesn't have a script. 129 | 130 | 131 | Templating 132 | ---------- 133 | 134 | **restcli** supports `Jinja2`_ templates in the ``url``, ``headers``, and 135 | ``body`` Request Parameters. This is used to parameterize Requests with the 136 | help of :ref:`Environments `. Any template variables in 137 | these parameters, denoted by double curly brackets, will be replaced with 138 | concrete values from the given Environment before the request is executed. 139 | 140 | During the `Debriefing`_, were told that the Whisperers can move members up the 141 | ranks if they're deemed worthy. Well it just so happens that Wanda, a fledgling 142 | member, has proven herself as a devout secret-keeper. 143 | 144 | We'll start by adding another Request to our ``memberships`` Group: 145 | 146 | .. code-block:: yaml 147 | 148 | # secrecy.yaml 149 | --- 150 | memberships: 151 | invite: ... 152 | 153 | bump_rank: 154 | method: patch 155 | url: '{{ server }}/memberships/{{ member_id }}' 156 | headers: 157 | Content-Type: application/json 158 | X-Secret-Key: '{{ secret_key }}' 159 | body: | 160 | title: '{{ titles[rank + 1] }}' 161 | rank: '{{ rank + 1 }}' 162 | 163 | 164 | Whew, lots of variables! Let's whip up an Environment file for Wanda. This 165 | strategy has the advantage that we can seamlessly move between different members 166 | without making any changes to the Collection. 167 | 168 | .. code-block:: yaml 169 | 170 | # wanda.env.yaml 171 | --- 172 | server: 'https://www.secrecy.org' 173 | secret_key: sup3rs3cr3t 174 | titles: 175 | - Loudmouth 176 | - Seeker 177 | - Keeper 178 | - Confidant 179 | - Spectre 180 | member_id: UGK882I59 181 | rank: 0 182 | #new_secrets: 183 | # - secret basement room full of kittens 184 | # - turtles all the way down 185 | 186 | .. todo:: add `new_secrets` below, remove from above. 187 | 188 | .. note:: 189 | The ``env.yaml`` extension in ``wanda.env.yaml`` is just a convention to 190 | identify the file as an Environment. Any extension may be used. 191 | 192 | We're almost ready to run it, but let's change ``server`` to something real 193 | so we don't get any errors: 194 | 195 | .. code-block:: yaml 196 | 197 | server: http://httpbin.org/anything 198 | 199 | Now we'll run the request: 200 | 201 | .. code-block:: sh 202 | 203 | $ restcli -c secrecy.yaml -e wanda.env.yaml run memberships bump_rank 204 | 205 | Here's what **restcli** does when we hit enter: 206 | 207 | #. Load the Collection (``secrecy.yaml``) and locate the Request 208 | ``memberships.bump_rank``. 209 | #. Load the Environment (``wanda.yaml``). 210 | #. Use the Environment to execute the contents of the ``url``, ``headers``, and 211 | ``body`` parameters as `Jinja2 Template`_\s,. 212 | #. Run the resulting HTTP request. 213 | 214 | If we could view the finalized Request object before running it in #4, this is 215 | what it would look like: 216 | 217 | .. code-block:: yaml 218 | 219 | # secrecy.yaml 220 | 221 | method: post 222 | url: 'https://www.secrecy.org/memberships/12345/bump_rank' 223 | headers: 224 | Content-Type: application/json 225 | X-Secret-Key: sup3rs3cr3t 226 | body: | 227 | rank: 1 228 | title: Seeker 229 | 230 | Here's a piece-by-piece breakdown of what happened: 231 | 232 | + In the ``url`` section: 233 | + ``{{ server }}`` was replaced with the value of Environment variable 234 | ``server``. 235 | + ``{{ member_id }}`` was replaced with the value of Environment variable 236 | ``member_id``. 237 | + In the ``headers`` section, ``{{ secret_key }}`` was replaced with the value 238 | of Environment variable ``secret_key``. 239 | + In the ``body`` section: 240 | + ``{{ rank }}`` was replaced with the value of Environment variable 241 | ``rank``, incremented by 1. 242 | + ``{{ title }}`` was replaced by an item from the Environment variable 243 | ``titles``, an array, by indexing it with the incremented rank value. 244 | 245 | .. note:: 246 | When it gets a request, http://httpbin.org/anything echoes back the 247 | URL, headers, and request body in the response. You can use this to check 248 | your work. If something is off, be sure to fix it before we continue. 249 | 250 | Congrats on your new rank Wanda! 251 | 252 | What we just learned should cover most use cases, but if you need more power 253 | or just want to explore, there's much more to templating than what we just 254 | covered! **restcli** supports the entire Jinja2 template language, so check 255 | out the official `Template Designer Documentation`_ for the whole scoop. 256 | 257 | .. _tutorial_scripting: 258 | 259 | Scripting 260 | --------- 261 | 262 | Templating is a powerful feature that allows you to make modular, reusable 263 | Requests which encapsulate particular functions of your API without being tied 264 | to specifics. We demonstrated this by modeling a function to increase a 265 | member's rank, and created an Environment file to use it on Wanda. If we wanted 266 | to do the same for another member, we'd simply create a new Environment. 267 | 268 | However, what happens when it's time for Wanda's second promotion? We know 269 | her current rank is 1, but the Environment still says 0. If we ran the 270 | ``bump_rank`` Request on the same Environment again, we'd get the same result: 271 | 272 | .. code-block:: yaml 273 | 274 | # secrecy.yaml 275 | 276 | body: | 277 | rank: 1 278 | title: Seeker 279 | 280 | We need a way to update the Environment automatically after we run the Request. 281 | 282 | This is achieved through scripting. As mentioned earlier in `Request 283 | Parameters`_, each Request supports an optional ``script`` parameter which 284 | contains Python code. It is evaluated after the request is ran, and can modify 285 | the current Environment. 286 | 287 | Let's add a script to our ``bump_rank`` Request: 288 | 289 | .. code-block:: yaml 290 | 291 | # secrecy.yaml 292 | 293 | bump_rank: 294 | ... 295 | script: | 296 | env['rank'] += 1 297 | 298 | Now each time we run ``bump_rank`` it will update the Environment with the new 299 | value. Let's run it again to see the changes in action: 300 | 301 | .. code-block:: sh 302 | 303 | $ restcli --save -c secrecy.yaml -e wanda.env.yaml run memberships bump_rank 304 | 305 | Notice that we added the ``--save`` flag. Without this, changes to the 306 | Environment would not be saved to disk. 307 | 308 | Open up your Environment file and make sure ``rank`` was updated successfully. 309 | 310 | .. note:: 311 | All script examples were written for Python3.7, but most will probably work 312 | in Python3+. To get version info, including the Python version, use the 313 | ``--version`` flag: 314 | 315 | .. code-block:: sh 316 | 317 | $ restcli --version 318 | 319 | Under the hood, scripts are executed with the Python builtin ``exec()``, which 320 | is called with a code object containing the script as well as a ``globals`` 321 | dict containing the following variables: 322 | 323 | ``response`` 324 | A `Response object`_ from the Python `requests library`_, which contains 325 | the status code, response headers, response body, and a lot more. Check 326 | out the `Response API `_ for a detailed list. 327 | 328 | ``env`` 329 | A Python dict which contains the entire hierarchy of the current 330 | Collection. It is mutable, and editing its contents may result in one or 331 | both of the following effects: 332 | 333 | A. If running in interactive mode, any changes made will persist in the 334 | active Environment until the session ends. 335 | B. If ``autosave`` is enabled, the changes will be saved to disk. 336 | 337 | Any functions or variables imported in the ``lib`` section of the `Config 338 | document`_ will be available in your scripts as well. We'll tackle the 339 | `Config document`_ in the next section. 340 | 341 | .. note:: 342 | Since Python is whitespace sensitive, you'll probably want to read the 343 | section on :ref:`YAML block style `. 344 | 345 | 346 | .. _Config document: 347 | 348 | The Config Document 349 | ------------------- 350 | 351 | So far our Collections have been composed of a single YAML document. 352 | **restcli** supports an optional second document per Collection as well, called 353 | the Config Document. 354 | 355 | .. note:: 356 | If you're not sure what "document" means in YAML, here's a quick primer: 357 | 358 | Essentially, documents allow you to have more than one YAML "file" 359 | (document) in the same file. Notice that ``---`` that appears at the top 360 | of each example we've looked at? That's how you tell YAML where your 361 | document begins. 362 | 363 | Technically, the spec has more rules than that for documents but PyYAML, 364 | the library **restcli** uses, isn't that strict. Here's the spec 365 | anyway if you're interested: http://yaml.org/spec/1.2/spec.html#id2800132 366 | 367 | If present, the Config Document must appear *before* the Requests document. 368 | Breaking it down, a Collection must either: 369 | 370 | - contain exactly one document, the Requests document, or 371 | - contain exactly two documents; the Config Document and the Requests document, 372 | in that order. 373 | 374 | Let's add a Config Document to our Secretmasons Collection. We'll take a look 375 | and then jump into explanations after: 376 | 377 | .. code-block:: yaml 378 | 379 | # secrecy.yaml 380 | --- 381 | defaults: 382 | headers: 383 | Content-Type: application/json 384 | X-Secret-Key: '{{ secret_key }}' 385 | lib: 386 | - restcli.contrib.scripts 387 | 388 | --- 389 | memberships: 390 | invite: ... 391 | 392 | upgrade: ... 393 | 394 | 395 | Config Parameters 396 | ~~~~~~~~~~~~~~~~~ 397 | 398 | The Config Document is used for global configuration in general, so the 399 | parameters defined here don't have much in common. 400 | 401 | ``defaults`` (object) 402 | Default values to use for each Request parameter when not specified in the 403 | Request. ``defaults`` has the same structure as a Request, so each 404 | parameters defined here must also be valid as a Request parameter. 405 | 406 | 407 | ``lib`` (array) 408 | ``lib`` is an array of Python module paths. Each module here must contain a 409 | function with the signature ``define(request, env, *args, **kwargs)`` which 410 | returns a dict. That dict will be added to the execution environment of any 411 | script that gets executed after a Request is completed. 412 | 413 | **restcli** ships with a pre-baked ``lib`` module at 414 | ``restcli.contrib.scripts``. It provides some useful utility functions 415 | to use in your scripts. It can also be used as a learning tool. 416 | 417 | 418 | ******** 419 | Appendix 420 | ******** 421 | 422 | .. _appendix_block_style: 423 | 424 | A. YAML Block Style 425 | -------------------- 426 | 427 | Writing multiline strings for the ``body`` and ``script`` Request parameters 428 | without losing readability is easy with YAML's `block style`_. I recommend 429 | using `literal style`_ since it preserves whitespace and is the most readable. 430 | Adding to the example above: 431 | 432 | .. code-block:: yaml 433 | 434 | body: | 435 | name: bar 436 | age: {{ foo_age }} 437 | attributes: 438 | fire_spinning: 32 439 | basket_weaving: 11 440 | 441 | The vertical bar (``|``) denotes the start of a literal block, so newlines are 442 | preserved, as well as any *additional* indentation. In this example, the 443 | result is that the value of ``body`` is 5 lines of text, with the last two 444 | lines indented 4 spaces. 445 | 446 | Note that it is impossible to escape characters within a literal block, so if 447 | that's something you need you may have to try a different 448 | 449 | .. _RFC 7231: https://tools.ietf.org/html/rfc7231 450 | .. _idempotent: https://en.wikipedia.org/wiki/Idempotence#Computer_science_meaning 451 | .. _Jinja2: http://jinja.pocoo.org/ 452 | .. _Jinja2 Template: http://jinja.pocoo.org/docs/2.9/api/#jinja2.Template 453 | .. _Template Designer Documentation: http://jinja.pocoo.org/docs/2.9/templates/ 454 | .. _response object: http://docs.python-requests.org/en/stable/api/#requests.Response 455 | .. _requests library: http://docs.python-requests.org/en/stable/ 456 | .. _block style: http://www.yaml.org/spec/1.2/spec.html#id2793604 457 | .. _literal style: http://www.yaml.org/spec/1.2/spec.html#id2793604 458 | -------------------------------------------------------------------------------- /docs/tutorial/secrecy.yaml: -------------------------------------------------------------------------------- 1 | # secrecy.yaml 2 | --- 3 | memberships: 4 | invite: 5 | method: post 6 | url: "{{ server }}/memberships/invite" 7 | headers: 8 | Content-Type: application/json 9 | X-Secret-Key: '{{ secret_key }}' 10 | body: | 11 | name: {{ member_name }} 12 | age: {{ member_age }} 13 | can_keep_secrets: true 14 | 15 | bump_rank: 16 | method: patch 17 | url: '{{ server }}/memberships/{{ member_id }}' 18 | headers: 19 | Content-Type: application/json 20 | X-Secret-Key: '{{ secret_key }}' 21 | body: | 22 | title: '{{ titles[rank + 1] }}' 23 | rank: '{{ rank + 1 }}' 24 | script: | 25 | env['rank'] += 1 26 | -------------------------------------------------------------------------------- /docs/tutorial/wanda.env.yaml: -------------------------------------------------------------------------------- 1 | server: http://httpbin.org/anything 2 | secret_key: sup3rs3cr3t 3 | titles: 4 | - Loudmouth 5 | - Seeker 6 | - Keeper 7 | - Confidant 8 | - Spectre 9 | member_id: UGK882I59 10 | rank: 1 11 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ##### 2 | Usage 3 | ##### 4 | 5 | **restcli** is invoked from the command-line. To display usage info, supply the 6 | ``--help`` flag: 7 | 8 | .. code-block:: console 9 | 10 | $ restcli --help 11 | 12 | Usage: restcli [OPTIONS] COMMAND [ARGS]... 13 | 14 | Options: 15 | -v, --version Show the version and exit. 16 | -c, --collection PATH Collection file. 17 | -e, --env PATH Environment file. 18 | -s, --save / -S, --no-save Save Environment to disk after changes. 19 | -q, --quiet / -Q, --loud Suppress HTTP output. 20 | --help Show this message and exit. 21 | 22 | Commands: 23 | env View or set Environment variables. 24 | exec Run multiple Requests from a file. 25 | repl Start an interactive prompt. 26 | run Run a Request. 27 | view View a Group, Request, or Request Parameter. 28 | 29 | The available commands are: 30 | 31 | `Command: run`_ 32 | Run a Request. 33 | 34 | `Command: exec`_ 35 | Run multiple Requests from a file. 36 | 37 | `Command: view`_ 38 | Inspect the contents of a Group, Request, or Request attribute. 39 | 40 | `Command: env`_ 41 | View or set Environment variables. 42 | 43 | `Command: repl`_ 44 | Start the interactive prompt. 45 | 46 | To display usage info for the different commands, supply the ``--help`` flag to 47 | that particular command. 48 | 49 | 50 | ************ 51 | Command: run 52 | ************ 53 | 54 | The ``run`` command is documented on its own page, in :doc:`Making Requests 55 | `. 56 | 57 | 58 | ************* 59 | Command: exec 60 | ************* 61 | 62 | .. code-block:: console 63 | 64 | $ restcli exec --help 65 | 66 | Usage: restcli exec [OPTIONS] FILE 67 | 68 | Run multiple Requests from a file. 69 | 70 | If '-' is given, stdin will be used. Lines beginning with '#' are ignored. 71 | Each line in the file should specify args for a single "run" invocation: 72 | 73 | [OPTIONS] GROUP REQUEST [MODIFIERS]... 74 | 75 | Options: 76 | --help Show this message and exit. 77 | 78 | The ``exec`` command loops through the given file, calling ``run`` with the 79 | arguments provided on each line. For example, for the following file: 80 | 81 | .. code-block:: text 82 | 83 | # requests.txt 84 | accounts create -o password:abc123 85 | accounts update password==abc123 -o name:foobar 86 | 87 | These two invocations are equivalent: 88 | 89 | .. code-block:: console 90 | 91 | $ restcli exec requests.txt 92 | 93 | .. code-block:: console 94 | 95 | $ restcli run accounts create -o password:abc123 96 | $ restcli run update password==abc123 -o name:foobar 97 | 98 | 99 | ************* 100 | Command: view 101 | ************* 102 | 103 | .. code-block:: console 104 | 105 | $ restcli view --help 106 | 107 | Usage: restcli view [OPTIONS] GROUP [REQUEST] [PARAM] 108 | 109 | View a Group, Request, or Request Parameter. 110 | 111 | Options: 112 | -r, --render / -R, --no-render Render with Environment variables. 113 | --help Show this message and exit. 114 | 115 | The ``view`` command selects part of a Collection and outputs it as JSON. 116 | It has three forms, described here with examples: 117 | 118 | **Group view** 119 | Select an entire Group, e.g.: 120 | 121 | .. code-block:: console 122 | 123 | $ restcli view chordata 124 | 125 | .. code-block:: javascript 126 | 127 | { 128 | "mammalia": { 129 | "headers": { 130 | ... 131 | }, 132 | "body": ..., 133 | ... 134 | }, 135 | "amphibia": { 136 | ... 137 | }, 138 | ... 139 | } 140 | 141 | **Request view** 142 | Select a particular Request within a Group, e.g.: 143 | 144 | .. code-block:: console 145 | 146 | $ restcli view chordata mammalia 147 | 148 | .. code-block:: json 149 | 150 | { 151 | "url": "{{ server }}/chordata/mammalia" 152 | "method": "get", 153 | "headers": { 154 | "Content-Type": "application/json", 155 | "Accept": "application/json", 156 | } 157 | } 158 | 159 | **Request Attribute view** 160 | Select a single Attribute of a Request, e.g.: 161 | 162 | .. code-block:: console 163 | 164 | $ restcli view chordata mammalia url 165 | 166 | .. code-block:: json 167 | 168 | "{{ server }}/chordata/mammalia" 169 | 170 | The output of ``view`` is just plain JSON, which makes it convenient for 171 | scripts that need to programmatically analyze Collections in some way. 172 | 173 | Use the ``--render`` flag to render template variables, e.g.: 174 | 175 | .. code-block:: console 176 | 177 | $ restcli view --render chordata mammalia url 178 | 179 | .. code-block:: json 180 | 181 | "https://animals.io/chordata/mammalia" 182 | 183 | 184 | ************ 185 | Command: env 186 | ************ 187 | 188 | .. todo:: Write this section 189 | 190 | 191 | ************* 192 | Command: repl 193 | ************* 194 | 195 | .. code-block:: console 196 | 197 | Usage: [OPTIONS] COMMAND [ARGS]... 198 | 199 | Options: 200 | -v, --version Show the version and exit. 201 | -c, --collection PATH Collection file. 202 | -e, --env PATH Environment file. 203 | -s, --save / -S, --no-save Save Environment to disk after changes. 204 | -q, --quiet / -Q, --loud Suppress HTTP output. 205 | --help Show this message and exit. 206 | 207 | Commands: 208 | change_collection Change to and load a new Collection file. 209 | change_env Change to and load a new Environment file. 210 | env View or set Environment variables. 211 | exec Run multiple Requests from a file. 212 | reload Reload Collection and Environment from disk. 213 | run Run a Request. 214 | save Save the current Environment to disk. 215 | view View a Group, Request, or Request Parameter. 216 | 217 | The ``repl`` command starts an interactive prompt which allows you to issue 218 | commands in a read-eval-print loop. It supports the same set of commands as the 219 | regular commandline interface and adds a few repl-specific commands as well. 220 | -------------------------------------------------------------------------------- /examples/simple/config.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # 3 | # simple.sh 4 | # Source this file to configure restcli for the example. 5 | # 6 | export RESTCLI_COLLECTION="./simple.collection.yaml" 7 | export RESTCLI_ENV="./simple.env.yaml" 8 | export RESTCLI_AUTOSAVE="1" 9 | -------------------------------------------------------------------------------- /examples/simple/simple.collection.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | actions: 3 | 4 | get: 5 | method: get 6 | url: "https://httpbin.org/get" 7 | query: | 8 | fooParam: "{{ foo_param }}" 9 | "foo=bar": "{{ foobar }}" 10 | headers: 11 | Accept: application/json 12 | X-Foo: "{{ foo_header }}" 13 | 14 | post: 15 | method: post 16 | url: "https://httpbin.org/post" 17 | body: | 18 | nickname: "foobar" 19 | sign: {{ zodiac_sign }} 20 | age: 26 21 | colors: 22 | - green 23 | - purple 24 | -------------------------------------------------------------------------------- /examples/simple/simple.env.yaml: -------------------------------------------------------------------------------- 1 | foo_param: '10' 2 | foo_header: foo+bar+baz 3 | zodiac_sign: scorpio 4 | __rando__: 429831975 5 | -------------------------------------------------------------------------------- /makeenv.sh: -------------------------------------------------------------------------------- 1 | #/usr/bin/env bash 2 | venv_dir=~/.venvs/restcli 3 | 4 | [[ -d ${venv_dir} ]] || python3 -m venv ${venv_dir} 5 | source ${venv_dir}/bin/activate 6 | 7 | pip install -r requirements.dev.txt 8 | pip install -r requirements.txt 9 | 10 | export PYTHONPATH="$PYTHONPATH:$(pwd)/restcli" 11 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | black==20.8b1 2 | invoke==1.5.0 3 | isort==5.7.0 4 | pylint==2.7.2 5 | pytest==6.2.2 6 | pytest-cov==2.11.1 7 | pytest-mock==3.5.1 8 | pytest-timeout==1.4.2 9 | Sphinx==3.5.2 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==7.1.2 2 | click-repl==0.1.6 3 | Jinja2==2.11.3 4 | prompt_toolkit==3.0.16 5 | Pygments==2.8.1 6 | PyYAML==5.4.1 7 | requests==2.25.1 8 | -------------------------------------------------------------------------------- /restcli/__init__.py: -------------------------------------------------------------------------------- 1 | from distutils.version import StrictVersion 2 | 3 | __strict_version__ = StrictVersion("0.2.1") 4 | __version__ = str(__strict_version__) 5 | -------------------------------------------------------------------------------- /restcli/app.py: -------------------------------------------------------------------------------- 1 | import json 2 | from string import Template 3 | 4 | from pygments import highlight 5 | from pygments.formatters.terminal256 import Terminal256Formatter 6 | from pygments.lexers.data import JsonLexer 7 | from pygments.lexers.python import Python3Lexer 8 | from pygments.lexers.textfmts import HttpLexer 9 | 10 | from restcli import utils 11 | from restcli.exceptions import ( 12 | GroupNotFoundError, 13 | ParameterNotFoundError, 14 | RequestNotFoundError, 15 | ) 16 | from restcli.reqmod import lexer, parser 17 | from restcli.requestor import Requestor 18 | 19 | __all__ = ["App"] 20 | 21 | 22 | class App: 23 | """High-level execution logic for restcli. 24 | 25 | Args: 26 | collection_file: Path to a Collection file. 27 | env_file: Path to an Environment file. 28 | autosave: Whether to automatically save Env changes. 29 | style: Pygments style to use for rendering. 30 | 31 | Attributes: 32 | r (:class:`Requestor`): The Requestor object. Handles almost all I/O. 33 | autosave (bool): Whether to automatically save Env changes. 34 | """ 35 | 36 | HTTP_TPL = Template( 37 | "\n".join( 38 | ( 39 | "HTTP/${http_version} ${status_code} ${reason}", 40 | "${headers}", 41 | "${body}", 42 | ) 43 | ) 44 | ) 45 | 46 | def __init__( 47 | self, 48 | collection_file: str, 49 | env_file: str, 50 | autosave: bool = False, 51 | quiet: bool = False, 52 | raw_output: bool = False, 53 | style: str = "fruity", 54 | ): 55 | self.r = Requestor(collection_file, env_file) 56 | self.autosave = autosave 57 | self.quiet = quiet 58 | self.raw_output = raw_output 59 | 60 | self.http_lexer = HttpLexer() 61 | self.json_lexer = JsonLexer() 62 | self.python_lexer = Python3Lexer() 63 | self.formatter = Terminal256Formatter(style=style) 64 | 65 | def run( 66 | self, 67 | group_name: str, 68 | request_name: str, 69 | modifiers: list = None, 70 | env_args: list = None, 71 | save: bool = None, 72 | quiet: bool = None, 73 | ) -> str: 74 | """Run a Request. 75 | 76 | Args: 77 | group_name: A :class:`Group` name in the Collection. 78 | request_name: A :class:`Request` name in the Collection. 79 | modifiers (optional): List of :class:`Request` modifiers. 80 | env_args (optional): List of :class:`Environment` overrides. 81 | save (optional): Whether to save Env changes to disk. 82 | quiet (optional): Whether to suppress output. 83 | 84 | Returns: 85 | The command output. 86 | """ 87 | # Make sure Request exists. 88 | group = self.get_group(group_name, action="run") 89 | self.get_request(group, group_name, request_name, action="run") 90 | 91 | # Parse modifiers. 92 | lexemes = lexer.lex(modifiers) 93 | updater = parser.parse(lexemes) 94 | 95 | response = self.r.request(group_name, request_name, updater, *env_args) 96 | 97 | if utils.select_first(save, self.autosave): 98 | self.r.env.save() 99 | 100 | output = self.show_response(response, quiet=quiet) 101 | return output 102 | 103 | def view( 104 | self, 105 | group_name: str, 106 | request_name: str = None, 107 | param_name: str = None, 108 | render: bool = False, 109 | ) -> str: 110 | """Inspect a Group, Request, or Request Parameter. 111 | 112 | Args: 113 | group_name: The Group to inspect. 114 | request_name: The Request to inspect. 115 | param_name: The Request Parameter to inspect. 116 | render: Whether to render with Environment variables inserted. 117 | 118 | Returns: 119 | The requested object in JSON, colorized. 120 | """ 121 | output_obj = group = self.get_group( 122 | group_name, action="view", render=render 123 | ) 124 | 125 | if request_name: 126 | output_obj = request = self.get_request( 127 | group, group_name, request_name, action="view" 128 | ) 129 | 130 | if param_name: 131 | output_obj = param = self.get_request_param( 132 | request, 133 | group_name, 134 | request_name, 135 | param_name, 136 | action="view", 137 | ) 138 | 139 | if param_name == "script": 140 | return self.highlight(param, self.python_lexer) 141 | 142 | if param_name == "headers": 143 | headers = dict( 144 | l.split(":") for l in param.strip().split("\n") 145 | ) 146 | output = self.key_value_pairs(headers) 147 | return self.highlight(output, self.http_lexer) 148 | 149 | output = self.fmt_json(output_obj) 150 | return self.highlight(output, self.json_lexer) 151 | 152 | def get_group(self, group_name, action, render=False): 153 | """Retrieve a Group object.""" 154 | try: 155 | group = self.r.collection[group_name] 156 | except KeyError: 157 | raise GroupNotFoundError( 158 | file=self.r.collection.source, 159 | action=action, 160 | path=[group_name], 161 | ) 162 | 163 | if render: 164 | # Apply Environment to all Requests 165 | group = { 166 | request_name: self.r.parse_request(request, self.r.env) 167 | for request_name, request in group.items() 168 | } 169 | 170 | return group 171 | 172 | def get_request(self, group, group_name, request_name, action): 173 | """Retrieve a Request object.""" 174 | try: 175 | return group[request_name] 176 | except KeyError: 177 | raise RequestNotFoundError( 178 | file=self.r.collection.source, 179 | action=action, 180 | path=[group_name, request_name], 181 | ) 182 | 183 | def get_request_param( 184 | self, request, group_name, request_name, param_name, action 185 | ): 186 | """Retrieve a Request Parameter.""" 187 | try: 188 | return request[param_name] 189 | except KeyError: 190 | raise ParameterNotFoundError( 191 | file=self.r.collection.source, 192 | action=action, 193 | path=[group_name, request_name, param_name], 194 | ) 195 | 196 | def load_collection(self, source=None): 197 | """Reload the current Collection, changing it to `source` if given.""" 198 | if source: 199 | self.r.collection.source = source 200 | self.r.collection.load() 201 | return "" 202 | 203 | def load_env(self, source=None): 204 | """Reload the current Environment, changing it to `source` if given.""" 205 | if source: 206 | self.r.env.source = source 207 | self.r.env.load() 208 | return "" 209 | 210 | def set_env(self, *env_args, save=False): 211 | """Set some new variables in the Environment.""" 212 | self.r.mod_env(env_args, save=save or self.autosave) 213 | return "" 214 | 215 | def save_env(self): 216 | """Save the current Environment to disk.""" 217 | self.r.env.save() 218 | return "" 219 | 220 | def show_env(self): 221 | """Return a formatted representation of the current Environment.""" 222 | if self.r.env: 223 | output = self.fmt_json(self.r.env) 224 | return self.highlight(output, self.json_lexer) 225 | return "No Environment loaded." 226 | 227 | def show_response(self, response, quiet=None): 228 | """Format an HTTP Response.""" 229 | if utils.select_first(quiet, self.quiet): 230 | return "" 231 | 232 | if response.headers.get("Content-Type", None) == "application/json": 233 | try: 234 | body = self.fmt_json(response.json()) 235 | except json.JSONDecodeError: 236 | body = response.text 237 | else: 238 | body = response.text 239 | 240 | if self.raw_output: 241 | http_txt = body 242 | else: 243 | http_txt = self.HTTP_TPL.substitute( 244 | http_version=str(float(response.raw.version) / 10), 245 | status_code=response.status_code, 246 | reason=response.reason, 247 | headers=self.key_value_pairs(response.headers), 248 | body=body, 249 | ) 250 | return self.highlight(http_txt, self.http_lexer) 251 | 252 | def highlight(self, code, pygments_lexer): 253 | """Highlight the given code. 254 | 255 | If ``self.raw_output`` is True, return ``code`` unaltered. 256 | """ 257 | if self.raw_output: 258 | return code 259 | return highlight(code, pygments_lexer, self.formatter) 260 | 261 | def fmt_json(self, data): 262 | if self.raw_output: 263 | return json.dumps(data) 264 | return json.dumps(data, indent=2) 265 | 266 | @staticmethod 267 | def key_value_pairs(obj): 268 | """Format a dict-like object into lines of 'KEY: VALUE'.""" 269 | return "\n".join(f"{k}: {v}" for k, v in obj.items()) 270 | -------------------------------------------------------------------------------- /restcli/cli.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | import sys 3 | 4 | import click 5 | from click_repl import repl as start_repl 6 | 7 | import restcli 8 | from restcli.app import App 9 | from restcli.exceptions import ( 10 | CollectionError, 11 | EnvError, 12 | InputError, 13 | LibError, 14 | NotFoundError, 15 | expect, 16 | ) 17 | 18 | pass_app = click.make_pass_decorator(App) 19 | 20 | 21 | @click.group() 22 | @click.version_option( 23 | restcli.__version__, 24 | "-v", 25 | "--version", 26 | prog_name="restcli", 27 | message=( 28 | "%(prog)s %(version)s" 29 | f" (Python {'.'.join(map(str, sys.version_info[:3]))})" 30 | ), 31 | ) 32 | @click.option( 33 | "-c", 34 | "--collection", 35 | envvar="RESTCLI_COLLECTION", 36 | type=click.Path(exists=True, dir_okay=False), 37 | help="Collection file.", 38 | ) 39 | @click.option( 40 | "-e", 41 | "--env", 42 | envvar="RESTCLI_ENV", 43 | type=click.Path(exists=True, dir_okay=False), 44 | help="Environment file.", 45 | ) 46 | @click.option( 47 | "-s/-S", 48 | "--save/--no-save", 49 | envvar="RESTCLI_AUTOSAVE", 50 | default=False, 51 | help="Save Environment to disk after changes.", 52 | ) 53 | @click.option( 54 | "-q/-Q", 55 | "--quiet/--no-quiet", 56 | envvar="RESTCLI_QUIET", 57 | default=False, 58 | help="Suppress HTTP output.", 59 | ) 60 | @click.option( 61 | "-r/-R", 62 | "--raw-output/--no-raw-output", 63 | envvar="RESTCLI_RAW_OUTPUT", 64 | default=False, 65 | help="Don't color or format output.", 66 | ) 67 | @click.pass_context 68 | # pylint: disable=redefined-outer-name 69 | def cli(ctx, collection, env, save, quiet, raw_output): 70 | if not ctx.obj: 71 | with expect(CollectionError, EnvError, LibError): 72 | ctx.obj = App( 73 | collection, 74 | env, 75 | autosave=save, 76 | quiet=quiet, 77 | raw_output=raw_output, 78 | ) 79 | 80 | 81 | @cli.command( 82 | help="Run a Request.", 83 | context_settings=dict( 84 | ignore_unknown_options=True, 85 | ), 86 | ) 87 | @click.argument("group") 88 | @click.argument("request") 89 | @click.argument("modifiers", nargs=-1, type=click.UNPROCESSED) 90 | @click.option( 91 | "-o", 92 | "--override-env", 93 | multiple=True, 94 | help="Override Environment variables.", 95 | ) 96 | @pass_app 97 | def run(app, group, request, modifiers, override_env): 98 | with expect(InputError, NotFoundError): 99 | output = app.run( 100 | group, request, modifiers=modifiers, env_args=override_env 101 | ) 102 | click.echo(output) 103 | 104 | 105 | @cli.command( 106 | help="""Run multiple Requests from a file. 107 | 108 | If '-' is given, stdin will be used. Lines beginning with '#' are ignored. Each 109 | line in the file should specify args for a single "run" invocation: 110 | 111 | [OPTIONS] GROUP REQUEST [MODIFIERS]... 112 | """ 113 | ) 114 | @click.argument("file", type=click.File()) 115 | @click.pass_context 116 | # pylint: disable=unexpected-keyword-arg,no-value-for-parameter 117 | # pylint: disable=redefined-builtin 118 | def exec(ctx, file): 119 | for line in file: 120 | line = line.strip() 121 | if line.startswith("#"): 122 | continue 123 | click.echo(f">>> run {line}") 124 | args = shlex.split(line) 125 | try: 126 | run(args, prog_name="restcli", parent=ctx) 127 | except SystemExit: 128 | continue 129 | 130 | 131 | @cli.command(help="View a Group, Request, or Request Parameter.") 132 | @click.argument("group") 133 | @click.argument("request", required=False) 134 | @click.argument("param", required=False) 135 | @click.option( 136 | "-r/-R", 137 | "--render/--no-render", 138 | default=False, 139 | help="Render with Environment variables.", 140 | ) 141 | @pass_app 142 | def view(app, group, request, param, render): 143 | with expect(NotFoundError): 144 | output = app.view(group, request, param, render) 145 | click.echo(output) 146 | 147 | 148 | @cli.command( 149 | help="View or set Environment variables." 150 | " If no args are given, print the current environment." 151 | " Otherwise, change the Environment via the given args." 152 | ) 153 | @click.argument("args", nargs=-1) 154 | @pass_app 155 | def env(app, args): 156 | if args: 157 | with expect(InputError): 158 | output = app.set_env(*args) 159 | else: 160 | output = app.show_env() 161 | click.echo(output) 162 | 163 | 164 | @cli.command(help="Start an interactive prompt.") 165 | @click.pass_context 166 | # pylint: disable=unused-variable 167 | def repl(ctx): 168 | # Define REPL-only commands here. 169 | # -------------------------------------------- 170 | 171 | @cli.command(help="Reload Collection and Environment from disk.") 172 | @pass_app 173 | def reload(app): 174 | output = "" 175 | output += app.load_collection() 176 | output += app.load_env() 177 | click.echo(output) 178 | 179 | @cli.command(help="Save the current Environment to disk.") 180 | @pass_app 181 | def save(app): 182 | output = app.save_env() 183 | click.echo(output) 184 | 185 | @cli.command(help="Change to and load a new Collection file.") 186 | @click.argument("path", type=click.Path(exists=True, dir_okay=False)) 187 | @pass_app 188 | def change_collection(app, path): 189 | output = app.load_collection(path) 190 | click.echo(output) 191 | 192 | @cli.command(help="Change to and load a new Environment file.") 193 | @click.argument("path", type=click.Path(exists=True, dir_okay=False)) 194 | @pass_app 195 | def change_env(app, path): 196 | output = app.load_env(path) 197 | click.echo(output) 198 | 199 | # Start REPL. 200 | # -------------------------------------------- 201 | 202 | start_repl(ctx) 203 | -------------------------------------------------------------------------------- /restcli/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/augustawind/restcli/ca45cee01aca406b1219a64d03a77eda5c2516e3/restcli/contrib/__init__.py -------------------------------------------------------------------------------- /restcli/contrib/scripts.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=possibly-unused-variable,unused-argument 2 | def define(response, env, *args, **kwargs): 3 | 4 | # pylint: disable=import-outside-toplevel 5 | from pprint import pformat 6 | 7 | class UnexpectedResponse(Exception): 8 | """An error raised when the response is not as expected. 9 | 10 | TODO: make this a first-class citizen available to all scripts. 11 | """ 12 | 13 | def __init__(self, response, msg="unexpected response"): 14 | super().__init__(response, msg) 15 | self.response = response 16 | self.msg = msg 17 | 18 | def __str__(self): 19 | return "{}: {}".format(self.msg, pformat(self.response, indent=2)) 20 | 21 | def assert_status(expected_status, error_msg=None): 22 | """Raise an error if response status code is not `expected_status`.""" 23 | if response.status_code != expected_status: 24 | raise UnexpectedResponse( 25 | response, 26 | msg=error_msg or f"expected status code '{expected_status}'", 27 | ) 28 | 29 | def set_env(status, var, path): 30 | """Shortcut for checking the status code and setting an env var.""" 31 | if response.status_code == status: 32 | value = response.json() 33 | 34 | try: 35 | for key in path: 36 | value = value[key] 37 | except (IndexError, KeyError): 38 | return 39 | env[var] = value 40 | 41 | return locals() 42 | -------------------------------------------------------------------------------- /restcli/exceptions.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | import click 4 | 5 | __all__ = [ 6 | "expect", 7 | "Error", 8 | "InputError", 9 | "ReqModError", 10 | "ReqModSyntaxError", 11 | "ReqModValueError", 12 | "ReqModKeyError", 13 | "FileContentError", 14 | "NotFoundError", 15 | "GroupNotFoundError", 16 | "RequestNotFoundError", 17 | "ParameterNotFoundError", 18 | "CollectionError", 19 | "EnvError", 20 | "LibError", 21 | ] 22 | 23 | 24 | @contextmanager 25 | def expect(*exceptions): 26 | try: 27 | yield 28 | except exceptions as exc: 29 | raise click.ClickException(exc.show()) 30 | 31 | 32 | class Error(Exception): 33 | """Base library exception.""" 34 | 35 | base_msg = "" 36 | 37 | def __init__(self, msg="", action=None): 38 | super().__init__(msg, action) 39 | # TODO: determine if `action` is still needed (I think no) 40 | self.action = action 41 | self.msg = msg 42 | 43 | def show(self): 44 | msg = self._fmt_label(self.base_msg, self.msg).format(**vars(self)) 45 | if self.action: 46 | return self._fmt_label(self.action, msg) 47 | return msg 48 | 49 | @staticmethod 50 | def _fmt_label(first, second): 51 | return "{}{}".format(first, f": {second}" if second else "") 52 | 53 | @staticmethod 54 | def _is_custom_attr(attr): 55 | """Return whether an attr is a custom member of an Error instance.""" 56 | return ( 57 | not callable(attr) 58 | and attr != "args" 59 | and not (isinstance(attr, str) and attr.startswith("__")) 60 | ) 61 | 62 | 63 | class InputError(Error): 64 | """Exception for invalid user input.""" 65 | 66 | base_msg = "Invalid input '{value}'" 67 | 68 | def __init__(self, value, msg="", action=None): 69 | super().__init__(msg, action) 70 | self.value = value 71 | 72 | 73 | class ReqModError(InputError): 74 | """Invalid Mod input.""" 75 | 76 | base_msg = "Invalid Request Modifier: '{value}'" 77 | 78 | 79 | class ReqModSyntaxError(ReqModError): 80 | """Badly structured Mod input.""" 81 | 82 | base_msg = "Syntax error in Request Modifier: '{value}'" 83 | 84 | 85 | class ReqModValueError(ReqModError): 86 | """Badly formed Mod key or value.""" 87 | 88 | 89 | class ReqModKeyError(ReqModError): 90 | """Mod key does not exist.""" 91 | 92 | base_msg = "Key does not exist: '{value}'" 93 | 94 | 95 | class FileContentError(Error): 96 | """Exception for invalid file data.""" 97 | 98 | base_msg = "Invalid content" 99 | file_type = "CONTENT" 100 | 101 | def __init__(self, file, msg="", path=None, action=None): 102 | super().__init__(msg, action) 103 | self.file = file 104 | self.path = path 105 | 106 | def show(self): 107 | line = self.file 108 | if self.path: 109 | line = f"{line} => {self._fmt_path(self.path)}" 110 | return f"{line}\n{super().show()}" 111 | 112 | @property 113 | def name(self): 114 | return self.path[-1] 115 | 116 | def _fmt_path(self, path): 117 | text = "" 118 | for item in path: 119 | if isinstance(item, str): 120 | text += f".{item}" 121 | else: 122 | text += f"[{item}]" 123 | return f"{self.file_type}{text}" 124 | 125 | 126 | class NotFoundError(FileContentError): 127 | """Exception for invalid lookups.""" 128 | 129 | base_msg = "Not found" 130 | 131 | 132 | class CollectionError(FileContentError): 133 | """Exception for invalid Collection files.""" 134 | 135 | base_msg = "Invalid collection" 136 | file_type = "COLLECTION" 137 | 138 | 139 | class GroupNotFoundError(CollectionError): 140 | 141 | base_msg = "Group not found: '{name}'" 142 | 143 | 144 | class RequestNotFoundError(CollectionError): 145 | 146 | base_msg = "Request not found: '{name}" 147 | 148 | 149 | class ParameterNotFoundError(CollectionError): 150 | 151 | base_msg = "Parameter not found: '{name}'" 152 | 153 | 154 | class EnvError(FileContentError): 155 | """Exception for invalid Env files.""" 156 | 157 | base_msg = "Invalid env" 158 | file_type = "ENV" 159 | 160 | 161 | class LibError(FileContentError): 162 | """Exception for invalid Libs files.""" 163 | 164 | base_msg = "Invalid lib(s)" 165 | file_type = "LIB" 166 | -------------------------------------------------------------------------------- /restcli/params.py: -------------------------------------------------------------------------------- 1 | from restcli.utils import AttrMap 2 | 3 | REQUIRED_REQUEST_PARAMS = AttrMap( 4 | ("method", str), 5 | ("url", str), 6 | ) 7 | REQUEST_PARAMS = AttrMap( 8 | ("query", str), 9 | ("headers", dict), 10 | ("body", str), 11 | ("script", str), 12 | *REQUIRED_REQUEST_PARAMS.items(), 13 | ) 14 | CONFIG_PARAMS = AttrMap( 15 | ("defaults", dict), 16 | ("lib", list), 17 | ) 18 | -------------------------------------------------------------------------------- /restcli/postman.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import re 4 | import sys 5 | import warnings 6 | from collections import OrderedDict 7 | 8 | from restcli import yaml_utils as yaml 9 | 10 | 11 | def parse_collection(postman_collection): 12 | collection = OrderedDict() 13 | for folder_info in postman_collection["item"]: 14 | group_name = normalize(folder_info["name"]) 15 | if group_name in collection: 16 | warnings.warn('duplicate group name "%s"; skipping' % group_name) 17 | collection[group_name] = parse_group(folder_info["item"]) 18 | 19 | return collection 20 | 21 | 22 | def parse_group(folder_info): 23 | group = OrderedDict() 24 | for request_info in folder_info: 25 | request_name = normalize(request_info["name"]) 26 | if request_name in group: 27 | warnings.warn( 28 | 'duplicate request name "%s"; skipping' % request_name 29 | ) 30 | if "item" in request_info: 31 | warnings.warn( 32 | "sub-folders in collections are not supported; " 33 | 'skipping folder "%s"' % request_name 34 | ) 35 | continue 36 | group[request_name] = parse_request(request_info) 37 | 38 | return group 39 | 40 | 41 | def parse_request(request_info): 42 | request = OrderedDict() 43 | r = request_info["request"] 44 | 45 | description = r.get("description") 46 | if description: 47 | request["description"] = description 48 | 49 | request["method"] = r["method"].lower() 50 | request["url"] = r["url"] 51 | 52 | header_info = r["header"] 53 | headers = parse_headers(header_info) 54 | if headers: 55 | request["headers"] = headers 56 | 57 | body_info = r["body"] 58 | body = parse_body(body_info) 59 | if body and body.strip() != "{}": 60 | request["body"] = body 61 | 62 | return request 63 | 64 | 65 | def parse_headers(header_info): 66 | return OrderedDict((h["key"], h["value"]) for h in header_info) 67 | 68 | 69 | def parse_body(body_info): 70 | # FIXME: This interprets all literals as strings. Use the PM "type" field. 71 | mode = body_info["mode"] 72 | body = body_info[mode] 73 | 74 | if body: 75 | if mode == "formdata": 76 | python_repr = parse_formdata(body) 77 | elif mode == "raw": 78 | python_repr = json.loads(body) 79 | else: 80 | warnings.warn('unsupported body mode "%s"; skipping' % mode) 81 | return None 82 | 83 | text = yaml.dump(python_repr, indent=4) 84 | return yaml.YamlLiteralStr(text) 85 | 86 | return None 87 | 88 | 89 | def parse_formdata(formdata): 90 | data = OrderedDict() 91 | 92 | for item in formdata: 93 | if item["type"] != "text": 94 | warnings.warn( 95 | 'unsupported type in formdata "%s"; skipping' % item["type"] 96 | ) 97 | continue 98 | 99 | key = item["key"] 100 | val = item["value"] 101 | data[key] = val 102 | 103 | return data 104 | 105 | 106 | def normalize(text): 107 | text = text.lower() 108 | text = re.sub(r"\s+", "-", text) 109 | text = re.sub("[{}]", "", text) 110 | return text 111 | 112 | 113 | def main(): 114 | parser = argparse.ArgumentParser() 115 | parser.add_argument("collection", type=open) 116 | parser.add_argument( 117 | "-o", "--outfile", type=argparse.FileType("w"), default=sys.stdout 118 | ) 119 | args = parser.parse_args() 120 | 121 | postman_collection = json.load(args.collection) 122 | collection = parse_collection(postman_collection) 123 | 124 | output = yaml.dump(collection, indent=4) 125 | output = re.sub(r"^([^\s].*)$", "\n\\1", output, flags=re.MULTILINE) 126 | for var in re.findall(r"{{([^{}]+)}}", output): 127 | output = output.replace("{{%s}}" % var, "{{ %s }}" % var.lower()) 128 | output = f"---\n{output}" 129 | print(output, file=args.outfile) 130 | 131 | 132 | if __name__ == "__main__": 133 | main() 134 | -------------------------------------------------------------------------------- /restcli/reqmod/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/augustawind/restcli/ca45cee01aca406b1219a64d03a77eda5c2516e3/restcli/reqmod/__init__.py -------------------------------------------------------------------------------- /restcli/reqmod/lexer.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import string 3 | from collections import namedtuple 4 | 5 | import click 6 | 7 | from restcli.utils import AttrSeq 8 | 9 | QUOTES = "\"'" 10 | ESCAPES = "\\" 11 | 12 | ACTIONS = AttrSeq( 13 | "append", 14 | "assign", 15 | "delete", 16 | ) 17 | 18 | Lexeme = namedtuple("Lexeme", ["action", "value"]) 19 | 20 | 21 | class ClickArgumentParser(argparse.ArgumentParser): 22 | """Override `ArgumentParser#error()` to raise a `click.UsageError()`.""" 23 | 24 | def error(self, message): 25 | raise click.UsageError(message) 26 | 27 | 28 | lexer = ClickArgumentParser(prog="lexer", add_help=False) 29 | lexer.add_argument("-a", f"--{ACTIONS.append}", action="append") 30 | lexer.add_argument("-n", f"--{ACTIONS.assign}", action="append") 31 | lexer.add_argument("-d", f"--{ACTIONS.delete}", action="append") 32 | lexer.add_argument("args", nargs="*") 33 | 34 | 35 | def lex(argv): 36 | """Lex an argv style sequence into a list of Lexemes. 37 | 38 | Args: 39 | argv: An iterable of strings. 40 | 41 | Returns: 42 | [ Lexeme(action, value) , ... ] 43 | 44 | Examples: 45 | >>> lex(('-a', 'foo:bar', '-d', 'baz==', 'a=b', 'x:=true')) 46 | [ 47 | Lexeme(action='append', value='foo:bar'), 48 | Lexeme(action='delete', value='baz=='), 49 | Lexeme(action='assign', value='a=b'), 50 | Lexeme(action='assign', value='x:=true'), 51 | ] 52 | """ 53 | opts = vars(lexer.parse_args(argv)) 54 | args = opts.pop("args") 55 | # Lex flagged options 56 | lexemes = [ 57 | Lexeme(action, val) 58 | for action, values in opts.items() 59 | if values is not None 60 | for val in values 61 | ] 62 | # Lex short-form `ACTIONS.assign` args 63 | lexemes.extend(Lexeme(ACTIONS.assign, token) for token in args) 64 | return lexemes 65 | 66 | 67 | def tokenize(s, sep=string.whitespace): 68 | """Split a string on whitespace. 69 | 70 | Whitespace can be present in a token if it is preceded by a backslash or 71 | contained within non-escaped quotations. 72 | 73 | Quotations can be present in a token if they are preceded by a backslash or 74 | contained within non-escaped quotations of a different kind. 75 | 76 | Args: 77 | s (str) - The string to tokenize. 78 | sep (str) - Character(s) to use as separators. Defaults to whitespace. 79 | 80 | Returns: 81 | A list of tokens. 82 | 83 | Examples: 84 | >>> tokenize('"Hello world!" I\\ love \\\'Python programming!\\\'') 85 | ['Hello world!', 'I love', '\'Python', 'programming!\''] 86 | """ 87 | tokens = [] 88 | token = "" 89 | current_quote = None 90 | 91 | chars = iter(s) 92 | char = next(chars, "") 93 | 94 | while char: 95 | # Quotation marks begin or end a quoted section 96 | if char in QUOTES: 97 | if char == current_quote: 98 | current_quote = None 99 | elif not current_quote: 100 | current_quote = char 101 | 102 | # Backslash makes the following character literal 103 | elif char in ESCAPES: 104 | token += char 105 | char = next(chars, "") 106 | 107 | # Unless in quotes, whitespace is skipped and signifies the token end. 108 | elif not current_quote and char in sep: 109 | while char in sep: 110 | char = next(chars, "") 111 | tokens.append(token) 112 | token = "" 113 | 114 | # Since we stopped at the first non-whitespace character, it 115 | # must be processed. 116 | continue 117 | 118 | token += char 119 | char = next(chars, "") 120 | 121 | tokens.append(token) 122 | return tokens 123 | -------------------------------------------------------------------------------- /restcli/reqmod/mods.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import json 3 | import re 4 | import string 5 | from urllib.parse import quote_plus 6 | 7 | from restcli.exceptions import ReqModSyntaxError, ReqModValueError 8 | from restcli.utils import AttrMap, AttrSeq, classproperty, is_ascii 9 | 10 | PARAM_TYPES = AttrSeq( 11 | "json_field", 12 | "str_field", 13 | "header", 14 | "url_param", 15 | ) 16 | 17 | 18 | def parse_mod(mod_str): 19 | """Attempt to parse a str into a Mod.""" 20 | for mod_cls in MODS.values(): 21 | try: 22 | mod = mod_cls.match(mod_str) 23 | except ReqModSyntaxError: 24 | continue 25 | return mod 26 | raise ReqModSyntaxError(value=mod_str) 27 | 28 | 29 | class Mod(metaclass=abc.ABCMeta): 30 | 31 | param_type = NotImplemented 32 | param = NotImplemented 33 | delimiter = NotImplemented 34 | 35 | _types = None 36 | _pattern: re.Pattern = None 37 | 38 | split_re_tpl = string.Template(r"(?<=[^\\])${delimiter}") 39 | 40 | def __init__(self, key, value): 41 | self.key, self.value = self.clean_params(key, value) 42 | 43 | def __str__(self): 44 | attrs = ("key", "value") 45 | attr_kwargs = ( 46 | f"{attr}{self.delimiter}{getattr(self, attr)!r}" for attr in attrs 47 | ) 48 | return "{}({})".format(type(self).__name__, ", ".join(attr_kwargs)) 49 | 50 | @classmethod 51 | @abc.abstractmethod 52 | def clean_params(cls, key, value): 53 | """Validate and format the Mod's key and/or value.""" 54 | 55 | @classmethod 56 | def match(cls, mod_str): 57 | """Create a new Mod by matching syntax.""" 58 | # pylint: disable=no-member 59 | parts = cls.pattern.split(mod_str, maxsplit=1) 60 | if len(parts) != 2: 61 | # TODO: add info about proper syntax in error msg 62 | raise ReqModSyntaxError(value=mod_str) 63 | key, value = parts 64 | return cls(key=key, value=value) 65 | 66 | @classproperty 67 | # pylint: disable=no-self-argument 68 | def pattern(cls) -> re.Pattern: 69 | if not cls._pattern: 70 | re_str = cls.split_re_tpl.substitute( 71 | delimiter=re.escape(cls.delimiter) 72 | ) 73 | cls._pattern = re.compile(re_str) 74 | return cls._pattern 75 | 76 | 77 | class JsonFieldMod(Mod): 78 | 79 | param_type = PARAM_TYPES.json_field 80 | param = "body" 81 | delimiter = ":=" 82 | 83 | @classmethod 84 | def clean_params(cls, key, value): 85 | try: 86 | json_value = json.loads(value) 87 | except json.JSONDecodeError as err: 88 | # TODO: implement error handling 89 | raise ReqModValueError(value=value, msg=f"invalid JSON - {err}") 90 | return key, json_value 91 | 92 | 93 | class StrFieldMod(Mod): 94 | 95 | param_type = PARAM_TYPES.str_field 96 | param = "body" 97 | delimiter = "=" 98 | 99 | @classmethod 100 | def clean_params(cls, key, value): 101 | return key, value 102 | 103 | 104 | class HeaderMod(Mod): 105 | 106 | param_type = PARAM_TYPES.header 107 | param = "headers" 108 | delimiter = ":" 109 | 110 | @classmethod 111 | def clean_params(cls, key, value): 112 | # TODO: legit error messages 113 | msg = "non-ASCII character(s) found in header" 114 | if not is_ascii(str(key)): 115 | raise ReqModValueError(value=key, msg=msg) 116 | if not is_ascii(str(value)): 117 | raise ReqModValueError(value=value, msg=msg) 118 | return key, value 119 | 120 | 121 | class UrlParamMod(Mod): 122 | 123 | param_type = PARAM_TYPES.url_param 124 | param = "query" 125 | delimiter = "==" 126 | 127 | @classmethod 128 | def clean_params(cls, key, value): 129 | return quote_plus(key), quote_plus(value) 130 | 131 | 132 | # Tuple of Mod classes, in order of specificity of delimiters 133 | MODS = AttrMap( 134 | *( 135 | (mod_cls.delimiter, mod_cls) 136 | for mod_cls in ( 137 | JsonFieldMod, 138 | UrlParamMod, 139 | HeaderMod, 140 | StrFieldMod, 141 | ) 142 | ) 143 | ) 144 | -------------------------------------------------------------------------------- /restcli/reqmod/parser.py: -------------------------------------------------------------------------------- 1 | from restcli.reqmod.mods import parse_mod 2 | from restcli.reqmod.updater import UPDATERS, Updates 3 | 4 | 5 | def parse(lexemes): 6 | """Parse a sequence of Lexemes. 7 | 8 | Args: 9 | lexemes: An iterable of Lexeme objects. 10 | 11 | Returns: 12 | An Updates object that can be used to update Requests. 13 | """ 14 | updates = Updates() 15 | 16 | for lexeme in lexemes: 17 | # Parse Mod 18 | mod = parse_mod(lexeme.value) 19 | 20 | # Create Updater 21 | updater_cls = UPDATERS[lexeme.action] 22 | updater = updater_cls(mod.param, mod.key, mod.value) 23 | updates.append(updater) 24 | 25 | return updates 26 | 27 | 28 | examples = [ 29 | # Set a header (:) 30 | """Authorization:'JWT abc123\'""", 31 | # Delete a header (-d) 32 | """-d Authorization:""", 33 | # Set a JSON param (string only) (=) 34 | '''description="A test Device."''', 35 | # Append (-a) to a url parameter (==) 36 | """-a _annotate==,counts""", 37 | # Set a nested (.) JSON field (non-string) (:=) 38 | """.location.postal_code:=33705""", 39 | # Set a nested (. / []) JSON field (string) (=) 40 | """.conditions[0].variable=ambient_light""", 41 | # Delete (-d) a nested (.) JSON field 42 | """-d .location.addr2""", 43 | ] 44 | -------------------------------------------------------------------------------- /restcli/reqmod/updater.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from dataclasses import dataclass 3 | from typing import Dict, List, Union 4 | 5 | from restcli.exceptions import ReqModKeyError, ReqModValueError 6 | from restcli.utils import AttrMap 7 | 8 | JsonValue = Union[ 9 | str, bool, int, float, List["JsonValue"], Dict[str, "JsonValue"] 10 | ] 11 | 12 | 13 | class Updates(list): 14 | """Simple list wrapper that provides update utilities.""" 15 | 16 | def apply(self, request): 17 | """Apply all updates to a Request object. 18 | 19 | Args: 20 | request (object): The Request object to update. 21 | """ 22 | for updater in self: 23 | updater(request) 24 | 25 | 26 | @dataclass 27 | class BaseUpdater(metaclass=abc.ABCMeta): 28 | """Base class for callable objects that update Request Parameters. 29 | 30 | Args: 31 | request_param (str): The name of the Request Parameter to update. 32 | key (str): The key that will be updated within the Request Parameter. 33 | value: The new value. 34 | 35 | Notes: 36 | Child classes must implement the ``update_request`` method. 37 | """ 38 | 39 | request_param: str 40 | key: str 41 | value: JsonValue 42 | 43 | def __call__(self, request): 44 | """Update a Request. 45 | 46 | This method dispatches to ``update_request`` to execute the update. 47 | 48 | Args: 49 | request (dict): The Request object. 50 | 51 | Returns: 52 | The updated value. 53 | """ 54 | current_request_param = request[self.request_param] 55 | try: 56 | self.update_request(current_request_param) 57 | except KeyError: 58 | raise ReqModKeyError(value=self.key) 59 | except (TypeError, ValueError): 60 | raise ReqModValueError(value=self.value) 61 | 62 | @abc.abstractmethod 63 | def update_request(self, param_value): 64 | """Update a Request Parameter. 65 | 66 | Args: 67 | param_value: The value of the Request Parameter to update. 68 | 69 | Notes: 70 | Child classes must implement this method. 71 | """ 72 | 73 | 74 | class AppendUpdater(BaseUpdater): 75 | """Appends a value to a Request Parameter field.""" 76 | 77 | def update_request(self, param_value): 78 | param_value[self.key] += self.value 79 | 80 | 81 | class AssignUpdater(BaseUpdater): 82 | """Sets a new value in a Request Parameter field.""" 83 | 84 | def update_request(self, param_value): 85 | param_value[self.key] = self.value 86 | 87 | 88 | class DeleteUpdater(BaseUpdater): 89 | """Deletes a field in a Request Parameter.""" 90 | 91 | def update_request(self, param_value): 92 | del param_value[self.key] 93 | 94 | 95 | UPDATERS = AttrMap( 96 | ("append", AppendUpdater), 97 | ("assign", AssignUpdater), 98 | ("delete", DeleteUpdater), 99 | ) 100 | -------------------------------------------------------------------------------- /restcli/requestor.py: -------------------------------------------------------------------------------- 1 | import re 2 | from contextlib import contextmanager 3 | 4 | import jinja2 5 | import requests 6 | 7 | from restcli import yaml_utils as yaml 8 | from restcli.exceptions import InputError 9 | from restcli.workspace import Collection, Environment 10 | 11 | __all__ = ["Requestor"] 12 | 13 | ENV_RE = re.compile(r"([^:]+):(.*)") 14 | 15 | 16 | class Requestor: 17 | """Parser and executor of requests.""" 18 | 19 | def __init__(self, collection_file, env_file=None): 20 | self.collection = Collection(collection_file) 21 | self.env = Environment(env_file) 22 | 23 | def request(self, group, name, updater=None, *env_args): 24 | """Execute the Request found at ``self.collection[group][name]``.""" 25 | request = self.collection[group][name] 26 | 27 | with self.override_env(env_args): 28 | request_kwargs = self.prepare_request(request, self.env, updater) 29 | 30 | response = requests.request(**request_kwargs) 31 | 32 | script = request.get("script") 33 | if script: 34 | script_locals = {"response": response, "env": self.env} 35 | if self.collection.libs: 36 | for lib in self.collection.libs.values(): 37 | script_locals.update(lib.define(response, self.env)) 38 | self.run_script(script, script_locals) 39 | 40 | return response 41 | 42 | @classmethod 43 | def prepare_request(cls, request, env, updater=None): 44 | """Prepare a Request to be executed.""" 45 | request = { 46 | k: request.get(k) 47 | for k in ("method", "url", "query", "headers", "body") 48 | } 49 | kwargs = cls.parse_request(request, env, updater) 50 | 51 | kwargs["json"] = kwargs.pop("body") 52 | kwargs["params"] = kwargs.pop("query") 53 | 54 | return kwargs 55 | 56 | @classmethod 57 | def parse_request(cls, request, env, updater=None): 58 | """Parse a Request object in the context of an Environment.""" 59 | kwargs = { 60 | **request, 61 | "method": request["method"], 62 | "url": cls.interpolate(request["url"], env), 63 | "query": {}, 64 | "headers": {}, 65 | "body": {}, 66 | } 67 | 68 | body = request.get("body") 69 | if body: 70 | kwargs["body"] = cls.interpolate(body, env) 71 | headers = request.get("headers") 72 | if headers: 73 | kwargs["headers"] = { 74 | k: cls.interpolate(v, env) for k, v in headers.items() 75 | } 76 | query = request.get("query") 77 | if query: 78 | kwargs["query"] = cls.interpolate(query, env) 79 | 80 | if updater: 81 | updater.apply(kwargs) 82 | 83 | return kwargs 84 | 85 | @contextmanager 86 | def override_env(self, env_args): 87 | """Temporarily modify an Environment with the given overrides. 88 | 89 | On exit, the Env is returned to its previous state. 90 | """ 91 | original = self.env.data 92 | self.mod_env(env_args) 93 | 94 | yield 95 | 96 | self.env.replace(original) 97 | 98 | def mod_env(self, env_args, save=False): 99 | """Modify an Environment with the given overrides.""" 100 | set_env, del_env = self.parse_env_args(*env_args) 101 | self.env.update(**set_env) 102 | self.env.remove(*del_env) 103 | 104 | if save: 105 | self.env.save() 106 | 107 | @staticmethod 108 | def parse_env_args(*env_args): 109 | """Parse some string args with Environment syntax.""" 110 | del_env = [] 111 | set_env = {} 112 | for arg in env_args: 113 | # Parse deletion syntax 114 | if arg.startswith("!"): 115 | var = arg[1:].strip() 116 | del_env.append(var) 117 | if var in set_env: 118 | del set_env[var] 119 | continue 120 | 121 | # Parse assignment syntax 122 | match = ENV_RE.match(arg) 123 | if not match: 124 | raise InputError( 125 | value=arg, 126 | msg="Error: args must take one of the forms `!KEY` or" 127 | " `KEY:VAL`, where `KEY` is a string and `VAL` is a" 128 | " valid YAML value.", 129 | action="env", 130 | ) 131 | key, val = match.groups() 132 | set_env[key.strip()] = yaml.load(val) 133 | return set_env, del_env 134 | 135 | @staticmethod 136 | def interpolate(data, env): 137 | """Given some ``data``, render it with the given ``env``.""" 138 | tpl = jinja2.Template(data) 139 | rendered = tpl.render(env) 140 | return yaml.load(rendered) 141 | 142 | @staticmethod 143 | def run_script(script, script_locals): 144 | """Run a Request script with a Response and Environment as context.""" 145 | code = compile(script, "<