├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── .tox-coveragerc ├── DESIGN.md ├── LICENSE ├── MANIFEST.in ├── PROJECT_LOG.md ├── README.md ├── docs ├── Makefile ├── command.rst ├── conf.py ├── faq.rst ├── index.rst ├── io.rst ├── make.bat ├── middleware.rst ├── testing.rst └── tutorial.rst ├── examples ├── cut_mp4.py ├── example.py ├── example_flagfile ├── giffr.py └── phototimeshifter │ ├── requirements.txt │ └── shifter.py ├── face ├── __init__.py ├── command.py ├── errors.py ├── helpers.py ├── middleware.py ├── parser.py ├── sinter.py ├── test │ ├── _search_cmd_a.flags │ ├── test_basic.py │ ├── test_calc_cmd.py │ ├── test_help.py │ ├── test_mw.py │ ├── test_search_cmd.py │ └── test_vcs_cmd.py ├── testing.py └── utils.py ├── requirements-docs.txt ├── requirements.in ├── requirements.txt ├── setup.py └── tox.ini /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | paths-ignore: 5 | - "docs/**" 6 | - "*.md" 7 | - "*.rst" 8 | pull_request: 9 | paths-ignore: 10 | - "docs/**" 11 | - "*.md" 12 | - "*.rst" 13 | jobs: 14 | tests: 15 | name: ${{ matrix.name }} 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | include: 21 | - { name: Linux, python: "3.13", os: ubuntu-latest, tox: py313 } 22 | - { name: Windows, python: "3.13", os: windows-latest, tox: py313 } 23 | - { name: Mac, python: "3.13", os: macos-latest, tox: py313 } 24 | - { name: "3.12", python: "3.12", os: ubuntu-latest, tox: py312 } 25 | - { name: "3.11", python: "3.11", os: ubuntu-latest, tox: py311 } 26 | - { name: "3.10", python: "3.10", os: ubuntu-latest, tox: py310 } 27 | - { name: "3.9", python: "3.9", os: ubuntu-latest, tox: py39 } 28 | - { name: "3.8", python: "3.8", os: ubuntu-latest, tox: py38 } 29 | - { name: "PyPy3", python: "pypy-3.9", os: ubuntu-latest, tox: pypy3 } 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python }} 35 | - name: update pip 36 | run: | 37 | pip install -U wheel 38 | pip install -U setuptools 39 | python -m pip install -U pip 40 | - name: get pip cache dir 41 | id: pip-cache 42 | shell: bash 43 | run: | 44 | echo "dir=$(pip cache dir)" >> "$GITHUB_OUTPUT" 45 | - name: cache pip 46 | uses: actions/cache@v4 47 | with: 48 | path: ${{ steps.pip-cache.outputs.dir }} 49 | key: pip|${{ runner.os }}|${{ matrix.python }}|${{ hashFiles('setup.py') }}|${{ hashFiles('requirements/*.txt') }} 50 | - run: pip install tox 51 | - run: tox -e ${{ matrix.tox }} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/_build 2 | tmp.py 3 | htmlcov/ 4 | 5 | *.py[cod] 6 | .pytest_cache 7 | venv 8 | 9 | # emacs 10 | *~ 11 | ._* 12 | .\#* 13 | \#*\# 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Packages 19 | *.egg 20 | *.egg-info 21 | dist 22 | build 23 | eggs 24 | parts 25 | bin 26 | var 27 | sdist 28 | develop-eggs 29 | .installed.cfg 30 | lib 31 | lib64 32 | 33 | # Installer logs 34 | pip-log.txt 35 | 36 | # Unit test / coverage reports 37 | .coverage 38 | .tox 39 | nosetests.xml 40 | 41 | # Translations 42 | *.mo 43 | 44 | # Mr Developer 45 | .mr.developer.cfg 46 | .project 47 | .pydevproject 48 | 49 | # Vim 50 | *.sw[op] 51 | 52 | .cache/ 53 | -------------------------------------------------------------------------------- /.tox-coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | face 5 | ../face 6 | omit = 7 | */flycheck_* 8 | 9 | 10 | [paths] 11 | source = 12 | ../face 13 | */lib/python*/site-packages/face 14 | */Lib/site-packages/face 15 | */pypy/site-packages/face 16 | -------------------------------------------------------------------------------- /DESIGN.md: -------------------------------------------------------------------------------- 1 | # Design 2 | 3 | Unlike URLs, there is no spec for parsing arguments. Much to my 4 | dismay, frankly. Living through argparse and docopt and click, it was 5 | like lash after lash against my poor CLI-loving flesh. 6 | 7 | * getopt basis? 8 | * Single- or no-argument flags only. No multi-argument flags 9 | * Configurable behavior as to multiple appearances of the same flag 10 | * error (default) 11 | * additive - value will be an int for no-arg flags (as with 12 | --verbose and -vvv), and added to a list for single-argument 13 | flags 14 | * Short flag support? 15 | * strong subcommand support (compositionally similar to clastic?) 16 | * store_true and store_false, or something better? 17 | * single argument flags support space-based and 'x=y' style arguments 18 | * transparent transformation between underscore and dash-based 19 | arguments (--enable-action and --enable_action are the same) 20 | * single variadic argument list 21 | * only valid for leaf subcommands 22 | * support partial parsing (a la parse_known_args) 23 | * support taking multiple flagfiles 24 | * always the same argument name, like a builtin, then warn on conflict 25 | * could be an argument type 26 | * might need to take a flagfilereader argument for testin purposes 27 | * No multi-level flag support. Flags push down under subcommands, 28 | conflicts raise errors early (at setup time). Flags can be masked 29 | out against being pushed under further subcommands (i.e., make the 30 | flag subcommand-local). 31 | 32 | Big challenge: helpful error messages 33 | 34 | /* TODO --key=val style arguments? */ 35 | 36 | ## Functionality 37 | 38 | By design, face supports command structure composed of five parts: 39 | 40 | 1. The command 41 | 2. The subcommand path 42 | 3. Flags 43 | 4. Positional arguments 44 | 5. Passthrough arguments 45 | 46 | cmd subcmd subsubcmd subsubsubcmd --flags -v --flag-with-arg arg posarg1 posarg2 -- passarg1 passarg2 47 | --- ----------------------------- ------------------------------ ---------------------- ----------------- 48 | command | subcommand path | flags | positional arguments | passthrough arguments 49 | 50 | Note that only leaf subcommands support positional arguments. 51 | 52 | "command" is just a display name, as no parsing is required for sys.argv[0] 53 | 54 | Collapsing abbreviated flags is not supported 55 | 56 | # Help Design 57 | 58 | If there are subcommands, do zfs-style subcommand syntax, only showing 59 | required flags and pos args (by display name). 60 | 61 | If at a leaf command, print full options listing for that path, with 62 | argparse-style help. 63 | 64 | If at a non-leaf command, argparse-style options help at that level 65 | (and above) can go below the zfs-style subcommand summary. 66 | 67 | Square [brackets] for optional. Angle for required. 68 | 69 | Boltons dependency can do basic pluralization/singularization for help 70 | displays. 71 | 72 | If a command has subcommands, then the automatic help should manifest 73 | as a subcommand. Otherwise, it should be a longform flag like 74 | --help. In a subcommand setting, the short "usage" message that pops 75 | up when an invalid command is issued should recommend typing "cmd 76 | help" for more options. 77 | 78 | ## OG Design Log 79 | 80 | ### Problems with argparse 81 | 82 | argparse is a pretty solid library, and despite many competitors over 83 | the years, the best argument parsing library available. Until now, of 84 | course. Here's an inventory of problems argparse did not solve, and in 85 | many ways, created. 86 | 87 | * "Fuzzy" flag matching 88 | * Inconvenient subcommand interface 89 | * Flags at each level of the subcommand tree 90 | * Positional arguments acceptable everywhere 91 | * Bad help rendering (esp for subcommands) 92 | * Inheritance-based API for extension with a lot of _* 93 | 94 | At the end of the day, the real sin of argparse is that it enables the 95 | creation of bad CLIs, often at the expense of ease of making good UIs 96 | Despite this friction, argparse is far from infinitely powerful. As a 97 | library, it is still relatively opinionated, and can only model a 98 | somewhat-conventional UNIX-y CLI. 99 | 100 | ### Should face be more than a parser? 101 | 102 | clastic calls your function for you, should this do that, too? is 103 | there an advantage to sticking to the argparse route of handing back 104 | a namespace? what would the signature of a CLI route be? 105 | 106 | * Specifying the CLI 107 | * Wiring up the routing/dispatch 108 | * OR Using the programmatic result of the parse (the Result object) 109 | * Formatting the help messages? 110 | * Using the actual CLI 111 | 112 | ### "Types" discussion 113 | 114 | * Should we support arbitrary validators (schema?) or go the clastic route and only basic types: 115 | * str 116 | * int 117 | * float 118 | * bool (TODO: default to true/false, think store_true, store_false in argparse) 119 | * list of the above 120 | * (leaning toward just basic types) 121 | 122 | 123 | ### Some subcommand ideas 124 | 125 | - autosuggest on incorrect subcommand 126 | - allow subcommand grouping 127 | - hyphens and underscores equivalent for flags and subcommands 128 | 129 | A command cannot have positional arguments _and_ subcommands. 130 | 131 | Need to be able to set display name for posargs 132 | 133 | Which is the best default behavior for a flag? single flag where 134 | presence=True (e.g., --verbose) or flag which accepts single string 135 | arg (e.g., --path /a/b/c) 136 | 137 | What to do if there appears to be flags after positional arguments? 138 | How to differentiate between a bad flag and a positional argument that 139 | starts with a dash? 140 | 141 | ### Design tag and philosophy 142 | 143 | "Face: the CLI framework that's friendly to your end-user." 144 | 145 | * Flag-first design that ensures flags stay consistent across all 146 | subcommands, for a more coherent API, less likely to surprise, more 147 | likely to delight. 148 | 149 | (Note: need to do some research re: non-unicode flags to see how much 150 | non-US CLI users care about em.) 151 | 152 | Case-sensitive flags are bad for business *except for* 153 | single-character flags (single-dash flags like -v vs -V). 154 | 155 | TODO: normalizing subcommands 156 | 157 | Should parse_as=List() with multi=extend give one long list or 158 | a list of lists? 159 | 160 | Parser is unable to determine which subcommands are valid leaf 161 | commands, i.e., which ones can be handled as the last subcommand. The 162 | Command dispatcher will have to raise an error if a specific 163 | intermediary subcommand doesn't have a handler to dispatch to. 164 | 165 | TODO: Duplicate arguments passed at the command line with the same value = ok? 166 | 167 | ### Strata integration 168 | 169 | * will need to disable and handle flagfiles separately if provenance 170 | is going to be retained? 171 | 172 | 173 | ### Should face have middleware or other clastic features? 174 | 175 | * middleware seems unavoidable for setting up logs and generic 176 | teardowns/exit messages 177 | * Might need an error map that maps various errors to exit codes for 178 | convenience. Neat idea, sort a list of classes by class hierarchy. 179 | 180 | ### Re: parse error corner cases 181 | 182 | There are certain parse errors, such as the omission of a value 183 | that takes a string argument which can semi-silently pass. For instance: 184 | 185 | copy --dest --verbose /a/b/c 186 | 187 | In this terrible CLI, --verbose could be absorbed as --dest's value 188 | and now there's a file called --verbose on the filesystem. Here are a 189 | few ideas to improve the situation: 190 | 191 | 1. Raise an exception for all flags' string arguments which start with 192 | a "-". Create a separate syntax for passing these args such as 193 | --flag=--dashedarg. 194 | 2. Similar to the above, but only raise exceptions on known 195 | flags. This creates a bit of a moving API, as a new flag could cause 196 | old values to fail. 197 | 3. Let particularly bad APIs like the above fail, but keep closer 198 | track of state to help identify missing arguments earlier in the line. 199 | 200 | ### A notable difference with Clastic 201 | 202 | One big difference between Clastic and Face is that with Face, you 203 | typically know your first and only request at startup time. With 204 | Clastic, you create an Application and have to wait for some remote 205 | user to issue a request. 206 | 207 | This translates to a different default behavior. With Clastic, all 208 | routes are checked for dependency satisfaction at Application 209 | creation. With Face, this check is performed on-demand, and only the 210 | single subcommand being executed is checked. 211 | 212 | ### Ideas for flag types: 213 | 214 | * iso8601 date/time/datetime 215 | * duration 216 | 217 | ### Middleware thoughts 218 | 219 | * Clastic-like, but single function 220 | * Mark with a @middleware(provides=()) decorator for provides 221 | 222 | * Keywords (ParseResult members) end with _ (e.g., flags_), leaving 223 | injection namespace wide open for flags. With clastic, argument 224 | names are primarily internal, like a path parameter's name is not 225 | exposed to the user. With face, the flag names are part of the 226 | exposed API, and we don't want to reserve keywords or have 227 | excessively long prefixes. 228 | 229 | * add() supports @middleware decorated middleware 230 | 231 | * add_middleware() exists for non-decorated middleware functions, and 232 | just conveniently calls middleware decorator for you (decorator only 233 | necessary for provides) 234 | 235 | Also Kurt says an easy way to access the subcommands to tweak them 236 | would be useful. I think it's better to build up from the leaves than 237 | to allow mutability that could trigger rechecks and failures across 238 | the whole subcommand tree. Better instead to make copies of 239 | subparsers/subcommands/flags and treat them as internal state. 240 | 241 | * Different error message for when the command's handler function is 242 | unfulfilled vs middlewares. 243 | * In addition to the existing function-as-first-arg interface, Command 244 | should take a list of add()-ables as the first argument. This allows 245 | easy composition from subcommands and common flags. 246 | * DisplayOptions/DisplaySpec class? (display name and hidden) 247 | * Should Commands have resources like clastic? 248 | 249 | 250 | ### What goes in a bound command? 251 | 252 | * name 253 | * doc 254 | * handler func 255 | * list of middlewares 256 | * parser (currently contains the following) 257 | * flag map 258 | * PosArgSpecs for posargs, post_posargs 259 | * flagfile flag 260 | * help flag (or help subcommand) 261 | 262 | TODO: allow user to configure the message for CommandLineErrors 263 | TODO: should Command take resources? 264 | TODO: should version_ be a built-in/injectable? 265 | 266 | Need to split up the checks. Basic verification of middleware 267 | structure OK. Can check for redefinitions of provides and 268 | conflicts. Need a final .check() method that checks that all 269 | subcommands have their requirements fulfilled. Technically a .run() 270 | only needs to run one specific subcommand, only thta one needs to get 271 | its middleware chain built. .check() would have to build/check them 272 | all. 273 | 274 | * Command inherit from Parser 275 | * Enable middleware flags 276 | * Ensure top-level middleware flags like --verbose show up for subcommands 277 | * Ensure "builtin" flags like --flagfile and --help show up for all commands 278 | * Make help flag come from HelpHandler 279 | * What to do when the top-level command doesn't have a help_handler, 280 | but a subcommand does? Maybe dispatch to the subcommand's help 281 | handler? Would deferring adding the HelpHandler's flag/subcmd help? 282 | Right now the help flag is parsed and ignored. 283 | 284 | 285 | ### Notes on making Command inherit from Parser 286 | 287 | The only fuzzy area is when to use prs.get_flag_map() vs 288 | prs._path_flag_map directly. Basically, when filtration-by-usage is 289 | desired, get_flag_map() (or get_flags()) should be used. Only Commands 290 | do this, so it looks a bit weird if you're only looking at the Parser, 291 | where this operation appears to do nothing. This only happens in 1-2 292 | places so probably safe to just comment it for now. 293 | 294 | Relatedly, there are some linting errors where it appears the private 295 | _path_flag_map is being accessed. I think these are ok, because these 296 | methods are operating on objects of the same type, so the members are 297 | still technically "protected", in the C++ OOP sense. 298 | 299 | ### A question about weakdeps 300 | 301 | Should weak deps on builtins_ be treated differently than weak 302 | deps on flags? Should weak deps in handler functions be treated 303 | differently than that in the middleware (middleware implies more 304 | "passthrough")? 305 | 306 | 307 | ## zfs-style help 308 | 309 | zpool help is probably handwritten (as evidenced by multiple instances 310 | of subcommands like "import" and spacing between groups like 311 | add/remove), but we can probably get pretty close to this. 312 | 313 | ``` 314 | $ zpool --help 315 | usage: zpool command args ... 316 | where 'command' is one of the following: 317 | 318 | create [-fnd] [-o property=value] ... 319 | [-O file-system-property=value] ... 320 | [-m mountpoint] [-R root] ... 321 | destroy [-f] 322 | 323 | add [-fgLnP] [-o property=value] ... 324 | remove ... 325 | 326 | labelclear [-f] 327 | 328 | list [-gHLPv] [-o property[,...]] [-T d|u] [pool] ... [interval [count]] 329 | iostat [-gLPvy] [-T d|u] [pool] ... [interval [count]] 330 | status [-gLPvxD] [-T d|u] [pool] ... [interval [count]] 331 | 332 | online ... 333 | offline [-t] ... 334 | clear [-nF] [device] 335 | reopen 336 | 337 | attach [-f] [-o property=value] 338 | detach 339 | replace [-f] [-o property=value] [new-device] 340 | split [-gLnP] [-R altroot] [-o mntopts] 341 | [-o property=value] [ ...] 342 | 343 | scrub [-s] ... 344 | 345 | import [-d dir] [-D] 346 | import [-d dir | -c cachefile] [-F [-n]] 347 | import [-o mntopts] [-o property=value] ... 348 | [-d dir | -c cachefile] [-D] [-f] [-m] [-N] [-R root] [-F [-n]] -a 349 | import [-o mntopts] [-o property=value] ... 350 | [-d dir | -c cachefile] [-D] [-f] [-m] [-N] [-R root] [-F [-n]] 351 | [newpool] 352 | export [-af] ... 353 | upgrade 354 | upgrade -v 355 | upgrade [-V version] <-a | pool ...> 356 | reguid 357 | 358 | history [-il] [] ... 359 | events [-vHfc] 360 | 361 | get [-pH] <"all" | property[,...]> ... 362 | set 363 | ``` 364 | 365 | ## youtube-dl help 366 | 367 | argparse-based, lots of options 368 | 369 | ``` 370 | Usage: youtube-dl [OPTIONS] URL [URL...] 371 | 372 | Options: 373 | General Options: 374 | -h, --help Print this help text and exit 375 | --version Print program version and exit 376 | -U, --update Update this program to latest version. Make sure that you have sufficient permissions (run with 377 | sudo if needed) 378 | -i, --ignore-errors Continue on download errors, for example to skip unavailable videos in a playlist 379 | --abort-on-error Abort downloading of further videos (in the playlist or the command line) if an error occurs 380 | --dump-user-agent Display the current browser identification 381 | --list-extractors List all supported extractors 382 | --extractor-descriptions Output descriptions of all supported extractors 383 | --force-generic-extractor Force extraction to use the generic extractor 384 | --default-search PREFIX Use this prefix for unqualified URLs. For example "gvsearch2:" downloads two videos from google 385 | videos for youtube-dl "large apple". Use the value "auto" to let youtube-dl guess ("auto_warning" 386 | to emit a warning when guessing). "error" just throws an error. The default value "fixup_error" 387 | repairs broken URLs, but emits an error if this is not possible instead of searching. 388 | --ignore-config Do not read configuration files. When given in the global configuration file /etc/youtube-dl.conf: 389 | Do not read the user configuration in ~/.config/youtube-dl/config (%APPDATA%/youtube-dl/config.txt 390 | on Windows) 391 | --config-location PATH Location of the configuration file; either the path to the config or its containing directory. 392 | --flat-playlist Do not extract the videos of a playlist, only list them. 393 | --mark-watched Mark videos watched (YouTube only) 394 | --no-mark-watched Do not mark videos watched (YouTube only) 395 | --no-color Do not emit color codes in output 396 | 397 | Network Options: 398 | --proxy URL Use the specified HTTP/HTTPS/SOCKS proxy. To enable experimental SOCKS proxy, specify a proper 399 | scheme. For example socks5://127.0.0.1:1080/. Pass in an empty string (--proxy "") for direct 400 | connection 401 | --socket-timeout SECONDS Time to wait before giving up, in seconds 402 | --source-address IP Client-side IP address to bind to 403 | -4, --force-ipv4 Make all connections via IPv4 404 | -6, --force-ipv6 Make all connections via IPv6 405 | 406 | Geo Restriction: 407 | --geo-verification-proxy URL Use this proxy to verify the IP address for some geo-restricted sites. The default proxy specified 408 | by --proxy (or none, if the options is not present) is used for the actual downloading. 409 | --geo-bypass Bypass geographic restriction via faking X-Forwarded-For HTTP header (experimental) 410 | --no-geo-bypass Do not bypass geographic restriction via faking X-Forwarded-For HTTP header (experimental) 411 | --geo-bypass-country CODE Force bypass geographic restriction with explicitly provided two-letter ISO 3166-2 country code 412 | (experimental) 413 | 414 | Video Selection: 415 | --playlist-start NUMBER Playlist video to start at (default is 1) 416 | --playlist-end NUMBER Playlist video to end at (default is last) 417 | --playlist-items ITEM_SPEC Playlist video items to download. Specify indices of the videos in the playlist separated by commas 418 | like: "--playlist-items 1,2,5,8" if you want to download videos indexed 1, 2, 5, 8 in the playlist. 419 | You can specify range: "--playlist-items 1-3,7,10-13", it will download the videos at index 1, 2, 420 | 3, 7, 10, 11, 12 and 13. 421 | --match-title REGEX Download only matching titles (regex or caseless sub-string) 422 | --reject-title REGEX Skip download for matching titles (regex or caseless sub-string) 423 | --max-downloads NUMBER Abort after downloading NUMBER files 424 | --min-filesize SIZE Do not download any videos smaller than SIZE (e.g. 50k or 44.6m) 425 | --max-filesize SIZE Do not download any videos larger than SIZE (e.g. 50k or 44.6m) 426 | --date DATE Download only videos uploaded in this date 427 | --datebefore DATE Download only videos uploaded on or before this date (i.e. inclusive) 428 | --dateafter DATE Download only videos uploaded on or after this date (i.e. inclusive) 429 | --min-views COUNT Do not download any videos with less than COUNT views 430 | --max-views COUNT Do not download any videos with more than COUNT views 431 | --match-filter FILTER Generic video filter. Specify any key (see the "OUTPUT TEMPLATE" for a list of available keys) to 432 | match if the key is present, !key to check if the key is not present, key > NUMBER (like 433 | "comment_count > 12", also works with >=, <, <=, !=, =) to compare against a number, key = 434 | 'LITERAL' (like "uploader = 'Mike Smith'", also works with !=) to match against a string literal 435 | and & to require multiple matches. Values which are not known are excluded unless you put a 436 | question mark (?) after the operator. For example, to only match videos that have been liked more 437 | than 100 times and disliked less than 50 times (or the dislike functionality is not available at 438 | the given service), but who also have a description, use --match-filter "like_count > 100 & 439 | dislike_count .+?) - 590 | (?P.+)" 591 | --xattrs Write metadata to the video file's xattrs (using dublin core and xdg standards) 592 | --fixup POLICY Automatically correct known faults of the file. One of never (do nothing), warn (only emit a 593 | warning), detect_or_warn (the default; fix file if we can, warn otherwise) 594 | --prefer-avconv Prefer avconv over ffmpeg for running the postprocessors (default) 595 | --prefer-ffmpeg Prefer ffmpeg over avconv for running the postprocessors 596 | --ffmpeg-location PATH Location of the ffmpeg/avconv binary; either the path to the binary or its containing directory. 597 | --exec CMD Execute a command on the file after downloading, similar to find's -exec syntax. Example: --exec 598 | 'adb push {} /sdcard/Music/ && rm {}' 599 | --convert-subs FORMAT Convert the subtitles to other format (currently supported: srt|ass|vtt|lrc) 600 | ``` 601 | 602 | 603 | ## PocketProtector v0.1 help 604 | 605 | modified argparse 606 | 607 | ``` 608 | $ pprotect --help 609 | usage: pprotect [COMMANDS] 610 | 611 | Commands: 612 | add-domain add a new domain to the protected 613 | add-key-custodian add a new key custodian to the protected 614 | add-owner add a key custodian as owner of a domain 615 | add-secret add a secret to a specified domain 616 | decrypt-domain decrypt and display JSON-formatted cleartext for a 617 | domain 618 | init create a new pocket-protected file 619 | list-all-secrets display all secrets, with a list of domains the key is 620 | present in 621 | list-audit-log display a chronological list of audit log entries 622 | representing file activity 623 | list-domain-secrets display a list of secrets under a specific domain 624 | list-domains display a list of available domains 625 | list-user-secrets similar to list-all-secrets, but filtered by a given 626 | user 627 | rm-domain remove a domain from the protected 628 | rm-owner remove an owner's privileges on a specified domain 629 | rm-secret remove a secret from a specified domain 630 | rotate-domain-keys rotate the internal keys for a particular domain (must 631 | be owner) 632 | set-key-custodian-passphrase 633 | change a key custodian passphrase 634 | update-secret update an existing secret in a specified domain 635 | 636 | Options: 637 | -h, --help show this help message and exit 638 | ``` 639 | 640 | # git error messages 641 | 642 | ``` 643 | $ git lol 644 | git: 'lol' is not a git command. See 'git --help'. 645 | 646 | Did you mean this? 647 | log 648 | ``` 649 | 650 | Pretty nice, draws attention to itself by being bigger, recommends 651 | help. The "did you mean" should include the command itself, i.e., "git 652 | log" instead of just "log" for easy copy and pastability. 653 | 654 | # API Design 655 | 656 | Face is designed to scale to a wide variety of command-line 657 | applications. As such, there are multiple levels of integration, each 658 | providing more control. 659 | 660 | 1. A single "autocommand" convenience function that automatically 661 | generates a command-line interface. 662 | 2. A more explicit object-oriented Command construction interface, 663 | with a polymorphic .add() method to add subcommands, flags, and 664 | middlewares. 665 | 3. Same as #2, but with explicit Command construction and direct usage 666 | of the explicit methods used to add subcommands and flags. 667 | 668 | All these options also come with the .run() method, which is used to 669 | dispatch to the developer's logic, much like how a web framework 670 | dispatches a client request to a endpoint function (sometimes called a 671 | "view" or "controller"). By default, the program automatically handles 672 | any --help / -h flags, prints the help output, and exits. 673 | 674 | For certain advanced use cases, there is an additional API option, the 675 | Parser itself. 676 | 677 | Face's Parser is configured almost identically to the Command, except 678 | that it does not take callables, and has no .run() method to dispatch 679 | to application code. Instead, integrators call .parse() to parse and 680 | validate flags and arguments, and handle flow control themselves. The 681 | Parser, much like the Command, has a default HelpHandler, which can 682 | render help, but only if explicitly called by the integrator. Parse 683 | errors can be caught like any other kind of Python exception. Again, 684 | the integrator has full control of program flow. 685 | 686 | ## Polymorphism 687 | 688 | Thanks to their prevalence in our workflow, we developers 689 | underestimate the variety and configurability of CLIs. As mentioned 690 | above, Face's APIs intentionally use polymorphism to better serve the 691 | evolving needs of a growing CLI. 692 | 693 | A common pattern for Face arguments: 694 | 695 | 1. Unset. Most arguments to Face APIs are optional. Everything has 696 | defaults designed to minimize surprise. 697 | 2. Boolean. Pass True to enable a behavior (or show an element), or 698 | False to disable it (or hide it). 699 | 3. Integer or string. Enable/show, and use this limit/label. 700 | 4. dict. Complex configurables are represented by objects. This 701 | dictionary is a mapping of keyword arguments that will be passed 702 | straight through to the configuration object constructor. Mostly 703 | used to minimize imports and memorization of class names. 704 | 5. A configuration object, manually imported and constructed by the 705 | user. Like most data objects, stateless and reusable. The most 706 | explicit option. 707 | 708 | For an example of this, look no further than the "posargs" argument to 709 | Parser/Command and the PosArgSpec configuration object that it expects 710 | or expects to be able to create. 711 | 712 | In my experience, the worst part about argparse and other UI libraries 713 | is constantly referencing the docs. When the API is too big, and there 714 | are too many methods and signatures to memorize, I find myself 715 | spending too much time in the docs (and often still not finding the 716 | feature I want/need). Face aims to be the library that changes that 717 | for CLIs. As few imports, methods, and arguments as is responsible. 718 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Mahmoud Hashemi 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | 15 | * The names of the contributors may not be used to endorse or 16 | promote products derived from this software without specific 17 | prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE CHANGELOG.md tox.ini requirements-test.txt .coveragerc Makefile pytest.ini .tox-coveragerc requirements.txt requirements-docs.txt 2 | global-include *.flags 3 | exclude TODO.md DESIGN.md PROJECT_LOG.md requirements.in 4 | 5 | recursive-include examples * 6 | graft docs 7 | prune docs/_build 8 | prune .tox 9 | -------------------------------------------------------------------------------- /PROJECT_LOG.md: -------------------------------------------------------------------------------- 1 | face Project Log 2 | ================ 3 | 4 | TODO 5 | ---- 6 | 7 | - file rotating CLI example 8 | 9 | 22.0.0 10 | ------ 11 | (2022-10-17) 12 | 13 | * Dropped Python 2 support 14 | * Added support for `--flag=val` 15 | * Improved `multi=extend` behavior 16 | * Many [docs](https://python-face.readthedocs.io/en/latest/) and tests 17 | 18 | 20.1.1 19 | ------ 20 | (2020-01-20) 21 | 22 | * Fix confirmation prompt formatting 23 | 24 | 20.1.0 25 | ------ 26 | (2020-01-20) 27 | 28 | * Add testing facilities (CommandChecker()) 29 | * Add echo() and prompt() 30 | 31 | 20.0.0 32 | ------ 33 | * Many bugfixes 34 | * New shortcuts for posarg count and injection 35 | 36 | 19.1.0 37 | ------ 38 | * posargspec aliases: 39 | * count (exact min and max count) 40 | * name (display name and provides name) 41 | 42 | Ideas 43 | ----- 44 | 45 | * Add an .add_version() method to Command 46 | * Automatically adds version subcommand if other subcommands are present 47 | * Automatically adds "-v" flag if one is not present 48 | * Check that subcommands may override parent flags 49 | * Should "signature" be enforced? 50 | * Utilities 51 | * confirm (default yes/no [Y/n], strict=True means type the whole word) 52 | * get_input (flag for password mode) 53 | * consistent behavior for ctrl-c/ctrl-d 54 | * ctrl-c cancels current input but does not exit 55 | * ctrl-d exits if it's the full message 56 | * banner (prints a banner) 57 | * debug/info/warn/critical 58 | * '..', '--', '!!', '**' 59 | * attach your own sinks for fun and profit 60 | * some hook for testing endpoints that use stdin/stdout 61 | * middleware + flag type that prompts the user for a value if it 62 | wasn't passed as a flag. 63 | * Built-in entrypoints-based plugin convention? 64 | * How to add subcommands mostly, but maybe middleware 65 | * Better error message around misordered middlewares 66 | * (check if mw_unres is in any of the mw provides) 67 | * What to do if flag and posargs provide the same thing? 68 | * If one is on parent and the other is on subcommand, pick the most 69 | local one? 70 | * If both are at the same level, raise an error as soon as one is set. 71 | * Allow middlewares to "override", "update", or "transform" 72 | injectables, aka provide an injectable which has already been 73 | provided. We don't want to invite conflicts, so they would need to 74 | explicitly accept that injectable as well as provide it. 75 | * Better document that if middleware arguments have a default value, 76 | they will not pull in flags. 77 | * Allow setting of "name" alias on PosArgSpec. Specifies an alternate 78 | name by which posargs_ (or post_posargs_) will be injected into the 79 | target function. 80 | * Work PosArgSpec name into ArgumentArityError messages 81 | * DisplayOptions: Some form of object acting as partial rendering 82 | context for instances of Command, Flag, PosArgSpec. 83 | * Add edit distance calculation for better error messages on unknown 84 | flags and invalid subcommands. 85 | * Hook for HelpHandler to add a line to error messages? (e.g., "For 86 | more information try --help" (or the help subcommand)) 87 | * Automatically add a message about the help subcommand or -h flag to 88 | parse errors, when help handler is present. 89 | 90 | ### Big Ticket Items 91 | 92 | * Calculator example 93 | * Docs 94 | * Autocompletion 95 | 96 | ### API 97 | 98 | * Handle conversion of argv to unicode on Py2? Or support bytes argv? 99 | * Better check for conflicting Flags (`__eq__` on Flag?) 100 | * Better exception for conflicting subcommands 101 | * Easier copying of Flags, Commands, etc. 102 | * CommandParseResult needs Source object 103 | * ListParam, FileValueParam 104 | * "display_name" to "display" (convenience: accept string display name 105 | which turns into DisplayOptions) (also affects help) 106 | * Possible injectables: target function, set of all dependencies of 107 | downstream function + middlewares (enables basic/shallow conditional 108 | middlewares) 109 | * Split out HelpFormatter to be a member of HelpHandler 110 | 111 | #### Parser / Command 112 | 113 | * Group (same as flags below) 114 | * Sort order (same as flags below) 115 | * Hidden? (doesn't make sense to customize label as with flags) 116 | * Doc (text between the usage line and the subcommands/flags) 117 | * Post-doc (text that comes after flags and subcommands) 118 | * What about multi-line usage strings? 119 | 120 | #### Flag 121 | 122 | * Group (default 0, unlabeled groups if integer, labeled group if string) 123 | * Sort order (default 0. first sorted by this number then 124 | alphabetical. sorting only happens within groups.) 125 | * Name (--canonical-name) (does it make sense to customize this?) 126 | * Label (--canonical-name / --alias / -C VALUE) (hide if empty/falsy, probably) 127 | * Value Label (name_of_the_flag.upper()) 128 | * "parse_as" label (see parser._get_type_desc) 129 | * pre_padding, post_padding 130 | * Should format_label live on FlagDisplay or in HelpHandler/HelpFormatter? 131 | 132 | Related: 133 | 134 | * Behavior on error (e.g., display usage) 135 | 136 | #### PosArgSpec 137 | 138 | * Description suffix (takes up to 2 integer args, takes 1-4 float args, etc.) 139 | * Usage line display (args ...) 140 | 141 | ### Small Questions 142 | 143 | * How bad of an idea is it to have HelpHandler take the remaining 144 | kwargs and pass them through to the helpformatter? kind of locks us 145 | into an API should we ever want to change the default help 146 | formatter. 147 | * Should we allow keywords to be flag/injectable names, but just 148 | automatically remap them internally such that a `--class` flag 149 | becomes available under `flags['class_']`. Might want to do this 150 | with builtin functions like sum, too? 151 | * Most useful default sort? Currently doing by insertion order, which 152 | works well for smaller command as it exposes quite a bit of control, 153 | but this will change for larger commands which may need to compose 154 | differently. Could keep an _id = next(itertools.count()) approach to 155 | record creation order. 156 | * Should we accept flags _anywhere_ in argv? Or just between 157 | subcommands and arguments? There is a case to be made for the 158 | occasional quick addition of a flag to the end of a command. 159 | * Mark as subinjectable? When lots of arguments might complicate a 160 | function API, create a config object that soaks them up and is 161 | itself injectable. (strata "lite") 162 | * Recommended practice for exiting with an error? 163 | * Need to also wrap command-level doc to width 164 | 165 | ### Common errors 166 | 167 | Errors a face-using developer might run into. 168 | 169 | * Flag nested under one command not available under another. Could do 170 | a quick look around and give a "did you mean" 171 | * posargs display expects name to be singular bc it's going to be 172 | pluralized. too smart? 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Face 2 | 3 | *Command-line parser and interface builder* 4 | 5 | <a href="https://python-face.readthedocs.io/en/latest/"><img src="https://img.shields.io/badge/docs-latest-brightgreen.svg?style=flat"></a> 6 | <a href="https://pypi.org/project/face/"><img src="https://img.shields.io/pypi/v/face.svg"></a> 7 | <a href="https://calver.org/"><img src="https://img.shields.io/badge/calver-YY.MINOR.MICRO-22bfda.svg"></a> 8 | 9 | Docs [here](https://python-face.readthedocs.io/). 10 | 11 | ## Users 12 | 13 | * Montage [administration tools](https://github.com/hatnote/montage/blob/master/tools/admin.py) 14 | * [Pocket Protector](https://github.com/SimpleLegal/pocket_protector/blob/master/pocket_protector/cli.py) 15 | * [glom CLI](https://github.com/mahmoud/glom/blob/master/glom/cli.py) 16 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 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 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/command.rst: -------------------------------------------------------------------------------- 1 | Command 2 | ======= 3 | 4 | .. autoclass:: face.Command 5 | :members: 6 | 7 | Command Exception Types 8 | ----------------------- 9 | 10 | In addition to all the Parser-layer exceptions, a command or user endpoint function can raise: 11 | 12 | .. autoclass:: face.CommandLineError 13 | 14 | .. autoclass:: face.UsageError 15 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | import sphinx 16 | from pprint import pprint 17 | 18 | CUR_PATH = os.path.dirname(os.path.abspath(__file__)) 19 | PROJECT_PATH = os.path.abspath(CUR_PATH + '/../') 20 | # PACKAGE_PATH = os.path.abspath(CUR_PATH + '/../face/') 21 | sys.path.insert(0, PROJECT_PATH) 22 | # sys.path.insert(0, PACKAGE_PATH) 23 | 24 | pprint(os.environ) 25 | 26 | 27 | # -- Project information ----------------------------------------------------- 28 | 29 | project = 'Face' 30 | copyright = '2024, Mahmoud Hashemi' 31 | author = 'Mahmoud Hashemi' 32 | 33 | # The full version, including alpha/beta/rc tags 34 | release = '24.0' 35 | 36 | 37 | # -- General configuration --------------------------------------------------- 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | 'sphinx.ext.autodoc', 44 | 'sphinx.ext.intersphinx', 45 | 'sphinx.ext.ifconfig', 46 | 'sphinx.ext.viewcode', 47 | ] 48 | 49 | # Read the Docs is version 1.2 as of writing 50 | #if sphinx.version_info[:2] < (1, 3): 51 | # extensions.append('sphinxcontrib.napoleon') 52 | #else: 53 | extensions.append('sphinx.ext.napoleon') 54 | 55 | master_doc = 'index' 56 | 57 | # Add any paths that contain templates here, relative to this directory. 58 | templates_path = ['_templates'] 59 | 60 | # List of patterns, relative to source directory, that match files and 61 | # directories to ignore when looking for source files. 62 | # This pattern also affects html_static_path and html_extra_path. 63 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 64 | 65 | 66 | # -- Options for HTML output ------------------------------------------------- 67 | 68 | # The theme to use for HTML and HTML Help pages. See the documentation for 69 | # a list of builtin themes. 70 | # 71 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 72 | 73 | if on_rtd: 74 | html_theme = 'default' 75 | else: # only import and set the theme if we're building docs locally 76 | import sphinx_rtd_theme 77 | html_theme = 'sphinx_rtd_theme' 78 | html_theme_path = ['_themes', sphinx_rtd_theme.get_html_theme_path()] 79 | 80 | html_theme_options = { 81 | 'navigation_depth': 4, 82 | 'collapse_navigation': False, 83 | } 84 | 85 | # Add any paths that contain custom static files (such as style sheets) here, 86 | # relative to this directory. They are copied after the builtin static files, 87 | # so a file named "default.css" will overwrite the builtin "default.css". 88 | html_static_path = ['_static'] 89 | -------------------------------------------------------------------------------- /docs/faq.rst: -------------------------------------------------------------------------------- 1 | Face FAQs 2 | ========= 3 | 4 | *TODO* 5 | 6 | What sets Face apart from other CLI libraries? 7 | ---------------------------------------------- 8 | 9 | In the Python world, you certainly have a lot of choices among 10 | argument parsers. Software isn't a competition, but there are many 11 | good reasons to choose face. 12 | 13 | * Rich dependency semantics guarantee that endpoints and their dependencies 14 | line up before the Command will build to start up. 15 | * Streamlined, Pythonic API. 16 | * Handy testing tools 17 | * Focus on CLI UX (arg order, discouraging required flag) 18 | * TODO: contrast with argparse, optparse, click, etc. 19 | 20 | Why is Face so picky about argument order? 21 | ------------------------------------------ 22 | 23 | In short, command-line user experience and history hygiene. While it's 24 | easy for us to be tempted to add flags to the ends of commands, anyone 25 | reading that command later is going to suffer:: 26 | 27 | cmd subcmd posarg1 --flag arg posarg2 28 | 29 | Does ``posarg2`` look more like a positional argument or an argument 30 | of ``--flag``? 31 | 32 | This is also why Face doesn't allow non-leaf commands to accept 33 | positional arguments (is it a subcommand or an argument?), or flags 34 | which support more than one whitespace-separated argument. 35 | 36 | Any recommended patterns for laying out CLI code? 37 | ------------------------------------------------- 38 | 39 | - Dedicated cli.py which constructs commands. 40 | - main function should take argv as an argument 41 | - ``if __name__ == '__main__': main(sys.argv)`` 42 | - Entrypoints are nicer than ``-m`` 43 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | face 2 | ==== 3 | 4 | |release| |calver| |changelog| 5 | 6 | **face** is a Pythonic microframework for building command-line applications: 7 | 8 | * First-class subcommand support 9 | * Powerful middleware architecture 10 | * Separate Parser layer 11 | * Built-in flagfile support 12 | * Handy testing utilities 13 | * Themeable help display 14 | 15 | Installation 16 | ------------ 17 | 18 | face is pure Python, and tested on Python 3.7+, as well as PyPy. Installation is easy:: 19 | 20 | pip install face 21 | 22 | Then get to building your first application! 23 | 24 | .. code-block:: python 25 | 26 | from face import Command, echo 27 | 28 | def hello_world(): 29 | "A simple greeting command." 30 | echo('Hello, world!') 31 | 32 | cmd = Command(hello_world) 33 | 34 | cmd.run() 35 | 36 | """ 37 | # Here's what the default help looks like at the command-line: 38 | 39 | $ cmd --help 40 | Usage: cmd [FLAGS] 41 | 42 | A simple greeting command. 43 | 44 | 45 | Flags: 46 | 47 | --help / -h show this help message and exit 48 | """ 49 | 50 | Getting Started 51 | --------------- 52 | 53 | Check out our :doc:`tutorial` for more. 54 | 55 | .. toctree:: 56 | :maxdepth: 2 57 | :caption: Contents: 58 | 59 | tutorial 60 | command 61 | middleware 62 | testing 63 | io 64 | faq 65 | 66 | .. |release| image:: https://img.shields.io/pypi/v/face.svg 67 | :target: https://pypi.org/project/face/ 68 | 69 | .. |calver| image:: https://img.shields.io/badge/calver-YY.MINOR.MICRO-22bfda.svg 70 | :target: https://calver.org 71 | 72 | .. |changelog| image:: https://img.shields.io/badge/CHANGELOG-UPDATED-b84ad6.svg 73 | :target: https://github.com/mahmoud/face/blob/master/CHANGELOG.md 74 | -------------------------------------------------------------------------------- /docs/io.rst: -------------------------------------------------------------------------------- 1 | Input / Output 2 | ================ 3 | 4 | Face includes a variety of utilities designed to make it easy to write 5 | applications that adhere to command-line conventions and user 6 | expectations. 7 | 8 | .. autofunction:: face.echo 9 | 10 | .. autofunction:: face.echo_err 11 | 12 | .. autofunction:: face.prompt 13 | 14 | .. autofunction:: face.prompt_secret 15 | 16 | 17 | TODO 18 | ---- 19 | 20 | * TODO: InputCancelled exception, to be handled by .run() 21 | * TODO: stuff for prompting choices 22 | * TODO: pre-made --color flag(s) (looks at isatty) 23 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/middleware.rst: -------------------------------------------------------------------------------- 1 | Middleware 2 | ========== 3 | 4 | *Coming soon!* 5 | 6 | * Dependency injection (like pytest!) 7 | * autodoc 8 | * inventory of all production-grade middlewares 9 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | Testing 2 | ======= 3 | 4 | Face provides a full-featured test client for maintaining high-quality 5 | command-line applications. 6 | 7 | .. autoclass:: face.CommandChecker 8 | 9 | .. automethod:: run 10 | 11 | .. automethod:: fail 12 | 13 | .. method:: fail_X 14 | 15 | Test that a command fails with exit code ``X``, where ``X`` is an integer. 16 | 17 | For testing convenience, any method of pattern ``fail_X()`` is the 18 | equivalent to ``fail(exit_code=X)``, and ``fail_X_Y()`` is 19 | equivalent to ``fail(exit_code=[X, Y])``, providing ``X`` and 20 | ``Y`` are integers. 21 | 22 | 23 | 24 | .. autoclass:: face.testing.RunResult 25 | 26 | .. attribute:: args 27 | 28 | The arguments passed to :meth:`~face.CommandChecker.run()`. 29 | 30 | .. attribute:: input 31 | 32 | The string input passed to the command, if any. 33 | 34 | .. attribute:: exit_code 35 | 36 | The integer exit code returned by the command. ``0`` conventionally indicates success. 37 | 38 | .. autoattribute:: stdout 39 | 40 | .. autoattribute:: stderr 41 | 42 | .. attribute:: stdout_bytes 43 | 44 | The output ("stdout") of the command, as an encoded bytestring. See 45 | :attr:`stdout` for the decoded text. 46 | 47 | .. attribute:: stderr_bytes 48 | 49 | The error output ("stderr") of the command, as an encoded 50 | bytestring. See :attr:`stderr` for the decoded text. May be 51 | ``None`` if *mix_stderr* was set to ``True`` in the 52 | :class:`CommandChecker`. 53 | 54 | .. autoattribute:: returncode 55 | 56 | .. attribute:: exc_info 57 | 58 | A 3-tuple of the internal exception, in the same fashion as 59 | :func:`sys.exc_info`, representing the captured uncaught 60 | exception raised by the command function from a 61 | :class:`~face.CommandChecker` with *reraise* set to 62 | ``True``. For advanced use only. 63 | 64 | .. autoattribute:: exception 65 | 66 | 67 | 68 | .. autoexception:: face.testing.CheckError 69 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | 4 | .. contents:: Contents 5 | :local: 6 | :depth: 2 7 | 8 | 9 | Part I: Say 10 | ----------- 11 | 12 | The field of overdone versions of ``echo`` has been too long dominated 13 | by Big GNU. 14 | Today, we start taking back the power. 15 | We will implement the ``say`` command. 16 | 17 | Positional arguments 18 | ~~~~~~~~~~~~~~~~~~~~ 19 | 20 | While face offers a Parser interface underneath, the canonical way to 21 | create even the simplest CLI is with the Command object. 22 | 23 | To demonstrate, we'll start with the basics, positional arguments. 24 | ``say hello world`` should print ``hello world``: 25 | 26 | .. code:: 27 | 28 | from face import Command, echo 29 | 30 | 31 | def main(): 32 | cmd = Command(say, posargs=True) # posargs=True means we accept positional arguments 33 | cmd.run() 34 | 35 | 36 | def say(posargs_): # positional arguments are passed through the posargs_ parameter 37 | echo(' '.join(posargs_)) # our business logic 38 | 39 | 40 | if __name__ == '__main__': # standard fare Python: https://stackoverflow.com/questions/419163 41 | main() 42 | 43 | A basic Command takes a single function entrypoint, in our case, the 44 | ``say`` function. 45 | 46 | .. note:: 47 | 48 | Face's :func:`echo` function is a version of :func:`print` with 49 | improved options and handling of console states, ideal for CLIs. 50 | 51 | Flags 52 | ~~~~~ 53 | 54 | Let's give ``say`` some options: 55 | 56 | ``say --upper hello world`` 57 | or 58 | ``say -U hello world`` 59 | should print 60 | ``HELLO WORLD``. 61 | 62 | .. code:: 63 | 64 | ... 65 | 66 | def main(): 67 | cmd = Command(say, posargs=True) 68 | cmd.add('--upper', char='-U', parse_as=True, doc='uppercase all output') 69 | cmd.run() 70 | 71 | 72 | def say(posargs_, upper): # our --upper flag is bound to the upper parameter 73 | args = posargs_ 74 | if upper: 75 | args = [a.upper() for a in args] 76 | echo(' '.join(args)) 77 | 78 | ... 79 | 80 | The ``parse_as`` keyword argument being set to ``True`` means that the 81 | presence of the flag results in the ``True`` value itself. As we'll 82 | see below, flags can take arguments, too. 83 | 84 | Flags with values 85 | ~~~~~~~~~~~~~~~~~ 86 | 87 | Let's add more flags, this time ones that take values. 88 | 89 | ``say --separator . hello world`` will print ``hello.world``. 90 | Likewise, 91 | ``say --count 2 hello world`` 92 | will repeat it twice: 93 | ``hello world hello world`` 94 | 95 | .. code:: 96 | 97 | ... 98 | 99 | def main(): 100 | cmd = Command(say, posargs=True) 101 | cmd.add('--upper', char='-U', parse_as=True, doc='uppercase all output') 102 | cmd.add('--separator', missing=' ', doc='text to put between arguments') 103 | cmd.add('--count', parse_as=int, missing=1, doc='how many times to repeat') 104 | cmd.run() 105 | 106 | 107 | def say(posargs_, upper, separator, count): 108 | args = posargs_ * count 109 | if upper: 110 | args = [a.upper() for a in args] 111 | echo(separator.join(args)) 112 | 113 | ... 114 | 115 | Now we can see that ``parse_as``: 116 | 117 | - Can take a value (e.g., ``True``), which make the flag no-argument 118 | - Can take a callable (e.g., ``int``), which is used to convert the single argument 119 | - Defaults to ``str`` (as used by ``separator``) 120 | 121 | We can also see the ``missing`` keyword argument, which specifies the 122 | value to be passed to the Command's handler function if the flag is 123 | absent. Without this, ``None`` is passed. 124 | 125 | .. note:: 126 | 127 | Face also supports required flags, though they are not an ideal CLI 128 | UX best practice. Simply set ``missing`` to :data:`face.ERROR`. 129 | 130 | More Interesting Flag Types 131 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 132 | 133 | 134 | ``say --multi-separator=@,# hello wonderful world`` 135 | prints 136 | ``hello@wonderful#world`` 137 | (The separators repeat) 138 | 139 | ``say --from-file=fname`` 140 | reads the file and adds all words from it to its 141 | output 142 | 143 | ``say --animal=dog|cat|cow`` 144 | will prepend "woof", "meow", or "moo" respectively. 145 | 146 | 147 | Part II: Calc 148 | ------------- 149 | 150 | (Details TBD!) 151 | 152 | With ``echo`` having met its match, 153 | we are on to bigger and better: 154 | this time, 155 | with math 156 | 157 | .. code:: 158 | 159 | $ num 160 | <Big help text> 161 | 162 | Add and Multiply 163 | ~~~~~~~~~~~~~~~~ 164 | 165 | .. code:: 166 | 167 | $ num add 1 2 168 | 3 169 | 170 | 171 | .. code:: 172 | 173 | $ num mul 3 5 174 | 15 175 | 176 | 177 | Subtract 178 | ~~~~~~~~ 179 | 180 | .. code:: 181 | 182 | $ num sub 10 5 183 | 5 184 | $ num sub 5 10 185 | Error: can't substract 186 | $ num --allow-negatives 5 10 187 | -5 188 | 189 | 190 | Divide 191 | ~~~~~~ 192 | 193 | .. code:: 194 | 195 | $ num div 2 3 196 | 0.6666666666666666 197 | $ num div --int 2 3 198 | 0 199 | 200 | 201 | Precision support 202 | ~~~~~~~~~~~~~~~~~ 203 | 204 | 205 | .. code:: 206 | 207 | $ num add 0.1 0.2 208 | 0.30000000000000004 209 | $ num add --precision=3 0.1 0.2 210 | 0.3 211 | 212 | Oh, now let's add it to all subcommands. 213 | 214 | Part III: Middleware 215 | -------------------- 216 | 217 | (Details TBD!) 218 | 219 | Doing math locally is all well and good, 220 | but sometimes we need to use the web. 221 | 222 | We will add an "expression" sub-command 223 | to num that uses ``https://api.mathjs.org/v4/``. 224 | But since we want to unit test it, 225 | we will create the ``httpx.Client`` in a middleware. 226 | 227 | .. code:: 228 | 229 | $ num expression "1 + (2 * 3)" 230 | 7 231 | 232 | But we can also write a unit test that does 233 | not touch the web: 234 | 235 | .. code:: 236 | 237 | $ pytest test_num.py 238 | 239 | 240 | Part IV: Examples 241 | ----------------- 242 | 243 | There are more realistic examples of 244 | `face` 245 | usage out there, 246 | that can serve as a reference. 247 | 248 | Cut MP4 249 | ~~~~~~~ 250 | 251 | The script 252 | `cut_mp4`_ 253 | is a quick but useful tool to cut recordings using 254 | ``ffmpeg``. 255 | I use it to slice and dice the Python meetup recordings. 256 | It does not have subcommands or middleware, 257 | just a few flags. 258 | 259 | 260 | .. _cut_mp4: https://github.com/mahmoud/face/blob/master/examples/cut_mp4.py 261 | 262 | Glom 263 | ~~~~ 264 | 265 | `Glom`_ 266 | is a command-line interface front end for the ``glom`` library. 267 | It does not have any subcommands, 268 | but does have some middleware usage. 269 | 270 | 271 | .. _Glom: https://github.com/mahmoud/glom/blob/master/glom/cli.py 272 | 273 | Pocket Protector 274 | ~~~~~~~~~~~~~~~~ 275 | 276 | `Pocket Protector`_ is a secrets management tool. 277 | It is a medium-sized application with quite a few subcommands 278 | for manipulating a YAML file. 279 | 280 | .. _Pocket Protector: https://github.com/SimpleLegal/pocket_protector/blob/master/pocket_protector/cli.py 281 | 282 | Montage Admin Tools 283 | ~~~~~~~~~~~~~~~~~~~ 284 | 285 | `Montage Admin Tools`_ 286 | is a larger application. 287 | It has nested subcommands 288 | and a database connection. 289 | It is used to administer a web application. 290 | 291 | .. _Montage Admin Tools: https://github.com/hatnote/montage/blob/master/tools/admin.py 292 | -------------------------------------------------------------------------------- /examples/cut_mp4.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """This small CLI app losslessly extracts parts of mp4 video by 3 | wrapping the canonical command-line video encoder, ffmpeg. ffmpeg's 4 | CLI interface is tricky, and cut_mp4's isn't! No more having to 5 | remember flag order! 6 | 7 | cut_mp4.py was written to minimally "edit" individual talk videos out of 8 | longer streams, recorded at Pyninsula, a local Python meetup I 9 | co-organize. Earlier videos didn't use this approach and had minor 10 | audio sync issues. Later videos are better about this. Results 11 | available here: 12 | https://www.youtube.com/channel/UCiT1ZWcRdFLx7PuR3fTjTfA 13 | 14 | An example command: 15 | 16 | ./cut_mp4.py --input input.mp4 --start 00:00:08 --end 00:02:02 --no-align-keyframes --output output.mp4 17 | 18 | Run ./cut_mp4.py --help for more info. 19 | 20 | """ 21 | 22 | import datetime 23 | import subprocess 24 | 25 | from face import Command, ERROR # NOTE: pip install face to fix an ImportError 26 | 27 | FFMPEG_CMD = 'ffmpeg' 28 | TIME_FORMAT = '%H:%M:%S' 29 | 30 | 31 | def main(): 32 | cmd = Command(cut_mp4) 33 | 34 | cmd.add('--input', missing=ERROR, doc='path to the input mp4 file') 35 | cmd.add('--output', missing=ERROR, doc='path to write the output mp4 file') 36 | cmd.add('--start', doc='starting timestamp in hh:mm:ss format') 37 | cmd.add('--end', doc='ending timestamp in hh:mm:ss format') 38 | 39 | cmd.add('--filter-audio', parse_as=True, 40 | doc='do high-pass/low-pass noise filtration of audio.' 41 | ' good for noisy meetup recordings.') 42 | cmd.add('--no-align-keyframes', parse_as=True, 43 | doc="don't align to the nearest keyframe, potentially" 44 | " creating an unclean cut with video artifacts") 45 | 46 | cmd.run() 47 | 48 | 49 | def cut_mp4(input, output, start, end, no_align_keyframes=False, filter_audio=False): 50 | """Losslessly cut an mp4 video to a time range using ffmpeg. Note that 51 | ffmpeg must be preinstalled on the system. 52 | """ 53 | start_ts = start or '00:00:00' 54 | start_dt = datetime.datetime.strptime(start_ts, TIME_FORMAT) 55 | 56 | end_ts = end or '23:59:59' # TODO 57 | end_dt = datetime.datetime.strptime(end_ts, TIME_FORMAT) 58 | 59 | assert end_dt > start_dt 60 | duration_ts = str(end_dt - start_dt) 61 | 62 | cmd = [FFMPEG_CMD, '-ss', start_ts] 63 | 64 | if not no_align_keyframes: 65 | cmd.append('-noaccurate_seek') 66 | 67 | audio_flags = ['-acodec', 'copy'] 68 | if filter_audio: 69 | audio_flags = ['-af', 'highpass=f=300,lowpass=f=3000'] 70 | 71 | cmd.extend(['-i', input, '-vcodec', 'copy'] + audio_flags + 72 | ['-t', duration_ts, '-avoid_negative_ts', 'make_zero', '-strict', '-2', output]) 73 | 74 | print('# ' + ' '.join(cmd)) 75 | 76 | return subprocess.check_call(cmd) 77 | 78 | 79 | if __name__ == '__main__': 80 | main() 81 | 82 | 83 | """This program was originally written with argparse, the CLI 84 | integration code (without the help strings) is below:: 85 | 86 | import argparse 87 | 88 | def main(argv): 89 | prs = argparse.ArgumentParser() 90 | add_arg = prs.add_argument 91 | add_arg('--input', required=True) 92 | add_arg('--output', required=True) 93 | add_arg('--start') 94 | add_arg('--end') 95 | add_arg('--no-align-keyframes', action="store_true") 96 | 97 | args = prs.parse_args(argv) 98 | 99 | return cut_mp4(input=args.input, 100 | output=args.output, 101 | start=args.start, 102 | end=args.end, 103 | no_align_keyframes=args.no_align_keyframes) 104 | 105 | 106 | if __name__ == '__main__': 107 | sys.exit(main(sys.argv[1:])) 108 | 109 | 110 | Due to the lack of subcommands and relative simplicity, the delta 111 | between face and argparse isn't so remarkable. Still, you can see how 112 | certain constructs compare. 113 | 114 | """ 115 | -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | """The CLI application below is a motley assortment of subcommands 2 | just for the purposes of showing off some face features. 3 | 4 | ( -- ._> -- ) 5 | 6 | """ 7 | from __future__ import print_function 8 | 9 | import sys 10 | 11 | from face import Command, face_middleware, ListParam 12 | 13 | 14 | def busy_loop(loop_count, stdout): 15 | """ 16 | Does a bit of busy work. No sweat. 17 | """ 18 | for i in range(loop_count or 3): 19 | stdout.write('work %s\n' % (i + 1)) 20 | return 21 | 22 | 23 | def sum_func(num): 24 | "Just a lil fun in the sum." 25 | print(sum(num)) 26 | 27 | 28 | def subtract(posargs_): 29 | summable = [float(posargs_[0])] + [-float(a) for a in posargs_[1:]] 30 | print(sum(summable)) 31 | 32 | 33 | def print_args(args_): 34 | print(args_.flags, args_.posargs, args_.post_posargs) 35 | 36 | 37 | def main(): 38 | cmd = Command(busy_loop, 'cmd', middlewares=[output_streams_mw]) 39 | 40 | sum_subcmd = Command(sum_func, 'sum') 41 | sum_subcmd.add('--num', parse_as=ListParam(int), missing=(0,), 42 | doc='a number to include in the sum, expects integers at the moment' 43 | ' because it is fun to change things later') 44 | sum_subcmd.add('--grummmmmmmmmmmmmmmmmmm', parse_as=int, multi=True, missing=0, 45 | doc='a bizarre creature, shrek-like, does nothing, but is here to' 46 | ' make the help longer and less helpful but still good for wraps.') 47 | 48 | cmd.add(sum_subcmd) 49 | 50 | cmd.add(verbose_mw) 51 | 52 | cmd.add(subtract, doc='', posargs=float) 53 | 54 | cmd.add(print_args, 'print', '', posargs=True) 55 | 56 | cmd.add('--loop-count', parse_as=int) 57 | 58 | return cmd.run() # execute 59 | 60 | 61 | from face.parser import Flag 62 | 63 | 64 | @face_middleware(flags=[Flag('--verbose', char='-V', parse_as=True)]) 65 | def verbose_mw(next_, verbose): 66 | if verbose: 67 | print('starting in verbose mode') 68 | ret = next_() 69 | if verbose: 70 | print('complete') 71 | return ret 72 | 73 | 74 | @face_middleware(provides=['stdout', 'stderr'], optional=True) 75 | def output_streams_mw(next_): 76 | return next_(stdout=sys.stdout, stderr=sys.stderr) 77 | 78 | 79 | 80 | if __name__ == '__main__': 81 | sys.exit(main()) 82 | -------------------------------------------------------------------------------- /examples/example_flagfile: -------------------------------------------------------------------------------- 1 | --loop-count 5 # a comment here 2 | # and a comment there 3 | --verbose 4 | 5 | #### Notes: 6 | 7 | # one flag and value per line at most 8 | # blank lines and comment lines are also allowed 9 | 10 | # --flag "quoted values # with hashes work, too" note that comments 11 | # can be directly adjacent to flags like: 12 | # "--verbose#set verbose mode to true" 13 | # but this is due to a bug in Python's shlex module. 14 | 15 | # flagfiles can include other flagfiles, and even have cycles 16 | # cycles are no issue because flagfiles are stateless 17 | --flagfile example_flagfile 18 | -------------------------------------------------------------------------------- /examples/giffr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """giffr extracts batches of gifs from mp4 files using 3 | a few tricks to improve quality and output filesize. 4 | 5 | Example command: 6 | 7 | ./giffr.py --fps 12 --height 360 --video ~/Videos/input.mp4 8 | 9 | Timestamp ranges are expected as text file located in the same directory 10 | as the video, with the same name and a .txt extension. The file can also 11 | be explicitly specified with the --stamps flag. Timestamp file format is 12 | one range per line: 13 | 14 | (HH:)MM:SS - (HH:)MM:SS (optional description can go here) 15 | 16 | An example timestamp file: 17 | 18 | 01:30 - 01:35 Nice clip 19 | 20 | 00:04:22 - 00:04:44 21 | 22 | This would generate two gifs, named 00_nice_clip.gif and 01.gif, in a 23 | directory adjacent the input video. 24 | 25 | Requires recent ffmpeg (2018+), and optionally gifsicle (for further gif optimization). 26 | 27 | See giffr.py -h for more options. 28 | 29 | """ 30 | 31 | import os 32 | import re 33 | import pprint 34 | import datetime 35 | import subprocess 36 | 37 | from boltons.fileutils import mkdir_p 38 | from boltons.strutils import slugify 39 | 40 | from face import Command, ERROR, UsageError, echo # NOTE: pip install face to fix an ImportError 41 | 42 | FFMPEG_CMD = 'ffmpeg' 43 | TIME_FORMAT = '%M:%S' 44 | TIME_FORMAT_2 = '%H:%M:%S' 45 | 46 | 47 | def main(): 48 | cmd = Command(process, doc=__doc__) 49 | 50 | cmd.add('--video', missing=ERROR, doc='path to the input mp4 file') 51 | cmd.add('--stamps', missing=None, doc='path to the stamps file') 52 | cmd.add('--overwrite', parse_as=True, doc='overwrite files if they already exist') 53 | cmd.add('--verbose', parse_as=True, doc='more output') 54 | cmd.add('--fps', parse_as=int, doc='framerate (frames per second) for the final rendered gif. omit to keep full framerate, set lower to decrease filesize. 12 is good.') 55 | cmd.add('--height', parse_as=int, doc='height in pixels of the output gif. width is autocomputed, omit to keep full size, set lower to decrease filesize.') 56 | cmd.add('--optimize', parse_as=True, doc='use gifsicle to losslessly compress gif output (shaves a few kilobytes sometimes)') 57 | cmd.add('--alt-palette', parse_as=True, doc='use alternative approach to palette: computing once per frame instead of once for whole gif. improved look, but may increase filesize.') 58 | cmd.add('--out-dir', missing=None, doc='where to create the directory with gifs. defaults to same directory as video.') 59 | 60 | cmd.run() 61 | 62 | def _parse_time(text): 63 | try: 64 | ret = datetime.datetime.strptime(text, TIME_FORMAT) 65 | except: 66 | ret = datetime.datetime.strptime(text, TIME_FORMAT_2) 67 | return ret 68 | 69 | 70 | def _parse_timestamps(ts_text): 71 | ret = [] 72 | lines = [line.strip() for line in ts_text.splitlines() if line.strip()] 73 | for i, line in enumerate(lines): 74 | parts = [x for x in re.split('[^\w:]', line) if x] 75 | start, end = parts[:2] 76 | desc = slugify(' '.join([f'{i:02}'] + parts[2:]), delim="_") 77 | 78 | start_dt = _parse_time(start) 79 | end_dt = _parse_time(end) 80 | assert end_dt > start_dt 81 | duration_ts = str(end_dt - start_dt) 82 | 83 | ret.append([start, end, duration_ts, desc]) 84 | return ret 85 | 86 | 87 | def process(video, stamps, overwrite, verbose, optimize, alt_palette, out_dir, fps=None, height=None): 88 | video_dir = os.path.dirname(os.path.abspath(video)) 89 | if out_dir is None: 90 | out_dir = video_dir 91 | 92 | basename, extension = os.path.splitext(os.path.basename(video)) 93 | if extension.lower() != '.mp4': 94 | raise UsageError(f'this command only supports mp4 at this time, not: {extension}') 95 | if not os.path.isfile(video): 96 | raise UsageError(f'missing video file: {video}') 97 | 98 | if stamps is None: 99 | inferred_stamps = video_dir + '/' + basename + '.txt' 100 | if os.path.isfile(inferred_stamps): 101 | stamps = inferred_stamps 102 | if verbose: 103 | echo(f'inferred timestamps filename: {stamps}') 104 | else: 105 | raise UsageError(f'missing --stamps value or file: {inferred_stamps}') 106 | 107 | with open(stamps) as f: 108 | timestamps = _parse_timestamps(f.read()) 109 | if verbose: 110 | pprint.pprint(timestamps) 111 | 112 | gif_dir = out_dir + '/' + slugify(basename) 113 | mkdir_p(gif_dir) 114 | 115 | filters = [] 116 | if fps: 117 | filters.append(f'fps={fps}') 118 | if height: 119 | assert height > 0 120 | filters.append(f'scale=-1:{height}') 121 | filters.append('split') 122 | 123 | for start, _, duration_ts, desc in timestamps: 124 | cmd = [FFMPEG_CMD, '-ss', start, '-t', duration_ts, '-i', video] 125 | if overwrite: 126 | cmd.append('-y') 127 | 128 | if alt_palette: 129 | palette_part = '[a] palettegen=stats_mode=single [p];[b][p] paletteuse=new=1' 130 | else: 131 | palette_part = '[a] palettegen [p];[b][p] paletteuse' 132 | 133 | cmd.extend(['-filter_complex', f'[0:v] {",".join(filters)} [a][b];{palette_part}']) 134 | 135 | if verbose: 136 | echo('# ' + ' '.join(cmd)) 137 | 138 | gif_path = gif_dir + f'/{desc}.gif' 139 | cmd.append(gif_path) 140 | subprocess.check_call(cmd) 141 | 142 | if optimize: 143 | opt_cmd = ['gifsicle', '-i', gif_path, '--optimize=3', '-o', gif_path] 144 | subprocess.check_call(opt_cmd) 145 | 146 | return 147 | 148 | if __name__ == '__main__': 149 | main() 150 | -------------------------------------------------------------------------------- /examples/phototimeshifter/requirements.txt: -------------------------------------------------------------------------------- 1 | boltons>=21.0.0 2 | exif>=1.3.5 3 | face>=22.0.0 4 | # py3exiv2==0.11.0 # see note in shifter.py 5 | tqdm>=4.0 -------------------------------------------------------------------------------- /examples/phototimeshifter/shifter.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | import tqdm 5 | from boltons.fileutils import atomic_save 6 | from boltons.timeutils import parse_timedelta 7 | from textwrap import dedent 8 | from exif import DATETIME_STR_FORMAT, Image 9 | from face import ERROR, Command 10 | 11 | 12 | def run(posargs_, shift, verbose, dryrun): 13 | """ 14 | Shift the EXIF datetimes of photos, mostly to correct for minor clock skew 15 | and timezone misconfigurations between multiple cameras. 16 | 17 | Modifies both the original EXIF date and the "digitized" EXIF date to be 18 | the same time, shifted from the original time. 19 | 20 | Does not modify any other EXIF or XMP data 21 | (i.e., tags, ratings, etc. will remain intact and unchanged) 22 | """ 23 | shift_td = parse_timedelta(shift) 24 | filenames = posargs_ 25 | 26 | total_count, update_count = len(filenames), 0 27 | if len(filenames) > 1 and not dryrun and not verbose: 28 | filenames = tqdm.tqdm(filenames) 29 | for i, fn in enumerate(filenames): 30 | image = Image(fn) 31 | if not image.has_exif: 32 | print(f' -- no exif detected in: {fn}') 33 | continue 34 | if not image.datetime_original: 35 | print(f' -- no datetime detected in: {fn}') 36 | 37 | # NOTE: exif doesn't support XMP, so I used pyexiv2 to confirm that writing new times has no effect on XMP. 38 | # Note that performing this check requires installing pyexiv2, which requires compilation (requiring boost and libexiv2-dev on ubuntu) 39 | # import pyexiv2; md = pyexiv2.ImageMetadata(fn); md.read(); old_md = {k: v.value for k, v in md.items()} 40 | 41 | dt = datetime.datetime.strptime(image.datetime_original, DATETIME_STR_FORMAT) 42 | new_dt = dt + shift_td 43 | old_dt_fmtd = dt.strftime(DATETIME_STR_FORMAT) 44 | new_dt_fmtd = new_dt.strftime(DATETIME_STR_FORMAT) 45 | update_count += 1 46 | short_fn = os.path.split(fn)[-1] 47 | verbose_msg = f'{short_fn} datetime: {old_dt_fmtd} -> {new_dt_fmtd} (updated {update_count} / {total_count})' 48 | if dryrun: 49 | msg = f' ++ dryrun: {verbose_msg}' 50 | print(msg, end='\n' if verbose else '\r') 51 | 52 | else: 53 | image.datetime_original = new_dt_fmtd 54 | image.datetime_digitized = new_dt_fmtd 55 | 56 | with atomic_save(fn, rm_part_on_exc=False) as new_img: 57 | new_img.write(image.get_file()) 58 | if verbose: 59 | print(f'updated: {verbose_msg}') 60 | # md = pyexiv2.ImageMetadata(fn); md.read(); new_md = {k: v.value for k, v in md.items()} 61 | # assert new_md == old_md # See NOTE above. Only true if run with "--shift 0m" 62 | 63 | if dryrun: 64 | print() 65 | 66 | 67 | cmd = Command(run, posargs=True, doc=dedent(run.__doc__)) 68 | cmd.add('--shift', missing=ERROR, doc="shift timedelta string, e.g., '2h' or '3d 1h 4m'") 69 | cmd.add('--dryrun', parse_as=True, doc="output change plan without modifying files") 70 | cmd.add('--verbose', parse_as=True, doc="display file-by-file output instead of progress bar") 71 | 72 | if __name__ == '__main__': 73 | cmd.run() 74 | -------------------------------------------------------------------------------- /face/__init__.py: -------------------------------------------------------------------------------- 1 | from face.parser import (Flag, 2 | FlagDisplay, 3 | ERROR, 4 | Parser, 5 | PosArgSpec, 6 | PosArgDisplay, 7 | CommandParseResult) 8 | 9 | from face.errors import (FaceException, 10 | CommandLineError, 11 | ArgumentParseError, 12 | UnknownFlag, 13 | DuplicateFlag, 14 | InvalidSubcommand, 15 | InvalidFlagArgument, 16 | UsageError) 17 | 18 | from face.parser import (ListParam, ChoicesParam) 19 | from face.command import Command 20 | from face.middleware import face_middleware 21 | from face.helpers import HelpHandler, StoutHelpFormatter 22 | from face.testing import CommandChecker, CheckError 23 | from face.utils import echo, echo_err, prompt, prompt_secret 24 | -------------------------------------------------------------------------------- /face/command.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from collections import OrderedDict 3 | from typing import Callable, List, Optional, Union 4 | 5 | from face.utils import unwrap_text, get_rdep_map, echo 6 | from face.errors import ArgumentParseError, CommandLineError, UsageError 7 | from face.parser import Parser, Flag, PosArgSpec 8 | from face.helpers import HelpHandler 9 | from face.middleware import (inject, 10 | get_arg_names, 11 | is_middleware, 12 | face_middleware, 13 | check_middleware, 14 | get_middleware_chain, 15 | _BUILTIN_PROVIDES) 16 | 17 | from boltons.strutils import camel2under 18 | from boltons.iterutils import unique 19 | 20 | 21 | def _get_default_name(func): 22 | from functools import partial 23 | if isinstance(func, partial): 24 | func = func.func # just one level of partial for now 25 | 26 | ret = getattr(func, '__name__', None) # most functions hit this 27 | 28 | if ret is None: 29 | ret = camel2under(func.__class__.__name__).lower() # callable instances, etc. 30 | 31 | return ret 32 | 33 | 34 | def _docstring_to_doc(func): 35 | doc = func.__doc__ 36 | if not doc: 37 | return '' 38 | 39 | unwrapped = unwrap_text(doc) 40 | try: 41 | ret = [g for g in unwrapped.splitlines() if g][0] 42 | except IndexError: 43 | ret = '' 44 | 45 | return ret 46 | 47 | 48 | def default_print_error(msg): 49 | return echo.err(msg) 50 | 51 | 52 | DEFAULT_HELP_HANDLER = HelpHandler() 53 | 54 | 55 | # TODO: should name really go here? 56 | class Command(Parser): 57 | """The central type in the face framework. Instantiate a Command, 58 | populate it with flags and subcommands, and then call 59 | command.run() to execute your CLI. 60 | 61 | Args: 62 | func: The function called when this command is 63 | run with an argv that contains no subcommands. 64 | name: The name of this command, used when this 65 | command is included as a subcommand. (Defaults to name 66 | of function) 67 | doc: A description or message that appears in various 68 | help outputs. 69 | flags: A list of Flag instances to initialize the 70 | Command with. Flags can always be added later with the 71 | .add() method. 72 | posargs: Pass True if the command takes positional 73 | arguments. Defaults to False. Can also pass a PosArgSpec 74 | instance. 75 | post_posargs: Pass True if the command takes 76 | additional positional arguments after a conventional '--' 77 | specifier. 78 | help: Pass False to disable the automatically added 79 | --help flag. Defaults to True. Also accepts a HelpHandler 80 | instance. 81 | middlewares: A list of @face_middleware decorated 82 | callables which participate in dispatch. 83 | """ 84 | def __init__(self, 85 | func: Optional[Callable], 86 | name: Optional[str] = None, 87 | doc: Optional[str] = None, 88 | *, 89 | flags: Optional[List[Flag]] = None, 90 | posargs: Optional[Union[bool, PosArgSpec]] = None, 91 | post_posargs: Optional[bool] = None, 92 | flagfile: bool = True, 93 | help: Union[bool, HelpHandler] = DEFAULT_HELP_HANDLER, 94 | middlewares: Optional[List[Callable]] = None) -> None: 95 | name = name if name is not None else _get_default_name(func) 96 | if doc is None: 97 | doc = _docstring_to_doc(func) 98 | 99 | # TODO: default posargs if none by inspecting func 100 | super().__init__(name, doc, 101 | flags=flags, 102 | posargs=posargs, 103 | post_posargs=post_posargs, 104 | flagfile=flagfile) 105 | 106 | self.help_handler = help 107 | 108 | # TODO: if func is callable, check that "next_" isn't taken 109 | self._path_func_map = OrderedDict() 110 | self._path_func_map[()] = func 111 | 112 | middlewares = list(middlewares or []) 113 | self._path_mw_map = OrderedDict() 114 | self._path_mw_map[()] = [] 115 | self._path_wrapped_map = OrderedDict() 116 | self._path_wrapped_map[()] = func 117 | for mw in middlewares: 118 | self.add_middleware(mw) 119 | 120 | if help: 121 | if help is True: 122 | help = DEFAULT_HELP_HANDLER 123 | if help.flag: 124 | self.add(help.flag) 125 | if help.subcmd: 126 | self.add(help.func, help.subcmd) # for 'help' as a subcmd 127 | 128 | if not func and not help: 129 | raise ValueError('Command requires a handler function or help handler' 130 | ' to be set, not: %r' % func) 131 | 132 | return 133 | 134 | @property 135 | def func(self): 136 | return self._path_func_map[()] 137 | 138 | def add(self, *a, **kw): 139 | """Add a flag, subcommand, or middleware to this Command. 140 | 141 | If the first argument is a callable, this method contructs a 142 | Command from it and the remaining arguments, all of which are 143 | optional. See the Command docs for for full details on names 144 | and defaults. 145 | 146 | If the first argument is a string, this method constructs a 147 | Flag from that flag string and the rest of the method 148 | arguments, all of which are optional. See the Flag docs for 149 | more options. 150 | 151 | If the argument is already an instance of Flag or Command, an 152 | exception is only raised on conflicting subcommands and 153 | flags. See add_command for details. 154 | 155 | Middleware is only added if it is already decorated with 156 | @face_middleware. Use .add_middleware() for automatic wrapping 157 | of callables. 158 | 159 | """ 160 | # TODO: need to check for middleware provides names + flag names 161 | # conflict 162 | 163 | target = a[0] 164 | 165 | if is_middleware(target): 166 | return self.add_middleware(target) 167 | 168 | subcmd = a[0] 169 | if not isinstance(subcmd, Command) and callable(subcmd) or subcmd is None: 170 | subcmd = Command(*a, **kw) # attempt to construct a new subcmd 171 | 172 | if isinstance(subcmd, Command): 173 | self.add_command(subcmd) 174 | return subcmd 175 | 176 | flag = a[0] 177 | if not isinstance(flag, Flag): 178 | flag = Flag(*a, **kw) # attempt to construct a Flag from arguments 179 | super().add(flag) 180 | 181 | return flag 182 | 183 | def add_command(self, subcmd): 184 | """Add a Command, and all of its subcommands, as a subcommand of this 185 | Command. 186 | 187 | Middleware from the current command is layered on top of the 188 | subcommand's. An exception may be raised if there are 189 | conflicting middlewares or subcommand names. 190 | """ 191 | if not isinstance(subcmd, Command): 192 | raise TypeError(f'expected Command instance, not: {subcmd!r}') 193 | self_mw = self._path_mw_map[()] 194 | super().add(subcmd) 195 | # map in new functions 196 | for path in self.subprs_map: 197 | if path not in self._path_func_map: 198 | self._path_func_map[path] = subcmd._path_func_map[path[1:]] 199 | sub_mw = subcmd._path_mw_map[path[1:]] 200 | self._path_mw_map[path] = self_mw + sub_mw # TODO: check for conflicts 201 | return 202 | 203 | def add_middleware(self, mw): 204 | """Add a single middleware to this command. Outermost middleware 205 | should be added first. Remember: first added, first called. 206 | 207 | """ 208 | if not is_middleware(mw): 209 | mw = face_middleware(mw) 210 | check_middleware(mw) 211 | 212 | for flag in mw._face_flags: 213 | self.add(flag) 214 | 215 | for path, mws in self._path_mw_map.items(): 216 | self._path_mw_map[path] = [mw] + mws # TODO: check for conflicts 217 | 218 | return 219 | 220 | # TODO: add_flag() 221 | 222 | def get_flag_map(self, path=(), with_hidden=True): 223 | """Command's get_flag_map differs from Parser's in that it filters 224 | the flag map to just the flags used by the endpoint at the 225 | associated subcommand *path*. 226 | """ 227 | flag_map = super().get_flag_map(path=path, with_hidden=with_hidden) 228 | dep_names = self.get_dep_names(path) 229 | if 'args_' in dep_names or 'flags_' in dep_names: 230 | # the argument parse result and flag dict both capture 231 | # _all_ the flags, so for functions accepting these 232 | # arguments we bypass filtering. 233 | 234 | # Also note that by setting an argument default in the 235 | # function definition, the dependency becomes "weak", and 236 | # this bypassing of filtering will not trigger, unless 237 | # another function in the chain has a non-default, 238 | # "strong" dependency. This behavior is especially useful 239 | # for middleware. 240 | 241 | # TODO: add decorator for the corner case where a function 242 | # accepts these arguments and doesn't use them all. 243 | return OrderedDict(flag_map) 244 | 245 | return OrderedDict([(k, f) for k, f in flag_map.items() if f.name in dep_names 246 | or f is self.flagfile_flag or f is self.help_handler.flag]) 247 | 248 | def get_dep_names(self, path=()): 249 | """Get a list of the names of all required arguments of a command (and 250 | any associated middleware). 251 | 252 | By specifying *path*, the same can be done for any subcommand. 253 | """ 254 | func = self._path_func_map[path] 255 | if not func: 256 | return [] # for when no handler is specified 257 | 258 | mws = self._path_mw_map[path] 259 | 260 | # start out with all args of handler function, which gets stronger dependencies 261 | required_args = set(get_arg_names(func, only_required=False)) 262 | dep_map = {func: set(required_args)} 263 | for mw in mws: 264 | arg_names = set(get_arg_names(mw, only_required=True)) 265 | for provide in mw._face_provides: 266 | dep_map[provide] = arg_names 267 | if not mw._face_optional: 268 | # all non-optional middlewares get their args required, too. 269 | required_args.update(arg_names) 270 | 271 | rdep_map = get_rdep_map(dep_map) 272 | 273 | recursive_required_args = rdep_map[func].union(required_args) 274 | 275 | return sorted(recursive_required_args) 276 | 277 | def prepare(self, paths=None): 278 | """Compile and validate one or more subcommands to ensure all 279 | dependencies are met. Call this once all flags, subcommands, 280 | and middlewares have been added (using .add()). 281 | 282 | This method is automatically called by .run() method, but it 283 | only does so for the specific subcommand being invoked. More 284 | conscientious users may want to call this method with no 285 | arguments to validate that all subcommands are ready for 286 | execution. 287 | """ 288 | # TODO: also pre-execute help formatting to make sure all 289 | # values are sane there, too 290 | if paths is None: 291 | paths = self._path_func_map.keys() 292 | 293 | for path in paths: 294 | func = self._path_func_map[path] 295 | if func is None: 296 | continue # handled by run() 297 | 298 | prs = self.subprs_map[path] if path else self 299 | provides = [] 300 | if prs.posargs.provides: 301 | provides += [prs.posargs.provides] 302 | if prs.post_posargs.provides: 303 | provides += [prs.post_posargs.provides] 304 | 305 | deps = self.get_dep_names(path) 306 | flag_names = [f.name for f in self.get_flags(path=path)] 307 | all_mws = self._path_mw_map[path] 308 | 309 | # filter out unused middlewares 310 | mws = [mw for mw in all_mws if not mw._face_optional 311 | or [p for p in mw._face_provides if p in deps]] 312 | provides += _BUILTIN_PROVIDES + flag_names 313 | try: 314 | wrapped = get_middleware_chain(mws, func, provides) 315 | except NameError as ne: 316 | ne.args = (ne.args[0] + f' (in path: {path!r})',) 317 | raise 318 | 319 | self._path_wrapped_map[path] = wrapped 320 | 321 | return 322 | 323 | def run(self, argv=None, extras=None, print_error=None): 324 | """Parses arguments and dispatches to the appropriate subcommand 325 | handler. If there is a parse error due to invalid user input, 326 | an error is printed and a CommandLineError is raised. If not 327 | caught, a CommandLineError will exit the process, typically 328 | with status code 1. Also handles dispatching to the 329 | appropriate HelpHandler, if configured. 330 | 331 | Defaults to handling the arguments on the command line 332 | (``sys.argv``), but can also be explicitly passed arguments 333 | via the *argv* parameter. 334 | 335 | Args: 336 | argv (list): A sequence of strings representing the 337 | command-line arguments. Defaults to ``sys.argv``. 338 | extras (dict): A map of additional arguments to be made 339 | available to the subcommand's handler function. 340 | print_error (callable): The function that formats/prints 341 | error messages before program exit on CLI errors. 342 | 343 | .. note:: 344 | 345 | For efficiency, :meth:`run()` only checks the subcommand 346 | invoked by *argv*. To ensure that all subcommands are 347 | configured properly, call :meth:`prepare()`. 348 | 349 | """ 350 | if print_error is None or print_error is True: 351 | print_error = default_print_error 352 | elif print_error and not callable(print_error): 353 | raise TypeError(f'expected callable for print_error, not {print_error!r}') 354 | 355 | kwargs = dict(extras) if extras else {} 356 | kwargs['print_error_'] = print_error # TODO: print_error_ in builtin provides? 357 | 358 | try: 359 | prs_res = self.parse(argv=argv) 360 | except ArgumentParseError as ape: 361 | prs_res = ape.prs_res 362 | 363 | # even if parsing failed, check if the caller was trying to access the help flag 364 | cmd = prs_res.to_cmd_scope()['subcommand_'] 365 | if cmd.help_handler and prs_res.flags and prs_res.flags.get(cmd.help_handler.flag.name): 366 | kwargs.update(prs_res.to_cmd_scope()) 367 | return inject(cmd.help_handler.func, kwargs) 368 | 369 | msg = 'error: ' + (prs_res.name or self.name) 370 | if prs_res.subcmds: 371 | msg += ' ' + ' '.join(prs_res.subcmds or ()) 372 | 373 | # args attribute, nothing to do with cmdline args this is 374 | # the standard-issue Exception 375 | e_msg = ape.args[0] 376 | if e_msg: 377 | msg += ': ' + e_msg 378 | cle = CommandLineError(msg) 379 | if print_error: 380 | print_error(msg) 381 | raise cle 382 | 383 | kwargs.update(prs_res.to_cmd_scope()) 384 | 385 | # default in case no middlewares have been installed 386 | func = self._path_func_map[prs_res.subcmds] 387 | 388 | cmd = kwargs['subcommand_'] 389 | if cmd.help_handler and (not func or (prs_res.flags and prs_res.flags.get(cmd.help_handler.flag.name))): 390 | return inject(cmd.help_handler.func, kwargs) 391 | elif not func: # pragma: no cover 392 | raise RuntimeError('expected command handler or help handler to be set') 393 | 394 | self.prepare(paths=[prs_res.subcmds]) 395 | wrapped = self._path_wrapped_map.get(prs_res.subcmds, func) 396 | 397 | try: 398 | ret = inject(wrapped, kwargs) 399 | except UsageError as ue: 400 | if print_error: 401 | print_error(ue.format_message()) 402 | raise 403 | return ret 404 | -------------------------------------------------------------------------------- /face/errors.py: -------------------------------------------------------------------------------- 1 | from boltons.iterutils import unique 2 | 3 | import face.utils 4 | 5 | class FaceException(Exception): 6 | """The basest base exception Face has. Rarely directly instantiated 7 | if ever, but useful for catching. 8 | """ 9 | pass 10 | 11 | 12 | class ArgumentParseError(FaceException): 13 | """A base exception used for all errors raised during argument 14 | parsing. 15 | 16 | Many subtypes have a ".from_parse()" classmethod that creates an 17 | exception message from the values available during the parse 18 | process. 19 | """ 20 | pass 21 | 22 | 23 | class ArgumentArityError(ArgumentParseError): 24 | """Raised when too many or too few positional arguments are passed to 25 | the command. See PosArgSpec for more info. 26 | """ 27 | pass 28 | 29 | 30 | class InvalidSubcommand(ArgumentParseError): 31 | """ 32 | Raised when an unrecognized subcommand is passed. 33 | """ 34 | @classmethod 35 | def from_parse(cls, prs, subcmd_name): 36 | # TODO: add edit distance calculation 37 | valid_subcmds = unique([path[:1][0] for path in prs.subprs_map.keys()]) 38 | msg = ('unknown subcommand "%s", choose from: %s' 39 | % (subcmd_name, ', '.join(valid_subcmds))) 40 | return cls(msg) 41 | 42 | 43 | class UnknownFlag(ArgumentParseError): 44 | """ 45 | Raised when an unrecognized flag is passed. 46 | """ 47 | @classmethod 48 | def from_parse(cls, cmd_flag_map, flag_name): 49 | # TODO: add edit distance calculation 50 | valid_flags = unique([face.utils.format_flag_label(flag) for flag in 51 | cmd_flag_map.values() if not flag.display.hidden]) 52 | msg = f"unknown flag \"{flag_name}\", choose from: {', '.join(valid_flags)}" 53 | return cls(msg) 54 | 55 | 56 | class InvalidFlagArgument(ArgumentParseError): 57 | """Raised when the argument passed to a flag (the value directly 58 | after it in argv) fails to parse. Tries to automatically detect 59 | when an argument is missing. 60 | """ 61 | @classmethod 62 | def from_parse(cls, cmd_flag_map, flag, arg, exc=None): 63 | if arg is None: 64 | return cls(f'expected argument for flag {flag.name}') 65 | 66 | val_parser = flag.parse_as 67 | vp_label = getattr(val_parser, 'display_name', face.utils.FRIENDLY_TYPE_NAMES.get(val_parser)) 68 | if vp_label is None: 69 | vp_label = repr(val_parser) 70 | tmpl = 'flag %s converter (%r) failed to parse value: %r' 71 | else: 72 | tmpl = 'flag %s expected a valid %s value, not %r' 73 | msg = tmpl % (flag.name, vp_label, arg) 74 | 75 | if exc: 76 | # TODO: put this behind a verbose flag? 77 | msg += f' (got error: {exc!r})' 78 | if arg.startswith('-'): 79 | msg += '. (Did you forget to pass an argument?)' 80 | 81 | return cls(msg) 82 | 83 | 84 | class InvalidPositionalArgument(ArgumentParseError): 85 | """Raised when one of the positional arguments does not 86 | parse/validate as specified. See PosArgSpec for more info. 87 | """ 88 | @classmethod 89 | def from_parse(cls, posargspec, arg, exc): 90 | prep, type_desc = face.utils.get_type_desc(posargspec.parse_as) 91 | return cls('positional argument failed to parse %s' 92 | ' %s: %r (got error: %r)' % (prep, type_desc, arg, exc)) 93 | 94 | 95 | class MissingRequiredFlags(ArgumentParseError): 96 | """ 97 | Raised when a required flag is not passed. See Flag for more info. 98 | """ 99 | @classmethod 100 | def from_parse(cls, cmd_flag_map, parsed_flag_map, missing_flag_names): 101 | flag_names = set(missing_flag_names) 102 | labels = [] 103 | for flag_name in flag_names: 104 | flag = cmd_flag_map[flag_name] 105 | labels.append(face.utils.format_flag_label(flag)) 106 | msg = f"missing required arguments for flags: {', '.join(sorted(labels))}" 107 | return cls(msg) 108 | 109 | 110 | class DuplicateFlag(ArgumentParseError): 111 | """Raised when a flag is passed multiple times, and the flag's 112 | "multi" setting is set to 'error'. 113 | """ 114 | @classmethod 115 | def from_parse(cls, flag, arg_val_list): 116 | avl_text = ', '.join([repr(v) for v in arg_val_list]) 117 | if callable(flag.parse_as): 118 | msg = f'more than one value was passed for flag "{flag.name}": {avl_text}' 119 | else: 120 | msg = f'flag "{flag.name}" was used multiple times, but can be used only once' 121 | return cls(msg) 122 | 123 | 124 | ## Non-parse related exceptions (used primarily in command.py instead of parser.py) 125 | 126 | class CommandLineError(FaceException, SystemExit): 127 | """A :exc:`~face.FaceException` and :exc:`SystemExit` subtype that 128 | enables safely catching runtime errors that would otherwise cause 129 | the process to exit. 130 | 131 | If instances of this exception are left uncaught, they will exit 132 | the process. 133 | 134 | If raised from a :meth:`~face.Command.run()` call and 135 | ``print_error`` is True, face will print the error before 136 | reraising. See :meth:`face.Command.run()` for more details. 137 | """ 138 | def __init__(self, msg, code=1): 139 | SystemExit.__init__(self, msg) 140 | self.code = code 141 | 142 | 143 | class UsageError(CommandLineError): 144 | """Application developers should raise this :exc:`CommandLineError` 145 | subtype to indicate to users that the application user has used 146 | the command incorrectly. 147 | 148 | Instead of printing an ugly stack trace, Face will print a 149 | readable error message of your choosing, then exit with a nonzero 150 | exit code. 151 | """ 152 | 153 | def format_message(self): 154 | msg = self.args[0] 155 | lines = msg.splitlines() 156 | msg = '\n '.join(lines) 157 | return 'error: ' + msg 158 | -------------------------------------------------------------------------------- /face/helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import array 4 | import textwrap 5 | 6 | from boltons.iterutils import unique, split 7 | 8 | from face.utils import format_flag_label, format_flag_post_doc, format_posargs_label, echo 9 | from face.parser import Flag 10 | 11 | DEFAULT_HELP_FLAG = Flag('--help', parse_as=True, char='-h', doc='show this help message and exit') 12 | DEFAULT_MAX_WIDTH = 120 13 | 14 | 15 | def _get_termios_winsize(): 16 | # TLPI, 62.9 (p. 1319) 17 | import fcntl 18 | import termios 19 | 20 | winsize = array.array('H', [0, 0, 0, 0]) 21 | 22 | assert not fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ, winsize) 23 | 24 | ws_row, ws_col, _, _ = winsize 25 | 26 | return ws_row, ws_col 27 | 28 | 29 | def _get_environ_winsize(): 30 | # the argparse approach. not sure which systems this works or 31 | # worked on, if any. ROWS/COLUMNS are special shell variables. 32 | try: 33 | rows, columns = int(os.environ['ROWS']), int(os.environ['COLUMNS']) 34 | except (KeyError, ValueError): 35 | rows, columns = None, None 36 | return rows, columns 37 | 38 | 39 | def get_winsize(): 40 | rows, cols = None, None 41 | try: 42 | rows, cols = _get_termios_winsize() 43 | except Exception: 44 | try: 45 | rows, cols = _get_environ_winsize() 46 | except Exception: 47 | pass 48 | return rows, cols 49 | 50 | 51 | def get_wrap_width(max_width=DEFAULT_MAX_WIDTH): 52 | _, width = get_winsize() 53 | if width is None: 54 | width = 80 55 | width = min(width, max_width) 56 | width -= 2 57 | return width 58 | 59 | 60 | def _wrap_stout_pair(indent, label, sep, doc, doc_start, max_doc_width): 61 | # TODO: consider making the fill character configurable (ljust 62 | # uses space by default, the just() methods can only take 63 | # characters, might be a useful bolton to take a repeating 64 | # sequence) 65 | ret = [] 66 | append = ret.append 67 | lhs = indent + label 68 | 69 | if not doc: 70 | append(lhs) 71 | return ret 72 | 73 | len_sep = len(sep) 74 | wrapped_doc = textwrap.wrap(doc, max_doc_width) 75 | if len(lhs) <= doc_start: 76 | lhs_f = lhs.ljust(doc_start - len(sep)) + sep 77 | append(lhs_f + wrapped_doc[0]) 78 | else: 79 | append(lhs) 80 | append((' ' * (doc_start - len_sep)) + sep + wrapped_doc[0]) 81 | 82 | for line in wrapped_doc[1:]: 83 | append(' ' * doc_start + line) 84 | 85 | return ret 86 | 87 | 88 | def _wrap_stout_cmd_doc(indent, doc, max_width): 89 | """Function for wrapping command description.""" 90 | parts = [] 91 | paras = ['\n'.join(para) for para in 92 | split(doc.splitlines(), lambda l: not l.lstrip()) 93 | if para] 94 | for para in paras: 95 | part = textwrap.fill(text=para, 96 | width=(max_width - len(indent)), 97 | initial_indent=indent, 98 | subsequent_indent=indent) 99 | parts.append(part) 100 | return '\n\n'.join(parts) 101 | 102 | 103 | def get_stout_layout(labels, indent, sep, width=None, max_width=DEFAULT_MAX_WIDTH, 104 | min_doc_width=40): 105 | width = width or get_wrap_width(max_width=max_width) 106 | 107 | len_sep = len(sep) 108 | len_indent = len(indent) 109 | 110 | max_label_width = 0 111 | max_doc_width = min_doc_width 112 | doc_start = width - min_doc_width 113 | for label in labels: 114 | cur_len = len(label) 115 | if cur_len < max_label_width: 116 | continue 117 | max_label_width = cur_len 118 | if (len_indent + cur_len + len_sep + min_doc_width) < width: 119 | max_doc_width = width - max_label_width - len_sep - len_indent 120 | doc_start = len_indent + cur_len + len_sep 121 | 122 | return {'width': width, 123 | 'label_width': max_label_width, 124 | 'doc_width': max_doc_width, 125 | 'doc_start': doc_start} 126 | 127 | 128 | DEFAULT_CONTEXT = { 129 | 'usage_label': 'Usage:', 130 | 'subcmd_section_heading': 'Subcommands: ', 131 | 'flags_section_heading': 'Flags: ', 132 | 'posargs_section_heading': 'Positional arguments:', 133 | 'section_break': '\n', 134 | 'group_break': '', 135 | 'subcmd_example': 'subcommand', 136 | 'width': None, 137 | 'max_width': 120, 138 | 'min_doc_width': 50, 139 | 'format_posargs_label': format_posargs_label, 140 | 'format_flag_label': format_flag_label, 141 | 'format_flag_post_doc': format_flag_post_doc, 142 | 'doc_separator': ' ', # ' + ' is pretty classy as bullet points, too 143 | 'section_indent': ' ', 144 | 'pre_doc': '', # TODO: these should go on CommandDisplay 145 | 'post_doc': '\n', 146 | } 147 | 148 | 149 | class StoutHelpFormatter: 150 | """This formatter takes :class:`Parser` and :class:`Command` instances 151 | and generates help text. The output style is inspired by, but not 152 | the same as, argparse's automatic help formatting. 153 | 154 | Probably what most Pythonists expect, this help text is slightly 155 | stouter (conservative with vertical space) than other conventional 156 | help messages. 157 | 158 | The default output looks like:: 159 | 160 | Usage: example.py subcommand [FLAGS] 161 | 162 | Does a bit of busy work 163 | 164 | 165 | Subcommands: 166 | 167 | sum Just a lil fun in the sum 168 | subtract 169 | print 170 | 171 | 172 | Flags: 173 | 174 | --help / -h show this help message and exit 175 | --verbose / -V 176 | 177 | 178 | Due to customizability, the constructor takes a large number of 179 | keyword arguments, the most important of which are highlighted 180 | here. 181 | 182 | Args: 183 | width (int): The width of the help output in 184 | columns/characters. Defaults to the width of the terminal, 185 | with a max of *max_width*. 186 | max_width (int): The widest the help output will get. Too wide 187 | and it can be hard to visually scan. Defaults to 120 columns. 188 | min_doc_width (int): The text documentation's minimum width in 189 | columns/characters. Puts flags and subcommands on their own 190 | lines when they're long or the terminal is narrow. Defaults to 191 | 50. 192 | doc_separator (str): The string to put between a 193 | flag/subcommand and its documentation. Defaults to `' '`. (Try 194 | `' + '` for a classy bulleted doc style. 195 | 196 | An instance of StoutHelpFormatter can be passed to 197 | :class:`HelpHandler`, which can in turn be passed to 198 | :class:`Command` for maximum command customizability. 199 | 200 | Alternatively, when using :class:`Parser` object directly, you can 201 | instantiate this type and pass a :class:`Parser` object to 202 | :meth:`get_help_text()` or :meth:`get_usage_line()` to get 203 | identically formatted text without sacrificing flow control. 204 | 205 | HelpFormatters are stateless, in that they can be used more than 206 | once, with different Parsers and Commands without needing to be 207 | recreated or otherwise reset. 208 | 209 | """ 210 | default_context = dict(DEFAULT_CONTEXT) 211 | 212 | def __init__(self, **kwargs): 213 | self.ctx = {} 214 | for key, val in self.default_context.items(): 215 | self.ctx[key] = kwargs.pop(key, val) 216 | if kwargs: 217 | raise TypeError(f'unexpected formatter arguments: {list(kwargs.keys())!r}') 218 | 219 | def _get_layout(self, labels): 220 | ctx = self.ctx 221 | return get_stout_layout(labels=labels, 222 | indent=ctx['section_indent'], 223 | sep=ctx['doc_separator'], 224 | width=ctx['width'], 225 | max_width=ctx['max_width'], 226 | min_doc_width=ctx['min_doc_width']) 227 | 228 | def get_help_text(self, parser, subcmds=(), program_name=None): 229 | """Turn a :class:`Parser` or :class:`Command` into a multiline 230 | formatted help string, suitable for printing. Includes the 231 | usage line and trailing newline by default. 232 | 233 | Args: 234 | parser (Parser): A :class:`Parser` or :class:`Command` 235 | object to generate help text for. 236 | subcmds (tuple): A sequence of subcommand strings 237 | specifying the subcommand to generate help text for. 238 | Defaults to ``()``. 239 | program_name (str): The program name, if it differs from 240 | the default ``sys.argv[0]``. (For example, 241 | ``example.py``, when running the command ``python 242 | example.py --flag val arg``.) 243 | 244 | """ 245 | # TODO: incorporate "Arguments" section if posargs has a doc set 246 | ctx = self.ctx 247 | 248 | ret = [self.get_usage_line(parser, subcmds=subcmds, program_name=program_name)] 249 | append = ret.append 250 | append(ctx['group_break']) 251 | 252 | shown_flags = parser.get_flags(path=subcmds, with_hidden=False) 253 | if subcmds: 254 | parser = parser.subprs_map[subcmds] 255 | 256 | if parser.doc: 257 | append(_wrap_stout_cmd_doc(indent=ctx['section_indent'], 258 | doc=parser.doc, 259 | max_width=ctx['width'] or get_wrap_width( 260 | max_width=ctx['max_width']))) 261 | append(ctx['section_break']) 262 | 263 | if parser.subprs_map: 264 | subcmd_names = unique([sp[0] for sp in parser.subprs_map if sp]) 265 | subcmd_layout = self._get_layout(labels=subcmd_names) 266 | 267 | append(ctx['subcmd_section_heading']) 268 | append(ctx['group_break']) 269 | for sub_name in unique([sp[0] for sp in parser.subprs_map if sp]): 270 | subprs = parser.subprs_map[(sub_name,)] 271 | # TODO: sub_name.replace('_', '-') = _cmd -> -cmd (need to skip replacing leading underscores) 272 | subcmd_lines = _wrap_stout_pair(indent=ctx['section_indent'], 273 | label=sub_name.replace('_', '-'), 274 | sep=ctx['doc_separator'], 275 | doc=subprs.doc, 276 | doc_start=subcmd_layout['doc_start'], 277 | max_doc_width=subcmd_layout['doc_width']) 278 | ret.extend(subcmd_lines) 279 | 280 | append(ctx['section_break']) 281 | 282 | if not shown_flags: 283 | return '\n'.join(ret) 284 | 285 | fmt_flag_label = ctx['format_flag_label'] 286 | flag_labels = [fmt_flag_label(flag) for flag in shown_flags] 287 | flag_layout = self._get_layout(labels=flag_labels) 288 | 289 | fmt_flag_post_doc = ctx['format_flag_post_doc'] 290 | append(ctx['flags_section_heading']) 291 | append(ctx['group_break']) 292 | for flag in shown_flags: 293 | disp = flag.display 294 | if disp.full_doc is not None: 295 | doc = disp.full_doc 296 | else: 297 | _parts = [disp.doc] if disp.doc else [] 298 | post_doc = disp.post_doc if disp.post_doc else fmt_flag_post_doc(flag) 299 | if post_doc: 300 | _parts.append(post_doc) 301 | doc = ' '.join(_parts) 302 | 303 | flag_lines = _wrap_stout_pair(indent=ctx['section_indent'], 304 | label=fmt_flag_label(flag), 305 | sep=ctx['doc_separator'], 306 | doc=doc, 307 | doc_start=flag_layout['doc_start'], 308 | max_doc_width=flag_layout['doc_width']) 309 | 310 | ret.extend(flag_lines) 311 | 312 | return ctx['pre_doc'] + '\n'.join(ret) + ctx['post_doc'] 313 | 314 | def get_usage_line(self, parser, subcmds=(), program_name=None): 315 | """Get just the top line of automated text output. Arguments are the 316 | same as :meth:`get_help_text()`. Basic info about running the 317 | command, such as: 318 | 319 | Usage: example.py subcommand [FLAGS] [args ...] 320 | 321 | """ 322 | ctx = self.ctx 323 | subcmds = tuple(subcmds or ()) 324 | parts = [ctx['usage_label']] if ctx['usage_label'] else [] 325 | append = parts.append 326 | 327 | program_name = program_name or parser.name 328 | 329 | append(' '.join((program_name,) + subcmds)) 330 | 331 | # TODO: put () in subprs_map to handle some of this sorta thing 332 | if not subcmds and parser.subprs_map: 333 | append('subcommand') 334 | elif subcmds and parser.subprs_map[subcmds].subprs_map: 335 | append('subcommand') 336 | 337 | # with subcommands out of the way, look up the parser for flags and args 338 | if subcmds: 339 | parser = parser.subprs_map[subcmds] 340 | 341 | flags = parser.get_flags(with_hidden=False) 342 | 343 | if flags: 344 | append('[FLAGS]') 345 | 346 | if not parser.posargs.display.hidden: 347 | fmt_posargs_label = ctx['format_posargs_label'] 348 | append(fmt_posargs_label(parser.posargs)) 349 | 350 | return ' '.join(parts) 351 | 352 | 353 | 354 | ''' 355 | class AiryHelpFormatter(object): 356 | """No wrapping a doc onto the same line as the label. Just left 357 | aligned labels + newline, then right align doc. No complicated 358 | width calculations either. See https://github.com/kbknapp/clap-rs 359 | """ 360 | pass # TBI 361 | ''' 362 | 363 | 364 | class HelpHandler: 365 | """The HelpHandler is a one-stop object for that all-important CLI 366 | feature: automatic help generation. It ties together the actual 367 | help handler with the optional flag and subcommand such that it 368 | can be added to any :class:`Command` instance. 369 | 370 | The :class:`Command` creates a HelpHandler instance by default, 371 | and its constructor also accepts an instance of this type to 372 | customize a variety of helpful features. 373 | 374 | Args: 375 | flag (face.Flag): The Flag instance to use for triggering a 376 | help output in a Command setting. Defaults to the standard 377 | ``--help / -h`` flag. Pass ``False`` to disable. 378 | subcmd (str): A subcommand name to be added to any 379 | :class:`Command` using this HelpHandler. Defaults to 380 | ``None``. 381 | formatter: A help formatter instance or type. Type will be 382 | instantiated with keyword arguments passed to this 383 | constructor. Defaults to :class:`StoutHelpFormatter`. 384 | func (callable): The actual handler function called on flag 385 | presence or subcommand invocation. Defaults to 386 | :meth:`HelpHandler.default_help_func()`. 387 | 388 | All other remaining keyword arguments are used to construct the 389 | HelpFormatter, if *formatter* is a type (as is the default). For 390 | an example of a formatter, see :class:`StoutHelpFormatter`, the 391 | default help formatter. 392 | """ 393 | # Other hooks (besides the help function itself): 394 | # * Callbacks for unhandled exceptions 395 | # * Callbacks for formatting errors (add a "see --help for more options") 396 | 397 | def __init__(self, flag=DEFAULT_HELP_FLAG, subcmd=None, 398 | formatter=StoutHelpFormatter, func=None, **formatter_kwargs): 399 | # subcmd expects a string 400 | self.flag = flag 401 | self.subcmd = subcmd 402 | self.func = func if func is not None else self.default_help_func 403 | if not callable(self.func): 404 | raise TypeError(f'expected help handler func to be callable, not {func!r}') 405 | 406 | self.formatter = formatter 407 | if not formatter: 408 | raise TypeError(f'expected Formatter type or instance, not: {formatter!r}') 409 | if isinstance(formatter, type): 410 | self.formatter = formatter(**formatter_kwargs) 411 | elif formatter_kwargs: 412 | raise TypeError('only accepts extra formatter kwargs (%r) if' 413 | ' formatter argument is a Formatter type, not: %r' 414 | % (sorted(formatter_kwargs.keys()), formatter)) 415 | _has_get_help_text = callable(getattr(self.formatter, 'get_help_text', None)) 416 | if not _has_get_help_text: 417 | raise TypeError('expected valid formatter, or other object with a' 418 | ' get_help_text() method, not %r' % (self.formatter,)) 419 | return 420 | 421 | def default_help_func(self, cmd_, subcmds_, args_, command_): 422 | """The default help handler function. Called when either the help flag 423 | or subcommand is passed. 424 | 425 | Prints the output of the help formatter instance attached to 426 | this HelpHandler and exits with exit code 0. 427 | 428 | """ 429 | echo(self.formatter.get_help_text(command_, subcmds=subcmds_, program_name=cmd_)) 430 | 431 | 432 | """Usage: cmd_name sub_cmd [..as many subcommands as the max] --flags args ... 433 | 434 | Possible commands: 435 | 436 | (One of the possible styles below) 437 | 438 | Flags: 439 | Group name (if grouped): 440 | -F, --flag VALUE Help text goes here. (integer, defaults to 3) 441 | 442 | Flag help notes: 443 | 444 | * don't display parenthetical if it's string/None 445 | * Also need to indicate required and mutual exclusion ("not with") 446 | * Maybe experimental / deprecated support 447 | * General flag listing should also include flags up the chain 448 | 449 | Subcommand listing styles: 450 | 451 | * Grouped, one-deep, flag overview on each 452 | * One-deep, grouped or alphabetical, help string next to each 453 | * Grouped by tree (new group whenever a subtree of more than one 454 | member finishes), with help next to each. 455 | 456 | What about extra lines in the help (like zfs) (maybe each individual 457 | line can be a template string?) 458 | 459 | TODO: does face need built-in support for version subcommand/flag, 460 | basically identical to help? 461 | 462 | Group names can be ints or strings. When, group names are strings, 463 | flags are indented under a heading consisting of the string followed 464 | by a colon. All ungrouped flags go under a 'General Flags' group 465 | name. When group names are ints, groups are not indented, but a 466 | newline is still emitted by each group. 467 | 468 | Alphabetize should be an option, otherwise everything stays in 469 | insertion order. 470 | 471 | Subcommands without handlers should not be displayed in help. Also, 472 | their implicit handler prints the help. 473 | 474 | Subcommand groups could be Commands with name='', and they can only be 475 | added to other commands, where they would embed as siblings instead of 476 | as subcommands. Similar to how clastic subapplications can be mounted 477 | without necessarily adding to the path. 478 | 479 | Is it better to delegate representations out or keep them all within 480 | the help builder? 481 | 482 | --- 483 | 484 | Help needs: a flag (and a way to disable it), as well as a renderer. 485 | 486 | Usage: 487 | 488 | Doc 489 | 490 | Subcommands: 491 | 492 | ... ... 493 | 494 | Flags: 495 | 496 | ... 497 | 498 | Postdoc 499 | 500 | 501 | {usage_label} {cmd_name} {subcmd_path} {subcmd_blank} {flags_blank} {posargs_label} 502 | 503 | {cmd.doc} 504 | 505 | {subcmd_heading} 506 | 507 | {subcmd.name} {subcmd.doc} {subcmd.post_doc} 508 | 509 | {flags_heading} 510 | 511 | {group_name}: 512 | 513 | {flag_label} {flag.doc} {flag.post_doc} 514 | 515 | {cmd.post_doc} 516 | 517 | 518 | -------- 519 | 520 | # Grouping 521 | 522 | Effectively sorted on: (group_name, group_index, sort_order, label) 523 | 524 | But group names should be based on insertion order, with the 525 | default-grouped/ungrouped items showing up in the last group. 526 | 527 | # Wrapping / Alignment 528 | 529 | Docs start at the position after the longest "left-hand side" 530 | (LHS/"key") item that would not cause the first line of the docs to be 531 | narrower than the minimum doc width. 532 | 533 | LHSes which do extend beyond this point will be on their own line, 534 | with the doc starting on the line below. 535 | 536 | # Window width considerations 537 | 538 | With better termios-based logic in place to get window size, there are 539 | going to be a lot of wider-than-80-char help messages. 540 | 541 | The goal of help message alignment is to help eyes track across from a 542 | flag or subcommand to its corresponding doc. Rather than maximizing 543 | width usage or topping out at a max width limit, we should be 544 | balancing or at least limiting the amount of whitespace between the 545 | shortest flag and its doc. (TODO) 546 | 547 | A width limit might still make sense because reading all the way 548 | across the screen can be tiresome, too. 549 | 550 | TODO: padding_top and padding_bottom attributes on various displays 551 | (esp FlagDisplay) to enable finer grained whitespace control without 552 | complicated group setups. 553 | 554 | """ 555 | -------------------------------------------------------------------------------- /face/middleware.py: -------------------------------------------------------------------------------- 1 | """Face Middleware 2 | =============== 3 | 4 | When using Face's Command framework, Face takes over handling dispatch 5 | of commands and subcommands. A particular command line string is 6 | routed to the configured function, in much the same way that popular 7 | web frameworks route requests based on path. 8 | 9 | In more advanced programs, this basic control flow can be enhanced by 10 | adding middleware. Middlewares comprise a stack of functions, each 11 | which calls the next, until finally calling the appropriate 12 | command-handling function. Middlewares are added to the command, with 13 | the outermost middleware being added first. Remember: first added, 14 | first called. 15 | 16 | Middlewares are a great way to handle general setup and logic which is 17 | common across many subcommands, such as verbosity, logging, and 18 | formatting. Middlewares can also be used to perform additional 19 | argument validation, and terminate programs early. 20 | 21 | The interface of middlewares retains the same injection ability of 22 | Command handler functions. Flags and builtins are automatically 23 | provided. In addition to having its arguments checked against those 24 | available injectables, a middleware _must_ take a ``next_`` parameter 25 | as its first argument. Then, much like a decorator, that ``next_`` 26 | function must be invoked to continue to program execution:: 27 | 28 | 29 | import time 30 | 31 | from face import face_middleware, echo 32 | 33 | @face_middleware 34 | def timing_middleware(next_): 35 | start_time = time.time() 36 | ret = next_() 37 | echo('command executed in:', time.time() - start_time, 'seconds') 38 | return ret 39 | 40 | As always, code speaks volumes. It's worth noting that ``next_()`` is 41 | a normal function. If you return without calling it, your command's 42 | handler function will not be called, nor will any other downstream 43 | middleware. Another corollary is that this makes it easy to use 44 | ``try``/``except`` to build error handling. 45 | 46 | While already practical, there are two significant ways it can be 47 | enhanced. The first would be to provide downstream handlers access to 48 | the ``start_time`` value. The second would be to make the echo 49 | functionality optional. 50 | 51 | Providing values from middleware 52 | -------------------------------- 53 | 54 | As mentioned above, the first version of our timing middleware works, 55 | but what if one or more of our handler functions needs to perform a 56 | calculation based on ``start_time``? 57 | 58 | Common code is easily folded away by middleware, and we can do so here 59 | by making the start_time available as an injectable:: 60 | 61 | import time 62 | 63 | from face import face_middleware, echo 64 | 65 | @face_middleware(provides=['start_time']) 66 | def timing_middleware(next_): 67 | start_time = time.time() 68 | ret = next_(start_time=start_time) 69 | echo('command executed in:', time.time() - start_time, 'seconds') 70 | return ret 71 | 72 | ``start_time`` is added to the list of provides in the middleware 73 | decoration, and ``next_()`` is simply invoked with a ``start_time`` 74 | keyword argument. Any command handler function that takes a 75 | ``start_time`` keyword argument will automatically pick up the value. 76 | 77 | That's all well and fine, but what if we don't always want to know the 78 | duration of the command? Whose responsibility is it to expose that 79 | optional behavior? Lucky for us, middlewares can take care of themselves. 80 | 81 | Adding flags to middleware 82 | -------------------------- 83 | 84 | Right now our middleware changes command output every time it is 85 | run. While that's pretty handy behavior, the command line is all about 86 | options. 87 | 88 | We can make our middleware even more reusable by adding self-contained 89 | optional behavior, via a flag:: 90 | 91 | import time 92 | 93 | from face import face_middleware, Flag, echo 94 | 95 | @face_middleware(provides=['start_time'], flags=[Flag('--echo-time', parse_as=True)]) 96 | def timing_middleware(next_, echo_time): 97 | start_time = time.time() 98 | ret = next_(start_time=start_time) 99 | if echo_time: 100 | echo('command executed in:', time.time() - start_time, 'seconds') 101 | return ret 102 | 103 | Now, every :class:`Command` that adds this middleware will 104 | automatically get a flag, ``--echo-time``. Just like other flags, its 105 | value will be injected into commands that need it. 106 | 107 | .. note:: **Weak Dependencies** - Middlewares that set defaults for 108 | keyword arguments are said to have a "weak" dependency on 109 | the associated injectable. If the command handler function, 110 | or another downstream middleware, do not accept the 111 | argument, the flag will not be parsed, or shown in generated 112 | help and error messages. This differs from the command 113 | handler function itself, which will accept arguments even 114 | when the function signature sets a default. 115 | 116 | Wrapping up 117 | ----------- 118 | 119 | I'd like to say that we were only scratching the surface of 120 | middlewares, but really there's not much more to them. They are an 121 | advanced feature of face, and a very powerful organizing tool for your 122 | code, but like many powerful tools, they are simple. You can use them 123 | in a wide variety of ways. Other useful middleware ideas: 124 | 125 | * Verbosity middleware - provides a ``verbose`` flag for downstream 126 | commands which can write additional output. 127 | * Logging middleware - sets up and provides an associated logger 128 | object for downstream commands. 129 | * Pipe middleware - Many CLIs are made for streaming. There are some 130 | semantics a middleware can help with, like breaking pipes. 131 | * KeyboardInterrupt middleware - Ctrl-C is a common way to exit 132 | programs, but Python generally spits out an ugly stack trace, even 133 | where a keyboard interrupt may have been valid. 134 | * Authentication middleware - provides an AuthenticatedUser object 135 | after checking environment variables and prompting for a username 136 | and password. 137 | * Debugging middleware - Because face middlewares are functions in a 138 | normal Python stack, it's easy to wrap downstream calls in a 139 | ``try``/``except``, and add a flag (or environment variable) that 140 | enables a ``pdb.post_mortem()`` to drop you into a debug console. 141 | 142 | The possibilities never end. If you build a middleware of particularly 143 | broad usefulness, consider contributing it back to the core! 144 | 145 | """ 146 | 147 | 148 | from face.parser import Flag 149 | from face.sinter import make_chain, get_arg_names, get_fb, get_callable_labels 150 | from face.sinter import inject # transitive import for external use 151 | from typing import Callable, List, Optional, Union 152 | 153 | INNER_NAME = 'next_' 154 | 155 | _BUILTIN_PROVIDES = [INNER_NAME, 'args_', 'cmd_', 'subcmds_', 156 | 'flags_', 'posargs_', 'post_posargs_', 157 | 'command_', 'subcommand_'] 158 | 159 | 160 | def is_middleware(target): 161 | """Mostly for internal use, this function returns True if *target* is 162 | a valid face middleware. 163 | 164 | Middlewares can be functions wrapped with the 165 | :func:`face_middleware` decorator, or instances of a user-created 166 | type, as long as it's a callable following face's signature 167 | convention and has the ``is_face_middleware`` attribute set to 168 | True. 169 | """ 170 | if callable(target) and getattr(target, 'is_face_middleware', None): 171 | return True 172 | return False 173 | 174 | 175 | def face_middleware(func: Optional[Callable] = None, 176 | *, 177 | provides: Union[List[str], str] = [], 178 | flags: List[Flag] = [], 179 | optional: bool = False) -> Callable: 180 | """A decorator to mark a function as face middleware, which wraps 181 | execution of a subcommand handler function. This decorator can be 182 | called with or without arguments: 183 | 184 | Args: 185 | provides: An optional list of names, declaring which 186 | values be provided by this middleware at execution time. 187 | flags: An optional list of Flag instances, which will be 188 | automatically added to any Command which adds this middleware. 189 | optional: Whether this middleware should be skipped if its 190 | provides are not required by the command. 191 | 192 | The first argument of the decorated function must be named 193 | "next_". This argument is a function, representing the next 194 | function in the execution chain, the last of which is the 195 | command's handler function. 196 | 197 | Returns: 198 | A decorator function that marks the decorated function as middleware. 199 | """ 200 | if isinstance(provides, str): 201 | provides = [provides] 202 | flags = list(flags) 203 | if flags: 204 | for flag in flags: 205 | if not isinstance(flag, Flag): 206 | raise TypeError(f'expected Flag object, not: {flag!r}') 207 | 208 | def decorate_face_middleware(func): 209 | check_middleware(func, provides=provides) 210 | func.is_face_middleware = True 211 | func._face_flags = list(flags) 212 | func._face_provides = list(provides) 213 | func._face_optional = optional 214 | return func 215 | 216 | if func and callable(func): 217 | return decorate_face_middleware(func) 218 | 219 | return decorate_face_middleware 220 | 221 | 222 | def get_middleware_chain(middlewares, innermost, preprovided): 223 | """Perform basic validation of innermost function, wrap it in 224 | middlewares, and raise a :exc:`NameError` on any unresolved 225 | arguments. 226 | 227 | Args: 228 | middlewares (list): A list of middleware functions, prechecked 229 | by :func:`check_middleware`. 230 | innermost (callable): A function to be called after all the 231 | middlewares. 232 | preprovided (list): A list of built-in or otherwise preprovided 233 | injectables. 234 | 235 | Returns: 236 | A single function representing the whole middleware chain. 237 | 238 | This function is called automatically by :meth:`Command.prepare()` 239 | (and thus, :meth:`Command.run()`), and is more or less for 240 | internal use. 241 | """ 242 | _inner_exc_msg = "argument %r reserved for middleware use only (%r)" 243 | if INNER_NAME in get_arg_names(innermost): 244 | raise NameError(_inner_exc_msg % (INNER_NAME, innermost)) 245 | 246 | mw_builtins = set(preprovided) - {INNER_NAME} 247 | mw_provides = [list(mw._face_provides) for mw in middlewares] 248 | 249 | mw_chain, mw_chain_args, mw_unres = make_chain(middlewares, mw_provides, innermost, mw_builtins, INNER_NAME) 250 | 251 | if mw_unres: 252 | msg = f"unresolved middleware or handler arguments: {sorted(mw_unres)!r}" 253 | avail_unres = mw_unres & (mw_builtins | set(sum(mw_provides, []))) 254 | if avail_unres: 255 | msg += (' (%r provided but not resolvable, check middleware order.)' 256 | % sorted(avail_unres)) 257 | raise NameError(msg) 258 | return mw_chain 259 | 260 | 261 | def check_middleware(func, provides=None): 262 | """Check that a middleware callable adheres to function signature 263 | requirements. Called automatically by 264 | :class:`Command.add_middleware()` and elsewhere, this function 265 | raises :exc:`TypeError` if any issues are found. 266 | """ 267 | if not callable(func): 268 | raise TypeError(f'expected middleware {func!r} to be a function') 269 | fb = get_fb(func) 270 | # TODO: this currently gives __main__abc instead of __main__.abc 271 | func_label = ''.join(get_callable_labels(func)) 272 | arg_names = fb.args 273 | if not arg_names: 274 | raise TypeError('middleware function %r must take at least one' 275 | ' argument "%s" as its first parameter' 276 | % (func_label, INNER_NAME)) 277 | if arg_names[0] != INNER_NAME: 278 | raise TypeError('middleware function %r must take argument' 279 | ' "%s" as the first parameter, not "%s"' 280 | % (func_label, INNER_NAME, arg_names[0])) 281 | if fb.varargs: 282 | raise TypeError('middleware function %r may only take explicitly' 283 | ' named arguments, not "*%s"' % (func_label, fb.varargs)) 284 | if fb.varkw: 285 | raise TypeError('middleware function %r may only take explicitly' 286 | ' named arguments, not "**%s"' % (func_label, fb.varkw)) 287 | 288 | provides = provides if provides is not None else func._face_provides 289 | conflict_args = list(set(_BUILTIN_PROVIDES) & set(provides)) 290 | if conflict_args: 291 | raise TypeError('middleware function %r provides conflict with' 292 | ' reserved face builtins: %r' % (func_label, conflict_args)) 293 | 294 | return 295 | -------------------------------------------------------------------------------- /face/sinter.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import types 3 | import inspect 4 | import hashlib 5 | import linecache 6 | 7 | from boltons import iterutils 8 | from boltons.strutils import camel2under 9 | from boltons.funcutils import FunctionBuilder 10 | 11 | 12 | _VERBOSE = False 13 | _INDENT = ' ' 14 | 15 | 16 | def get_fb(f, drop_self=True): 17 | # TODO: support partials 18 | if not (inspect.isfunction(f) or inspect.ismethod(f) or \ 19 | inspect.isbuiltin(f)) and hasattr(f, '__call__'): 20 | if isinstance(getattr(f, '_sinter_fb', None), FunctionBuilder): 21 | return f._sinter_fb 22 | f = f.__call__ # callable objects 23 | 24 | if isinstance(getattr(f, '_sinter_fb', None), FunctionBuilder): 25 | return f._sinter_fb # we'll take your word for it; good luck, lil buddy. 26 | 27 | ret = FunctionBuilder.from_func(f) 28 | 29 | if not all([isinstance(a, str) for a in ret.args]): # pragma: no cover (2 only) 30 | raise TypeError('does not support anonymous tuple arguments' 31 | ' or any other strange args for that matter.') 32 | if drop_self and isinstance(f, types.MethodType): 33 | ret.args = ret.args[1:] # discard "self" on methods 34 | return ret 35 | 36 | 37 | def get_arg_names(f, only_required=False): 38 | fb = get_fb(f) 39 | 40 | return fb.get_arg_names(only_required=only_required) 41 | 42 | 43 | def inject(f, injectables): 44 | __traceback_hide__ = True # TODO 45 | 46 | fb = get_fb(f) 47 | 48 | all_kwargs = fb.get_defaults_dict() 49 | all_kwargs.update(injectables) 50 | 51 | if fb.varkw: 52 | return f(**all_kwargs) 53 | 54 | kwargs = {k: v for k, v in all_kwargs.items() if k in fb.get_arg_names()} 55 | return f(**kwargs) 56 | 57 | 58 | def get_callable_labels(obj): 59 | ctx_parts = [] 60 | if isinstance(obj, types.MethodType): 61 | # bit of 2/3 messiness below 62 | im_self = getattr(obj, 'im_self', getattr(obj, '__self__', None)) 63 | if im_self: 64 | ctx_parts.append(im_self.__class__.__name__) 65 | obj = getattr(obj, 'im_func', getattr(obj, '__func__', None)) 66 | 67 | fb = get_fb(obj) 68 | if fb.module: 69 | ctx_parts.insert(0, fb.module) 70 | 71 | 72 | return '.'.join(ctx_parts), fb.name, fb.get_invocation_str() 73 | 74 | 75 | 76 | # TODO: turn the following into an object (keeps inner_name easier to 77 | # track, as well as better handling of state the func_aliaser will 78 | # need 79 | 80 | def chain_argspec(func_list, provides, inner_name): 81 | provided_sofar = {inner_name} # the inner function name is an extremely special case 82 | optional_sofar = set() 83 | required_sofar = set() 84 | for f, p in zip(func_list, provides): 85 | # middlewares can default the same parameter to different values; 86 | # can't properly keep track of default values 87 | fb = get_fb(f) 88 | arg_names = fb.get_arg_names() 89 | defaults_dict = fb.get_defaults_dict() 90 | 91 | defaulted, undefaulted = iterutils.partition(arg_names, key=defaults_dict.__contains__) 92 | 93 | optional_sofar.update(defaulted) 94 | # keep track of defaults so that e.g. endpoint default param 95 | # can pick up request injected/provided param 96 | required_sofar |= set(undefaulted) - provided_sofar 97 | provided_sofar.update(p) 98 | 99 | return required_sofar, optional_sofar 100 | 101 | 102 | #funcs[0] = function to call 103 | #params[0] = parameters to take 104 | def build_chain_str(funcs, params, inner_name, params_sofar=None, level=0, 105 | func_aliaser=None, func_names=None): 106 | if not funcs: 107 | return '' # stopping case 108 | if params_sofar is None: 109 | params_sofar = {inner_name} 110 | 111 | params_sofar.update(params[0]) 112 | inner_args = get_fb(funcs[0]).args 113 | inner_arg_dict = {a: a for a in inner_args} 114 | inner_arg_items = sorted(inner_arg_dict.items()) 115 | inner_args = ', '.join(['%s=%s' % kv for kv in inner_arg_items 116 | if kv[0] in params_sofar]) 117 | outer_indent = _INDENT * level 118 | inner_indent = outer_indent + _INDENT 119 | outer_arg_str = ', '.join(params[0]) 120 | def_str = f'{outer_indent}def {inner_name}({outer_arg_str}):\n' 121 | body_str = build_chain_str(funcs[1:], params[1:], inner_name, params_sofar, level + 1) 122 | #func_name = get_func_name(funcs[0]) 123 | #func_alias = get_inner_func_alias(funcs[0]) 124 | htb_str = f'{inner_indent}__traceback_hide__ = True\n' 125 | return_str = f'{inner_indent}return funcs[{level}]({inner_args})\n' 126 | return ''.join([def_str, body_str, htb_str + return_str]) 127 | 128 | 129 | def compile_chain(funcs, params, inner_name, verbose=_VERBOSE): 130 | call_str = build_chain_str(funcs, params, inner_name) 131 | return compile_code(call_str, inner_name, {'funcs': funcs}, verbose=verbose) 132 | 133 | 134 | def compile_code(code_str, name, env=None, verbose=_VERBOSE): 135 | env = {} if env is None else env 136 | code_hash = hashlib.sha1(code_str.encode('utf8')).hexdigest()[:16] 137 | unique_filename = f"<sinter generated {name} {code_hash}>" 138 | code = compile(code_str, unique_filename, 'single') 139 | if verbose: 140 | print(code_str) # pragma: no cover 141 | 142 | exec(code, env) 143 | 144 | linecache.cache[unique_filename] = ( 145 | len(code_str), 146 | None, 147 | code_str.splitlines(True), 148 | unique_filename, 149 | ) 150 | return env[name] 151 | 152 | 153 | def make_chain(funcs, provides, final_func, preprovided, inner_name): 154 | funcs = list(funcs) 155 | provides = list(provides) 156 | preprovided = set(preprovided) 157 | reqs, opts = chain_argspec(funcs + [final_func], 158 | provides + [()], inner_name) 159 | 160 | unresolved = tuple(reqs - preprovided) 161 | args = reqs | (preprovided & opts) 162 | chain = compile_chain(funcs + [final_func], 163 | [args] + provides, inner_name) 164 | return chain, set(args), set(unresolved) 165 | -------------------------------------------------------------------------------- /face/test/_search_cmd_a.flags: -------------------------------------------------------------------------------- 1 | -g * 2 | # comment 3 | --verbose 4 | -------------------------------------------------------------------------------- /face/test/test_basic.py: -------------------------------------------------------------------------------- 1 | from random import shuffle 2 | 3 | import pytest 4 | 5 | from face import (Command, Flag, ERROR, FlagDisplay, PosArgSpec, 6 | PosArgDisplay, ChoicesParam, CommandLineError, 7 | ArgumentParseError, echo, prompt, CommandChecker) 8 | from face.utils import format_flag_label, identifier_to_flag, get_minimal_executable 9 | 10 | def test_cmd_name(): 11 | 12 | def handler(): 13 | return 0 14 | 15 | Command(handler, name='ok_cmd') 16 | 17 | name_err_map = {'': 'non-zero length string', 18 | 5: 'non-zero length string', 19 | 'name_': 'without trailing dashes or underscores', 20 | 'name--': 'without trailing dashes or underscores', 21 | 'n?me': ('valid subcommand name must begin with a letter, and' 22 | ' consist only of letters, digits, underscores, and' 23 | ' dashes')} 24 | 25 | for name, err in name_err_map.items(): 26 | with pytest.raises(ValueError, match=err): 27 | Command(handler, name=name) 28 | 29 | return 30 | 31 | 32 | def test_flag_name(): 33 | flag = Flag('ok_name') 34 | assert repr(flag).startswith('<Flag name=') 35 | 36 | assert format_flag_label(flag) == '--ok-name OK_NAME' 37 | assert format_flag_label(Flag('name', display={'label': '--nAmE'})) == '--nAmE' 38 | assert format_flag_label(Flag('name', display='--nAmE')) == '--nAmE' 39 | 40 | assert Flag('name', display='').display.hidden == True 41 | assert repr(Flag('name', display='').display).startswith('<FlagDisplay') 42 | 43 | with pytest.raises(TypeError, match='or FlagDisplay instance'): 44 | Flag('name', display=object()) 45 | 46 | with pytest.raises(ValueError, match='expected identifier.*'): 47 | assert identifier_to_flag('--flag') 48 | 49 | name_err_map = {'': 'non-zero length string', 50 | 5: 'non-zero length string', 51 | 'name_': 'without trailing dashes or underscores', 52 | 'name--': 'without trailing dashes or underscores', 53 | 'n?me': ('must begin with a letter.*and' 54 | ' consist only of letters, digits, underscores, and' 55 | ' dashes'), 56 | 'for': 'valid flag names must not be Python keywords'} 57 | 58 | for name, err in name_err_map.items(): 59 | with pytest.raises(ValueError, match=err): 60 | Flag(name=name) 61 | return 62 | 63 | 64 | def test_flag_char(): 65 | with pytest.raises(ValueError, match='char flags must be exactly one character'): 66 | Flag('flag', char='FLAG') 67 | with pytest.raises(ValueError, match='expected valid flag character.*ASCII letters, numbers.*'): 68 | Flag('flag', char='é') 69 | 70 | assert Flag('flag', char='-f').char == 'f' 71 | 72 | 73 | def test_flag_hidden(): 74 | # TODO: is display='' sufficient for hiding (do we need hidden=True) 75 | cmd = Command(lambda tiger, dragon: None, 'cmd') 76 | cmd.add('--tiger', display='') 77 | flags = cmd.get_flags(with_hidden=False) 78 | assert 'tiger' not in [f.name for f in flags] 79 | 80 | cmd.add('--dragon', display={'label': ''}) 81 | flags = cmd.get_flags(with_hidden=False) 82 | assert 'dragon' not in [f.name for f in flags] 83 | 84 | 85 | def test_flag_init(): 86 | cmd = Command(lambda flag, part: None, name='cmd') 87 | 88 | with pytest.raises(ValueError, match='cannot make an argument-less flag required'): 89 | cmd.add('--flag', missing=ERROR, parse_as=True) 90 | 91 | # test custom callable multi 92 | cmd.add(Flag('--part', multi=lambda flag, vals: ''.join(vals))) 93 | res = cmd.parse(['cmd', '--part', 'a', '--part', 'b']) 94 | assert res.flags['part'] == 'ab' 95 | 96 | with pytest.raises(ValueError, match='multi expected callable, bool, or one of.*'): 97 | cmd.add('--badflag', multi='nope') 98 | 99 | 100 | def test_char_missing_error(): 101 | # testing required flags 102 | cmd = Command(lambda req_flag: None, name='cmd') 103 | cmd.add('--req-flag', char='-R', missing=ERROR) 104 | res = cmd.parse(['cmd', '--req-flag', 'val']) 105 | assert res.flags['req_flag'] == 'val' 106 | 107 | res = cmd.parse(['cmd', '-R', 'val']) 108 | assert res.flags['req_flag'] == 'val' 109 | 110 | with pytest.raises(ArgumentParseError, match='--req-flag'): 111 | cmd.parse(['cmd']) 112 | 113 | return 114 | 115 | def test_minimal_exe(): 116 | venv_exe_path = '/home/mahmoud/virtualenvs/face/bin/python' 117 | res = get_minimal_executable(venv_exe_path, 118 | environ={'PATH': ('/home/mahmoud/virtualenvs/face/bin' 119 | ':/home/mahmoud/bin:/usr/local/sbin' 120 | ':/usr/local/bin:/usr/sbin' 121 | ':/usr/bin:/sbin:/bin:/snap/bin')}) 122 | assert res == 'python' 123 | 124 | res = get_minimal_executable(venv_exe_path, 125 | environ={'PATH': ('/home/mahmoud/bin:/usr/local/sbin' 126 | ':/usr/local/bin:/usr/sbin' 127 | ':/usr/bin:/sbin:/bin:/snap/bin')}) 128 | assert res == venv_exe_path 129 | 130 | # TODO: where is PATH not a string? 131 | res = get_minimal_executable(venv_exe_path, environ={'PATH': []}) 132 | assert res == venv_exe_path 133 | 134 | 135 | def test_posargspec_init(): 136 | with pytest.raises(TypeError, match='expected callable or ERROR'): 137 | PosArgSpec(parse_as=object()) 138 | 139 | with pytest.raises(ValueError, match='expected min_count >= 0'): 140 | PosArgSpec(min_count=-1) 141 | 142 | with pytest.raises(ValueError, match='expected max_count > 0'): 143 | PosArgSpec(max_count=-1) 144 | 145 | with pytest.raises(ValueError, match='expected min_count > max_count'): 146 | PosArgSpec(max_count=3, min_count=4) 147 | 148 | with pytest.raises(TypeError, match='.*PosArgDisplay instance.*'): 149 | PosArgSpec(display=object()) 150 | with pytest.raises(TypeError, match='unexpected keyword'): 151 | PosArgSpec(display={'badkw': 'val'}) 152 | 153 | # cmd = Command(lambda posargs_: posargs_, posargs=PosArgSpec(display=False)) 154 | assert PosArgSpec(display=False).display.hidden == True 155 | assert PosArgSpec(display=PosArgDisplay(name='posargs')) 156 | 157 | cmd = Command(lambda: None, name='cmd', posargs=1) 158 | assert cmd.posargs.min_count == 1 159 | assert cmd.posargs.max_count == 1 160 | 161 | cmd = Command(lambda targs: None, name='cmd', posargs='targs') 162 | assert cmd.posargs.display.name == 'targs' 163 | assert cmd.posargs.provides == 'targs' 164 | 165 | cmd = Command(lambda targs: None, name='cmd', posargs=int) 166 | assert cmd.posargs.parse_as == int 167 | 168 | with pytest.raises(TypeError, match='.*instance of PosArgSpec.*'): 169 | Command(lambda targs: None, name='cmd', posargs=object()) 170 | 171 | return 172 | 173 | 174 | def test_bad_posargspec(): 175 | # issue #11 176 | assert PosArgSpec(name=None).display.name is not None 177 | assert PosArgDisplay(name=None).name is not None 178 | 179 | posargs_args = [ 180 | {'name': None}, 181 | {'provides': 'x'}, 182 | {'display': {'doc': 'wee'}}, 183 | {'display': {'name': 'better_name'}} 184 | ] 185 | 186 | for arg in posargs_args: 187 | cmd = Command(lambda targs: None, name='cmd', posargs=arg) 188 | cmd_chk = CommandChecker(cmd, mix_stderr=True) 189 | res = cmd_chk.run(['cmd', '-h']) 190 | assert res.stdout.startswith('Usage') 191 | 192 | return 193 | 194 | 195 | def test_bad_subprs(): 196 | with pytest.raises(ValueError, 197 | match='commands accepting positional arguments cannot take subcommands'): 198 | posarg_cmd = Command(lambda: None, 'pa', posargs=True) 199 | posarg_cmd.add(lambda: None, 'bad_subcmd') 200 | 201 | cmd = Command(lambda: None, 'base') 202 | cmd.add(lambda: None, 'twin') 203 | with pytest.raises(ValueError, match='conflicting subcommand name'): 204 | cmd.add(lambda: None, 'twin') 205 | 206 | with pytest.raises(TypeError, match='expected Command instance'): 207 | cmd.add_command(object()) 208 | 209 | 210 | def test_choices_init(): 211 | with pytest.raises(ValueError, match='expected at least one'): 212 | ChoicesParam(choices=[]) 213 | 214 | class Unsortable: 215 | def __gt__(self, other): 216 | raise TypeError() 217 | __cmp__ = __lt__ = __gt__ 218 | 219 | choices = [Unsortable() for _ in range(100)] 220 | choices_param = ChoicesParam(choices=choices) 221 | assert choices == choices_param.choices 222 | assert choices is not choices_param.choices 223 | 224 | 225 | def test_echo(capsys): 226 | test_str = 'tést' 227 | echo(test_str) 228 | echo.err(test_str.upper()) 229 | captured = capsys.readouterr() 230 | assert captured.out == test_str + '\n' 231 | assert captured.err == test_str.upper() + '\n' 232 | 233 | echo(test_str, end='\n\n') 234 | assert capsys.readouterr().out == test_str + '\n\n' 235 | echo(test_str, nl=False) 236 | assert capsys.readouterr().out == test_str 237 | 238 | 239 | def test_multi_extend(): 240 | cmd = Command(lambda override: None, name='cmd') 241 | cmd.add('--override', char='o', multi=True) 242 | res = cmd.parse(['cmd', '-o', 'x=y', '-o', 'a=b']) 243 | 244 | assert res.flags['override'] == ['x=y', 'a=b'] 245 | 246 | res = cmd.parse(['cmd']) 247 | assert res.flags['override'] == [] 248 | 249 | res = cmd.parse(['cmd', '-o=x']) 250 | assert res.flags['override'] == ['x'] 251 | 252 | 253 | def test_post_posargs(): 254 | cmd = Command(lambda posargs, post_posargs: None, name='cmd') 255 | 256 | res = cmd.parse(['cmd']) 257 | assert res.posargs == () 258 | assert res.post_posargs == None 259 | # TODO: if this ^ isn't a useful signal, it would be more convenient to have the 260 | # behavior be the same as below 261 | 262 | res = cmd.parse(['cmd', '--']) 263 | assert res.posargs == () 264 | assert res.post_posargs == () 265 | -------------------------------------------------------------------------------- /face/test/test_calc_cmd.py: -------------------------------------------------------------------------------- 1 | import os 2 | import getpass 3 | 4 | import pytest 5 | 6 | from face import (Command, 7 | Parser, 8 | PosArgSpec, 9 | ArgumentParseError, 10 | CommandLineError, 11 | CommandChecker, 12 | CheckError, 13 | prompt) 14 | 15 | 16 | def get_calc_cmd(as_parser=False): 17 | cmd = Command(None, 'calc') 18 | 19 | cmd.add(_add_cmd, name='add', posargs={'min_count': 2, 'parse_as': float}) 20 | cmd.add(_add_two_ints, name='add_two_ints', posargs={'count': 2, 'parse_as': int, 'provides': 'ints'}) 21 | cmd.add(_is_odd, name='is_odd', posargs={'count': 1, 'parse_as': int, 'provides': 'target_int'}) 22 | cmd.add(_ask_halve, name='halve', posargs=False) 23 | cmd.add(_ask_blackjack, name='blackjack') 24 | 25 | if as_parser: 26 | cmd.__class__ = Parser 27 | 28 | return cmd 29 | 30 | 31 | def _add_cmd(posargs_): 32 | "add numbers together" 33 | assert posargs_ 34 | ret = sum(posargs_) 35 | print(ret) 36 | return ret 37 | 38 | 39 | def _add_two_ints(ints): 40 | assert ints 41 | ret = sum(ints) 42 | # TODO: stderr 43 | return ret 44 | 45 | 46 | def _ask_halve(): 47 | val = float(prompt('Enter a number: ')) 48 | print() 49 | ret = val / float(os.getenv('CALC_TWO', 2)) 50 | print(ret) 51 | return ret 52 | 53 | 54 | def _is_odd(target_int): 55 | return bool(target_int % 2) 56 | 57 | 58 | def _ask_blackjack(): 59 | bottom = int(prompt.secret('Bottom card: ', confirm=True)) 60 | top = int(prompt('Top card: ')) 61 | total = top + bottom 62 | if total > 21: 63 | res = 'bust' 64 | elif total == 21: 65 | res = 'blackjack!' 66 | else: 67 | res = 'hit (if you feel lucky)' 68 | print(res) 69 | return 70 | 71 | 72 | def test_calc_basic(): 73 | prs = cmd = get_calc_cmd() 74 | 75 | res = prs.parse(['calc', 'add', '1.1', '2.2']) 76 | assert res 77 | 78 | with pytest.raises(ArgumentParseError): 79 | prs.parse(['calc', 'add-two-ints', 'not', 'numbers']) 80 | with pytest.raises(ArgumentParseError): 81 | prs.parse(['calc', 'add-two-ints', '1', '2', '3']) 82 | with pytest.raises(ArgumentParseError): 83 | prs.parse(['calc', 'add-two-ints', '1']) 84 | 85 | res = cmd.run(['calc', 'add-two-ints', '1', '2']) 86 | assert res == 3 87 | 88 | cmd.run(['calc', 'add-two-ints', '-h']) 89 | 90 | with pytest.raises(TypeError): 91 | prs.parse(['calc', 'is-odd', 3]) # fails bc 3 isn't a str 92 | 93 | res = cmd.run(['calc', 'is-odd', '3']) 94 | assert res == True 95 | res = cmd.run(['calc', 'is-odd', '4']) 96 | assert res == False 97 | 98 | 99 | def test_calc_stream(): 100 | cmd = get_calc_cmd() 101 | 102 | tc = CommandChecker(cmd, reraise=True) 103 | 104 | res = tc.run(['calc', 'add', '1', '2']) 105 | 106 | assert res.stdout.strip() == '3.0' 107 | 108 | res = tc.run(['calc', 'halve'], input='30') 109 | assert res.stdout.strip() == 'Enter a number: \n15.0' 110 | 111 | res = tc.run('calc halve', input='4', env={'CALC_TWO': '-2'}) 112 | assert res.stdout.strip() == 'Enter a number: \n-2.0' 113 | assert not res.exception 114 | 115 | with pytest.raises(ZeroDivisionError): 116 | tc.run('calc halve', input='4', env={'CALC_TWO': '0'}) 117 | 118 | return 119 | 120 | 121 | def test_cc_exc(): 122 | cmd = get_calc_cmd() 123 | cc_no_reraise = CommandChecker(cmd) 124 | res = cc_no_reraise.fail('calc halve', input='4', env={'CALC_TWO': '0'}) 125 | assert res.exception 126 | assert res.stdout == 'Enter a number: \n' 127 | 128 | res = cc_no_reraise.fail('calc halve nonexistentarg') 129 | assert type(res.exception) is CommandLineError 130 | 131 | # NB: expect to update these as error messaging improves 132 | assert str(res.exception) == "error: calc halve: unexpected positional arguments: ['nonexistentarg']" 133 | assert res.stderr.startswith("error: calc halve: unexpected positional arguments: ['nonexistentarg']") 134 | assert 'stderr=' in repr(res) 135 | 136 | with pytest.raises(TypeError): 137 | cc_no_reraise.run('calc halve', input=object()) 138 | return 139 | 140 | 141 | def test_cc_mixed(tmpdir): 142 | cmd = get_calc_cmd() 143 | cc_mixed = CommandChecker(cmd, mix_stderr=True) 144 | res = cc_mixed.fail_1('calc halve nonexistentarg', chdir=tmpdir) 145 | assert type(res.exception) is CommandLineError 146 | assert res.stdout.startswith("error: calc halve: unexpected positional arguments: ['nonexistentarg']") 147 | assert repr(res) 148 | 149 | with pytest.raises(ValueError): 150 | res.stderr 151 | 152 | return 153 | 154 | 155 | def test_cc_getpass(): 156 | cmd = get_calc_cmd() 157 | cc = CommandChecker(cmd, mix_stderr=True) 158 | res = cc.run('calc blackjack', input=['20', '20', '1']) 159 | assert res.stdout.endswith('blackjack!\n') 160 | 161 | # check newline-autoadding behavior when getpass is aborted 162 | cc = CommandChecker(cmd) 163 | def _raise_eof(*a, **kw): 164 | raise EOFError() 165 | 166 | real_getpass = getpass.getpass 167 | try: 168 | getpass.getpass = _raise_eof 169 | res = cc.fail('calc blackjack') 170 | finally: 171 | getpass.getpass = real_getpass 172 | assert res.stderr.endswith('\n') 173 | 174 | 175 | def test_cc_edge_cases(): 176 | cmd = get_calc_cmd() 177 | cc = CommandChecker(cmd) 178 | 179 | with pytest.raises(AttributeError): 180 | cc.nonexistentattr 181 | with pytest.raises(AttributeError, match='end in integers'): 182 | cc.fail_x 183 | 184 | with pytest.raises(TypeError, match='Container of ints'): 185 | cc.run('calc blackjack', exit_code=object()) 186 | 187 | # disable automatic checking 188 | res = cc.run('calc blackjack', input=['20', '20', '1'], exit_code=None) 189 | assert res.exit_code == 0 190 | assert res.stderr == 'Bottom card: Retype bottom card: ' 191 | 192 | # CheckError is also an AssertionError 193 | with pytest.raises(AssertionError) as exc_info: 194 | cc.run('calc halve nonexistentarg', input='tldr') 195 | assert exc_info.value.result.stderr.startswith('error: calc halve: unexpected') 196 | 197 | with pytest.raises(CheckError): 198 | cc.fail('calc halve', input='4') 199 | 200 | with pytest.raises(CheckError): 201 | cc.fail('calc halve', input='4', exit_code=(1, 2)) 202 | -------------------------------------------------------------------------------- /face/test/test_help.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from face import (Flag, 4 | ERROR, 5 | Command, 6 | CommandChecker, 7 | Parser, 8 | PosArgSpec, 9 | HelpHandler, 10 | ArgumentParseError, 11 | StoutHelpFormatter) 12 | from face.utils import format_flag_post_doc 13 | 14 | 15 | def get_subcmd_cmd(): 16 | subcmd = Command(None, name='subcmd', doc='the subcmd help') 17 | subcmd.add(_subsubcmd, name='subsubcmd', posargs={'count': 2, 'name': 'posarg_item'}) 18 | cmd = Command(None, 'halp', doc='halp help') 19 | cmd.add(subcmd) 20 | 21 | return cmd 22 | 23 | 24 | def _subsubcmd(): 25 | """the subsubcmd help 26 | 27 | another line 28 | """ 29 | pass 30 | 31 | 32 | @pytest.fixture 33 | def subcmd_cmd(): 34 | return get_subcmd_cmd() 35 | 36 | 37 | @pytest.mark.parametrize( 38 | "argv, contains, exit_code", 39 | [(['halp', '-h'], ['Usage', 'halp help', 'subcmd'], 0), # basic, explicit help 40 | (['halp', '--help'], ['Usage', 'halp help', 'subcmd'], 0), 41 | # explicit help on subcommands 42 | (['halp', 'subcmd', '-h'], ['Usage', 'the subcmd help', 'subsubcmd'], 0), 43 | (['halp', 'subcmd', 'subsubcmd', '-h'], ['Usage', 'the subsubcmd help', 'posarg_item'], 0), 44 | # invalid subcommands 45 | # TODO: the following should also include "nonexistent-subcmd" but instead it lists "nonexistent_subcmd" 46 | (['halp', 'nonexistent-subcmd'], ['error', 'subcmd'], 1), 47 | (['halp', 'subcmd', 'nonexistent-subsubcmd'], ['error', 'subsubcmd'], 1) 48 | ] 49 | ) 50 | def test_help(subcmd_cmd, argv, contains, exit_code, capsys): 51 | if isinstance(contains, str): 52 | contains = [contains] 53 | 54 | try: 55 | subcmd_cmd.run(argv) 56 | except SystemExit as se: 57 | if exit_code is not None: 58 | assert se.code == exit_code 59 | 60 | out, err = capsys.readouterr() 61 | if exit_code == 0: 62 | output = out 63 | else: 64 | output = err 65 | 66 | for cont in contains: 67 | assert cont in output 68 | 69 | return 70 | 71 | 72 | def test_help_subcmd(): 73 | hhandler = HelpHandler(flag=False, subcmd='help') 74 | cmd = Command(None, 'cmd', help=hhandler) 75 | 76 | try: 77 | cmd.run(['cmd', 'help']) 78 | except SystemExit as se: 79 | assert se.code == 0 80 | 81 | with pytest.raises(ValueError, match='requires a handler function or help handler'): 82 | Command(None, help=None) 83 | 84 | 85 | def test_err_subcmd_prog_name(): 86 | cmd = Command(lambda: print("foo"), "foo") 87 | subcmd = Command(lambda: print("bar"), "bar") 88 | subcmd.add(Command(lambda: print("baz"), "baz")) 89 | cmd.add(subcmd) 90 | 91 | cc = CommandChecker(cmd) 92 | res = cc.fail('fred.py bar ba') 93 | assert 'fred.py' in res.stderr 94 | assert 'foo' not in res.stderr 95 | 96 | 97 | def test_stout_help(): 98 | with pytest.raises(TypeError, match='unexpected formatter arguments'): 99 | StoutHelpFormatter(bad_kwarg=True) 100 | 101 | return 102 | 103 | 104 | def test_handler(): 105 | with pytest.raises(TypeError, match='expected help handler func to be callable'): 106 | HelpHandler(func=object()) 107 | 108 | with pytest.raises(TypeError, match='expected Formatter type or instance'): 109 | HelpHandler(formatter=None) 110 | 111 | with pytest.raises(TypeError, match='only accepts extra formatter'): 112 | HelpHandler(usage_label='Fun: ', formatter=StoutHelpFormatter()) 113 | 114 | with pytest.raises(TypeError, match='expected valid formatter'): 115 | HelpHandler(formatter=object()) 116 | 117 | 118 | 119 | # TODO: need to have commands reload their own subcommands if 120 | # we're going to allow adding subcommands to subcommands after 121 | # initial addition to the parent command 122 | 123 | 124 | def test_flag_post_doc(): 125 | assert format_flag_post_doc(Flag('flag')) == '' 126 | assert format_flag_post_doc(Flag('flag', missing=42)) == '(defaults to 42)' 127 | assert format_flag_post_doc(Flag('flag', missing=ERROR)) == '(required)' 128 | assert format_flag_post_doc(Flag('flag', display={'post_doc': '(fun)'})) == '(fun)' 129 | -------------------------------------------------------------------------------- /face/test/test_mw.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | 5 | from face import face_middleware, Command, Flag 6 | 7 | 8 | def test_mw_basic_sig(): 9 | @face_middleware(provides='time') 10 | def time_mw(next_): 11 | return next_(time=time.time()) 12 | 13 | with pytest.raises(TypeError): 14 | face_middleware(bad_kwarg=True) 15 | 16 | 17 | def test_mw_flags(): 18 | with pytest.raises(TypeError): 19 | @face_middleware(provides='time', flags=['not_valid']) 20 | def time_mw(next_): 21 | return next_(time=time.time()) 22 | 23 | # TODO: an actual flags test 24 | 25 | 26 | def test_no_provides(): 27 | @face_middleware 28 | def time_mw(next_): 29 | print(time.time()) 30 | return next_() 31 | 32 | 33 | def test_next_reserved(): 34 | def bad_cmd(next_): 35 | return 36 | 37 | cmd = Command(bad_cmd) 38 | 39 | with pytest.raises(NameError): 40 | cmd.run(['bad_cmd']) 41 | 42 | 43 | 44 | def test_mw_unres(): 45 | def unres_cmd(unresolved_arg): 46 | return unresolved_arg 47 | 48 | cmd = Command(unres_cmd) 49 | assert cmd.func is unres_cmd 50 | 51 | with pytest.raises(NameError, match="unresolved middleware or handler arguments: .*unresolved_arg.*"): 52 | cmd.run(['unres_cmd']) 53 | 54 | def inner_mw(next_, arg): 55 | return next_() 56 | 57 | @face_middleware(provides='arg', flags=[Flag('--verbose', parse_as=True)]) 58 | def outer_mw(next_): 59 | return next_(arg=1) 60 | 61 | def ok_cmd(arg): 62 | return None 63 | 64 | cmd = Command(ok_cmd, middlewares=[outer_mw]) 65 | cmd.add_middleware(inner_mw) 66 | 67 | with pytest.raises(NameError, match="unresolved middleware or handler arguments: .*arg.* check middleware order."): 68 | cmd.run(['ok_cmd']) 69 | return 70 | 71 | 72 | def test_check_mw(): 73 | with pytest.raises(TypeError, match='be a function'): 74 | face_middleware()('not a function') 75 | 76 | with pytest.raises(TypeError, match='take at least one argument'): 77 | face_middleware()(lambda: None) 78 | 79 | with pytest.raises(TypeError, match='as the first parameter'): 80 | face_middleware()(lambda bad_first_arg_name: None) 81 | 82 | with pytest.raises(TypeError, match=r'explicitly named arguments, not "\*a'): 83 | face_middleware()(lambda next_, *a: None) 84 | 85 | with pytest.raises(TypeError, match=r'explicitly named arguments, not "\*\*kw'): 86 | face_middleware()(lambda next_, **kw: None) 87 | 88 | with pytest.raises(TypeError, match='provides conflict with reserved face builtins'): 89 | face_middleware(provides='flags_')(lambda next_: None) 90 | -------------------------------------------------------------------------------- /face/test/test_search_cmd.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import datetime 4 | # from StringIO import StringIO 5 | 6 | from pytest import raises 7 | import pytest 8 | 9 | from face import (Command, 10 | Parser, 11 | Flag, 12 | ERROR, 13 | ListParam, 14 | face_middleware, 15 | ArgumentParseError, 16 | InvalidFlagArgument, 17 | DuplicateFlag, 18 | CommandLineError, 19 | InvalidSubcommand, 20 | UnknownFlag, 21 | UsageError, 22 | ChoicesParam) 23 | 24 | CUR_PATH = os.path.dirname(os.path.abspath(__file__)) 25 | 26 | 27 | def _rg(glob, max_count): 28 | "Great stuff from the regrep" 29 | print('regrepping', glob, max_count) 30 | return 31 | 32 | 33 | def _ls(file_paths): 34 | print(file_paths) 35 | for fp in file_paths: 36 | if '*' in fp: 37 | raise UsageError('no wildcards, ya hear?') 38 | return file_paths 39 | 40 | 41 | @face_middleware(provides=['timestamp']) 42 | def _timestamp_mw(next_): 43 | return next_(timestamp=datetime.datetime.now()) 44 | 45 | 46 | def get_search_command(as_parser=False): 47 | """A command which provides various subcommands mimicking popular 48 | command-line text search tools to test power, compatiblity, and 49 | flexibility. 50 | 51 | """ 52 | cmd = Command(None, 'search') 53 | cmd.add('--verbose', char='-V', parse_as=True) 54 | 55 | strat_flag = Flag('--strategy', multi='override', missing='fast') 56 | rg_subcmd = Command(_rg, 'rg', flags=[strat_flag]) 57 | rg_subcmd.add('--glob', char='-g', multi=True, parse_as=str, 58 | doc='Include or exclude files/directories for searching' 59 | ' that match the given glob. Precede with ! to exclude.') 60 | rg_subcmd.add('--max-count', char='-m', parse_as=int, 61 | doc='Limit the number of matching lines per file.') 62 | rg_subcmd.add('--filetype', ChoicesParam(['py', 'js', 'html'])) 63 | rg_subcmd.add('--extensions', ListParam(strip=True)) 64 | 65 | cmd.add(rg_subcmd) 66 | 67 | ls_subcmd = Command(_ls, 'ls', 68 | posargs={'display': 'file_path', 'provides': 'file_paths'}, 69 | post_posargs={'provides': 'diff_paths'}) 70 | cmd.add(ls_subcmd) 71 | 72 | class TwoFour: 73 | def __call__(self, posargs_): 74 | return ', '.join(posargs_) 75 | 76 | cmd.add(TwoFour(), posargs=dict(min_count=2, max_count=4)) 77 | 78 | cmd.add(_timestamp_mw) 79 | 80 | if as_parser: 81 | cmd.__class__ = Parser 82 | 83 | return cmd 84 | 85 | 86 | def test_search_prs_basic(): 87 | prs = get_search_command(as_parser=True) 88 | assert repr(prs).startswith('<Parser') 89 | 90 | res = prs.parse(['search', '--verbose']) 91 | assert repr(res).startswith('<CommandParseResult') 92 | assert res.name == 'search' 93 | assert res.flags['verbose'] is True 94 | 95 | res = prs.parse(['search', 'rg', '--glob', '*.py', '-g', '*.md', '--max-count', '5']) 96 | assert res.subcmds == ('rg',) 97 | assert res.flags['glob'] == ['*.py', '*.md'] 98 | 99 | res = prs.parse(['search', 'rg', '--extensions', 'py,html,css']) 100 | assert res.flags['extensions'] == ['py', 'html', 'css'] 101 | 102 | res = prs.parse(['search', 'rg', '--strategy', 'fast', '--strategy', 'slow']) 103 | assert res.flags['strategy'] == 'slow' 104 | 105 | 106 | @pytest.mark.skipif(sys.platform == "win32", reason="Module shortcut test not supported on Windows (mostly on github ci)") 107 | def test_module_shortcut(): 108 | prs = get_search_command(as_parser=True) 109 | assert prs.parse(['/search_pkg/__main__.py']).to_cmd_scope()['cmd_'] == 'python -m search_pkg' 110 | 111 | 112 | def test_prs_sys_argv(): 113 | prs = get_search_command(as_parser=True) 114 | old_argv = sys.argv 115 | try: 116 | sys.argv = ['search', 'ls', 'a', 'b', 'c'] 117 | res = prs.parse(argv=None) 118 | scope = res.to_cmd_scope() 119 | assert scope['file_paths'] == ('a', 'b', 'c') 120 | finally: 121 | sys.argv = old_argv 122 | 123 | 124 | def test_post_posargs(): 125 | prs = get_search_command(as_parser=True) 126 | 127 | res = prs.parse(['search', 'ls', 'path1', '--', 'diff_path1', 'diff_path2']) 128 | 129 | scope = res.to_cmd_scope() 130 | assert scope['file_paths'] == ('path1',) 131 | assert scope['diff_paths'] == ('diff_path1', 'diff_path2') 132 | 133 | 134 | def test_search_prs_errors(): 135 | prs = get_search_command(as_parser=True) 136 | 137 | with raises(ValueError, match='expected Parser, Flag, or Flag parameters'): 138 | prs.add('bad_arg', name='bad_kwarg') 139 | 140 | with raises(ValueError, match='conflicts with name of new flag'): 141 | prs.add('verbose') 142 | 143 | with raises(ValueError, match='conflicts with short form for new flag'): 144 | prs.add('--verbosity', char='V') 145 | 146 | with raises(UnknownFlag): 147 | prs.parse(['search', 'rg', '--unknown-flag']) 148 | 149 | with raises(ArgumentParseError): 150 | prs.parse(['splorch', 'splarch']) 151 | 152 | with raises(InvalidFlagArgument): 153 | prs.parse(['search', 'rg', '--max-count', 'not-an-int']) 154 | 155 | with raises(InvalidFlagArgument): 156 | prs.parse(['search', 'rg', '--max-count', '--glob', '*']) # max-count should have an arg but doesn't 157 | 158 | with raises(InvalidFlagArgument): 159 | prs.parse(['search', 'rg', '--max-count']) # gets a slightly different error message than above 160 | 161 | with raises(DuplicateFlag): 162 | prs.parse(['search', 'rg', '--max-count', '4', '--max-count', '5']) 163 | 164 | with raises(InvalidSubcommand): 165 | prs.parse(['search', 'nonexistent-subcommand']) 166 | 167 | with raises(ArgumentParseError): 168 | prs.parse(['search', 'rg', '--filetype', 'c']) 169 | 170 | with raises(DuplicateFlag, match="was used multiple times"): 171 | # TODO: is this really so bad? 172 | prs.parse(['search', '--verbose', '--verbose']) 173 | 174 | with raises(ArgumentParseError, match='expected non-empty') as exc_info: 175 | prs.parse(argv=[]) 176 | assert exc_info.value.prs_res.to_cmd_scope()['cmd_'] == 'search' 177 | 178 | with raises(ArgumentParseError, match='2 - 4 arguments'): 179 | prs.parse(argv=['search', 'two-four', '1', '2', '3', '4', '5']) 180 | 181 | prs.add('--req-flag', missing=ERROR) 182 | with raises(ArgumentParseError, match='missing required'): 183 | prs.parse(argv=['search', 'rg', '--filetype', 'py']) 184 | 185 | return 186 | 187 | 188 | def test_search_flagfile(): 189 | prs = get_search_command(as_parser=True) 190 | 191 | with raises(ArgumentParseError): 192 | prs.parse(['search', 'rg', '--flagfile', '_nonexistent_flagfile']) 193 | 194 | flagfile_path = CUR_PATH + '/_search_cmd_a.flags' 195 | 196 | res = prs.parse(['search', 'rg', '--flagfile', flagfile_path]) 197 | 198 | cmd = Command(lambda: None, name='cmd', flagfile=False) 199 | assert cmd.flagfile_flag is None 200 | 201 | # check that flagfile being passed False causes the flag to error 202 | with raises(ArgumentParseError): 203 | cmd.parse(['cmd', '--flagfile', 'doesnt_have_to_exist']) 204 | 205 | cmd = Command(lambda: None, name='cmd', flagfile=Flag('--flags')) 206 | assert cmd.flagfile_flag.name == 'flags' 207 | 208 | with open(flagfile_path) as f: 209 | flagfile_text = f.read() 210 | 211 | # does this even make sense as a case? 212 | # flagfile_strio = StringIO(flagfile_text) 213 | 214 | with raises(TypeError, match='Flag instance for flagfile'): 215 | Command(lambda: None, name='cmd', flagfile=object()) 216 | 217 | 218 | def test_search_cmd_basic(capsys): 219 | cmd = get_search_command() 220 | 221 | cmd.run(['search', 'rg', '--glob', '*', '-m', '10']) 222 | 223 | out, err = capsys.readouterr() 224 | assert 'regrepping' in out 225 | 226 | with raises(SystemExit) as exc_info: 227 | cmd.run(['search', 'rg', 'badposarg']) 228 | out, err = capsys.readouterr() 229 | assert 'error:' in err 230 | 231 | with raises(CommandLineError): 232 | cmd.run(['search', 'rg', '--no-such-flag']) 233 | out, err = capsys.readouterr() 234 | assert 'error: search rg: unknown flag "--no-such-flag",' in err 235 | 236 | cmd.run(['search', 'rg', '-h', 'badposarg']) 237 | out, err = capsys.readouterr() 238 | assert '[FLAGS]' in out # help printed bc flag 239 | 240 | cmd.prepare() # prepares all paths/subcmds 241 | 242 | 243 | def test_search_help(capsys): 244 | # pdb won't work in here bc of the captured stdout/err 245 | cmd = get_search_command() 246 | 247 | cmd.run(['search', '-h']) 248 | 249 | out, err = capsys.readouterr() 250 | assert '[FLAGS]' in out 251 | assert '--help' in out 252 | assert 'show this help message and exit' in out 253 | 254 | 255 | def test_search_ls(capsys): 256 | cmd = get_search_command() 257 | 258 | res = cmd.run(['search', 'ls', 'a', 'b']) 259 | 260 | assert res == ('a', 'b') 261 | 262 | cmd.run(['search', 'ls', '-h']) 263 | 264 | out, err = capsys.readouterr() 265 | assert 'file_paths' in out 266 | 267 | 268 | def test_usage_error(capsys): 269 | cmd = get_search_command() 270 | 271 | with raises(UsageError, match='no wildcards'): 272 | cmd.run(['search', 'ls', '*']) 273 | 274 | out, err = capsys.readouterr() 275 | assert 'no wildcards' in err 276 | -------------------------------------------------------------------------------- /face/test/test_vcs_cmd.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from face import (Command, 4 | Parser, 5 | PosArgSpec, 6 | ArgumentParseError) 7 | 8 | 9 | def get_vcs_cmd(as_parser=False): 10 | cmd = Command(None, 'vcs') 11 | 12 | cmd.add(_add_cmd, name='add', posargs={'min_count': 1}, post_posargs=True) 13 | cmd.add(_checkout_cmd, name='checkout', posargs={'max_count': 1}) 14 | 15 | if as_parser: 16 | cmd.__class__ = Parser 17 | 18 | return cmd 19 | 20 | 21 | def _add_cmd(posargs_): 22 | "add files to the vcs" 23 | assert posargs_ 24 | return 25 | 26 | def _checkout_cmd(posargs_, post_posargs_): 27 | "checkout a branch or files" 28 | assert posargs_ or post_posargs_ 29 | return 30 | 31 | 32 | def test_vcs_basic(): 33 | prs = get_vcs_cmd(as_parser=True) 34 | 35 | with pytest.raises(ArgumentParseError): 36 | prs.parse(['vcs', 'add']) 37 | 38 | res = prs.parse(['vcs', 'add', 'myfile.txt']) 39 | assert res 40 | 41 | res = prs.parse(['vcs', 'checkout', 'trunk']) 42 | assert res 43 | -------------------------------------------------------------------------------- /face/testing.py: -------------------------------------------------------------------------------- 1 | # Design (and some implementation) of this owes heavily to Click's 2 | # CliRunner (TODO: bring in license?) 3 | 4 | """Porting notes: 5 | 6 | * EchoingStdin.read1() needed to exist for py3 and raw_input 7 | * Not sure why the isolate context manager deals in byte streams and 8 | then relegates to the Result to do late encoding (in properties no 9 | less). This is especially troublesome because sys.stdout/stderr 10 | isn't the same stream as stdout/stderr as returned by the context 11 | manager. (see the extra flush calls in run's finally block.) Is 12 | it just for parity with py2? There was a related bug, sys.stdout was 13 | flushed, but not sys.stderr, which caused py3's error_bytes to come 14 | through as blank. 15 | * sys.stderr had to be flushed, too, on py3 (in invoke's finally) 16 | * Result.exception was redundant with exc_info 17 | * Result.stderr raised a ValueError when stderr was empty, not just 18 | when it wasn't captured. 19 | * Instead of isolated_filesystem, I just added chdir to run, 20 | because pytest already does temporary directories. 21 | * Removed echo_stdin (stdin never echos, as it wouldn't with subprocess) 22 | 23 | """ 24 | 25 | import io 26 | import os 27 | import sys 28 | import shlex 29 | import getpass 30 | import contextlib 31 | from subprocess import list2cmdline 32 | from functools import partial 33 | from collections.abc import Container 34 | 35 | from boltons.setutils import complement 36 | 37 | 38 | def _make_input_stream(input, encoding): 39 | if input is None: 40 | input = b'' 41 | elif isinstance(input, str): 42 | input = input.encode(encoding) 43 | elif not isinstance(input, bytes): 44 | raise TypeError(f'expected bytes, text, or None, not: {input!r}') 45 | return io.BytesIO(input) 46 | 47 | 48 | def _fake_getpass(prompt='Password: ', stream=None): 49 | if not stream: 50 | stream = sys.stderr 51 | input = sys.stdin 52 | prompt = str(prompt) 53 | if prompt: 54 | stream.write(prompt) 55 | stream.flush() 56 | line = input.readline() 57 | if not line: 58 | raise EOFError 59 | if line[-1] == '\n': 60 | line = line[:-1] 61 | return line 62 | 63 | 64 | class RunResult: 65 | """Returned from :meth:`CommandChecker.run()`, complete with the 66 | relevant inputs and outputs of the run. 67 | 68 | Instances of this object are especially valuable for verifying 69 | expected output via the :attr:`~RunResult.stdout` and 70 | :attr:`~RunResult.stderr` attributes. 71 | 72 | API modeled after :class:`subprocess.CompletedProcess` for 73 | familiarity and porting of tests. 74 | 75 | """ 76 | 77 | def __init__(self, args, input, exit_code, stdout_bytes, stderr_bytes, exc_info=None, checker=None): 78 | self.args = args 79 | self.input = input 80 | self.checker = checker 81 | self.stdout_bytes = stdout_bytes 82 | self.stderr_bytes = stderr_bytes 83 | self.exit_code = exit_code # integer 84 | 85 | # if an exception occurred: 86 | self.exc_info = exc_info 87 | # TODO: exc_info won't be available in subprocess... maybe the 88 | # text of a parsed-out traceback? But in general, tracebacks 89 | # aren't usually part of CLI UX... 90 | 91 | @property 92 | def exception(self): 93 | """Exception instance, if an uncaught error was raised. 94 | Equivalent to ``run_res.exc_info[1]``, but more readable.""" 95 | return self.exc_info[1] if self.exc_info else None 96 | 97 | @property 98 | def returncode(self): # for parity with subprocess.CompletedProcess 99 | "Alias of :attr:`exit_code`, for parity with :class:`subprocess.CompletedProcess`" 100 | return self.exit_code 101 | 102 | @property 103 | def stdout(self): 104 | """The text output ("stdout") of the command, as a decoded 105 | string. See :attr:`stdout_bytes` for the bytestring. 106 | """ 107 | return (self.stdout_bytes 108 | .decode(self.checker.encoding, 'replace') 109 | .replace('\r\n', '\n')) 110 | 111 | @property 112 | def stderr(self): 113 | """The error output ("stderr") of the command, as a decoded 114 | string. See :attr:`stderr_bytes` for the bytestring. May be 115 | ``None`` if *mix_stderr* was set to ``True`` in the 116 | :class:`~face.CommandChecker`. 117 | """ 118 | if self.stderr_bytes is None: 119 | raise ValueError("stderr not separately captured") 120 | return (self.stderr_bytes 121 | .decode(self.checker.encoding, 'replace') 122 | .replace('\r\n', '\n')) 123 | 124 | def __repr__(self): 125 | # very similar to subprocess.CompleteProcess repr 126 | args = [f'args={self.args!r}', 127 | f'returncode={self.returncode!r}'] 128 | if self.stdout_bytes: 129 | args.append(f'stdout={self.stdout!r}') 130 | if self.stderr_bytes is not None: 131 | args.append(f'stderr={self.stderr!r}') 132 | if self.exception: 133 | args.append(f'exception={self.exception!r}') 134 | return f"{self.__class__.__name__}({', '.join(args)})" 135 | 136 | 137 | 138 | def _get_exp_code_text(exp_codes): 139 | try: 140 | codes_len = len(exp_codes) 141 | except Exception: 142 | comp_codes = complement(exp_codes) 143 | try: 144 | comp_codes = tuple(comp_codes) 145 | return f'any code but {comp_codes[0] if len(comp_codes) == 1 else comp_codes!r}' 146 | except Exception: 147 | return repr(exp_codes) 148 | if codes_len == 1: 149 | return repr(exp_codes[0]) 150 | return f'one of {tuple(exp_codes)!r}' 151 | 152 | 153 | class CheckError(AssertionError): 154 | """Rarely raised directly, :exc:`CheckError` is automatically 155 | raised when a :meth:`CommandChecker.run()` call does not terminate 156 | with an expected error code. 157 | 158 | This error attempts to format the stdout, stderr, and stdin of the 159 | run for easier debugging. 160 | """ 161 | def __init__(self, result, exit_codes): 162 | self.result = result 163 | exp_code = _get_exp_code_text(exit_codes) 164 | msg = ('Got exit code %r (expected %s) when running command: %s' 165 | % (result.exit_code, exp_code, list2cmdline(result.args))) 166 | if result.stdout: 167 | msg += '\nstdout = """\n' 168 | msg += result.stdout 169 | msg += '"""\n' 170 | if result.stderr_bytes: 171 | msg += '\nstderr = """\n' 172 | msg += result.stderr 173 | msg += '"""\n' 174 | if result.input: 175 | msg += '\nstdin = """\n' 176 | msg += result.input 177 | msg += '"""\n' 178 | AssertionError.__init__(self, msg) 179 | 180 | 181 | class CommandChecker: 182 | """Face's main testing interface. 183 | 184 | Wrap your :class:`Command` instance in a :class:`CommandChecker`, 185 | :meth:`~CommandChecker.run()` commands with arguments, and get 186 | :class:`RunResult` objects to validate your Command's behavior. 187 | 188 | Args: 189 | 190 | cmd: The :class:`Command` instance to test. 191 | env (dict): An optional base environment to use for subsequent 192 | calls issued through this checker. Defaults to ``{}``. 193 | chdir (str): A default path to execute this checker's commands 194 | in. Great for temporary directories to ensure test isolation. 195 | mix_stderr (bool): Set to ``True`` to capture stderr into 196 | stdout. This makes it easier to verify order of standard 197 | output and errors. If ``True``, this checker's results' 198 | error_bytes will be set to ``None``. Defaults to ``False``. 199 | reraise (bool): Reraise uncaught exceptions from within *cmd*'s 200 | endpoint functions, instead of returning a :class:`RunResult` 201 | instance. Defaults to ``False``. 202 | 203 | """ 204 | def __init__(self, cmd, env=None, chdir=None, mix_stderr=False, reraise=False): 205 | self.cmd = cmd 206 | self.base_env = env or {} 207 | self.reraise = reraise 208 | self.mix_stderr = mix_stderr 209 | self.encoding = 'utf8' # not clear if this should be an arg yet 210 | self.chdir = chdir 211 | 212 | @contextlib.contextmanager 213 | def _isolate(self, input=None, env=None, chdir=None): 214 | old_cwd = os.getcwd() 215 | old_stdin, old_stdout, old_stderr = sys.stdin, sys.stdout, sys.stderr 216 | old_getpass = getpass.getpass 217 | 218 | tmp_stdin = _make_input_stream(input, self.encoding) 219 | 220 | full_env = dict(self.base_env) 221 | 222 | chdir = chdir or self.chdir 223 | if env: 224 | full_env.update(env) 225 | 226 | bytes_output = io.BytesIO() 227 | tmp_stdin = io.TextIOWrapper(tmp_stdin, encoding=self.encoding) 228 | tmp_stdout = io.TextIOWrapper( 229 | bytes_output, encoding=self.encoding) 230 | if self.mix_stderr: 231 | tmp_stderr = tmp_stdout 232 | else: 233 | bytes_error = io.BytesIO() 234 | tmp_stderr = io.TextIOWrapper( 235 | bytes_error, encoding=self.encoding) 236 | 237 | old_env = {} 238 | try: 239 | _sync_env(os.environ, full_env, old_env) 240 | if chdir: 241 | os.chdir(str(chdir)) 242 | sys.stdin, sys.stdout, sys.stderr = tmp_stdin, tmp_stdout, tmp_stderr 243 | getpass.getpass = _fake_getpass 244 | 245 | yield (bytes_output, bytes_error if not self.mix_stderr else None) 246 | finally: 247 | if chdir: 248 | os.chdir(old_cwd) 249 | 250 | _sync_env(os.environ, old_env) 251 | 252 | # see note above 253 | tmp_stdout.flush() 254 | tmp_stderr.flush() 255 | sys.stdin, sys.stdout, sys.stderr = old_stdin, old_stdout, old_stderr 256 | getpass.getpass = old_getpass 257 | 258 | return 259 | 260 | def fail(self, *a, **kw): 261 | """Convenience method around :meth:`~CommandChecker.run()`, with the 262 | same signature, except that this will raise a 263 | :exc:`CheckError` if the command completes with exit code 264 | ``0``. 265 | """ 266 | kw.setdefault('exit_code', complement({0})) 267 | return self.run(*a, **kw) 268 | 269 | def __getattr__(self, name): 270 | if not name.startswith('fail_'): 271 | return super().__getattr__(name) 272 | _, _, code_str = name.partition('fail_') 273 | try: 274 | code = [int(cs) for cs in code_str.split('_')] 275 | except Exception: 276 | raise AttributeError('fail_* shortcuts must end in integers, not %r' 277 | % code_str) 278 | return partial(self.fail, exit_code=code) 279 | 280 | def run(self, args, input=None, env=None, chdir=None, exit_code=0): 281 | """The :meth:`run` method acts as the primary entrypoint to the 282 | :class:`CommandChecker` instance. Pass arguments as a list or 283 | string, and receive a :class:`RunResult` with which to verify 284 | your command's output. 285 | 286 | If the arguments do not result in an expected *exit_code*, a 287 | :exc:`CheckError` will be raised. 288 | 289 | Args: 290 | 291 | args: A list or string representing arguments, as one might 292 | find in :attr:`sys.argv` or at the command line. 293 | input (str): A string (or list of lines) to be passed to 294 | the command's stdin. Used for testing 295 | :func:`~face.prompt` interactions, among others. 296 | env (dict): A mapping of environment variables to apply on 297 | top of the :class:`CommandChecker`'s base env vars. 298 | chdir (str): A string (or stringifiable path) path to 299 | switch to before running the command. Defaults to 300 | ``None`` (runs in current directory). 301 | exit_code (int): An integer or list of integer exit codes 302 | expected from running the command with *args*. If the 303 | actual exit code does not match *exit_code*, 304 | :exc:`CheckError` is raised. Set to ``None`` to disable 305 | this behavior and always return 306 | :class:`RunResult`. Defaults to ``0``. 307 | 308 | .. note:: 309 | 310 | At this time, :meth:`run` interacts with global process 311 | state, and is not designed for parallel usage. 312 | 313 | """ 314 | if isinstance(input, (list, tuple)): 315 | input = '\n'.join(input) 316 | if exit_code is None: 317 | exit_codes = () 318 | elif isinstance(exit_code, int): 319 | exit_codes = (exit_code,) 320 | elif not isinstance(exit_code, Container): 321 | raise TypeError('expected exit_code to be None, int, or' 322 | ' Container of ints, representing expected' 323 | ' exit_codes, not: %r' % (exit_code,)) 324 | else: 325 | exit_codes = exit_code 326 | with self._isolate(input=input, env=env, chdir=chdir) as (stdout, stderr): 327 | exc_info = None 328 | exit_code = 0 329 | 330 | if isinstance(args, str): 331 | args = shlex.split(args) 332 | 333 | try: 334 | res = self.cmd.run(args or ()) 335 | except SystemExit as se: 336 | exc_info = sys.exc_info() 337 | exit_code = se.code if se.code is not None else 0 338 | except Exception: 339 | if self.reraise: 340 | raise 341 | exit_code = -1 # TODO: something better? 342 | exc_info = sys.exc_info() 343 | finally: 344 | sys.stdout.flush() 345 | sys.stderr.flush() 346 | stdout_bytes = stdout.getvalue() 347 | stderr_bytes = stderr.getvalue() if not self.mix_stderr else None 348 | 349 | run_res = RunResult(checker=self, 350 | args=args, 351 | input=input, 352 | stdout_bytes=stdout_bytes, 353 | stderr_bytes=stderr_bytes, 354 | exit_code=exit_code, 355 | exc_info=exc_info) 356 | if exit_codes and exit_code not in exit_codes: 357 | exc = CheckError(run_res, exit_codes) 358 | raise exc 359 | return run_res 360 | 361 | 362 | # syncing os.environ (as opposed to modifying a copy and setting it 363 | # back) takes care of cases when someone has a reference to environ 364 | def _sync_env(env, new, backup=None): 365 | for key, value in new.items(): 366 | if backup is not None: 367 | backup[key] = env.get(key) 368 | if value is not None: 369 | env[key] = value 370 | continue 371 | try: 372 | del env[key] 373 | except Exception: 374 | pass 375 | return backup 376 | -------------------------------------------------------------------------------- /face/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import re 5 | import sys 6 | import getpass 7 | import keyword 8 | import textwrap 9 | import typing 10 | 11 | from boltons.strutils import pluralize, strip_ansi 12 | from boltons.iterutils import split, unique 13 | from boltons.typeutils import make_sentinel 14 | 15 | import face 16 | 17 | raw_input = input 18 | 19 | ERROR = make_sentinel('ERROR') # used for parse_as=ERROR 20 | 21 | # keep it just to subset of valid ASCII python identifiers for now 22 | VALID_FLAG_RE = re.compile(r"^[A-z][-_A-z0-9]*\Z") 23 | 24 | FRIENDLY_TYPE_NAMES = {int: 'integer', 25 | float: 'decimal'} 26 | 27 | 28 | def process_command_name(name): 29 | """Validate and canonicalize a Command's name, generally on 30 | construction or at subcommand addition. Like 31 | ``flag_to_identifier()``, only letters, numbers, '-', and/or 32 | '_'. Must begin with a letter, and no trailing underscores or 33 | dashes. 34 | 35 | Python keywords are allowed, as subcommands are never used as 36 | attributes or variables in injection. 37 | 38 | """ 39 | 40 | if not name or not isinstance(name, str): 41 | raise ValueError(f'expected non-zero length string for subcommand name, not: {name!r}') 42 | 43 | if name.endswith('-') or name.endswith('_'): 44 | raise ValueError('expected subcommand name without trailing dashes' 45 | ' or underscores, not: %r' % name) 46 | 47 | name_match = VALID_FLAG_RE.match(name) 48 | if not name_match: 49 | raise ValueError('valid subcommand name must begin with a letter, and' 50 | ' consist only of letters, digits, underscores, and' 51 | ' dashes, not: %r' % name) 52 | 53 | subcmd_name = normalize_flag_name(name) 54 | 55 | return subcmd_name 56 | 57 | 58 | def normalize_flag_name(flag): 59 | ret = flag.lstrip('-') 60 | if (len(flag) - len(ret)) > 1: 61 | # only single-character flags are considered case-sensitive (like an initial) 62 | ret = ret.lower() 63 | ret = ret.replace('-', '_') 64 | return ret 65 | 66 | 67 | def flag_to_identifier(flag): 68 | """Validate and canonicalize a flag name to a valid Python identifier 69 | (variable name). 70 | 71 | Valid input strings include only letters, numbers, '-', and/or 72 | '_'. Only single/double leading dash allowed (-/--). No trailing 73 | dashes or underscores. Must not be a Python keyword. 74 | 75 | Input case doesn't matter, output case will always be lower. 76 | """ 77 | orig_flag = flag 78 | if not flag or not isinstance(flag, str): 79 | raise ValueError(f'expected non-zero length string for flag, not: {flag!r}') 80 | 81 | if flag.endswith('-') or flag.endswith('_'): 82 | raise ValueError('expected flag without trailing dashes' 83 | ' or underscores, not: %r' % orig_flag) 84 | 85 | if flag[:2] == '--': 86 | flag = flag[2:] 87 | 88 | flag_match = VALID_FLAG_RE.match(flag) 89 | if not flag_match: 90 | raise ValueError('valid flag names must begin with a letter, optionally' 91 | ' prefixed by two dashes, and consist only of letters,' 92 | ' digits, underscores, and dashes, not: %r' % orig_flag) 93 | 94 | flag_name = normalize_flag_name(flag) 95 | 96 | if keyword.iskeyword(flag_name): 97 | raise ValueError(f'valid flag names must not be Python keywords: {orig_flag!r}') 98 | 99 | return flag_name 100 | 101 | 102 | def identifier_to_flag(identifier): 103 | """ 104 | Turn an identifier back into its flag format (e.g., "Flag" -> --flag). 105 | """ 106 | if identifier.startswith('-'): 107 | raise ValueError(f'expected identifier, not flag name: {identifier!r}') 108 | ret = identifier.lower().replace('_', '-') 109 | return '--' + ret 110 | 111 | 112 | def format_flag_label(flag): 113 | "The default flag label formatter, used in help and error formatting" 114 | if flag.display.label is not None: 115 | return flag.display.label 116 | parts = [identifier_to_flag(flag.name)] 117 | if flag.char: 118 | parts.append('-' + flag.char) 119 | ret = ' / '.join(parts) 120 | if flag.display.value_name: 121 | ret += ' ' + flag.display.value_name 122 | return ret 123 | 124 | 125 | def format_posargs_label(posargspec): 126 | "The default positional argument label formatter, used in help formatting" 127 | if posargspec.display.label: 128 | return posargspec.display.label 129 | if not posargspec.accepts_args: 130 | return '' 131 | return get_cardinalized_args_label(posargspec.display.name, posargspec.min_count, posargspec.max_count) 132 | 133 | 134 | def get_cardinalized_args_label(name, min_count, max_count): 135 | ''' 136 | Examples for parameter values: (min_count, max_count): output for name=arg: 137 | 138 | 1, 1: arg 139 | 0, 1: [arg] 140 | 0, None: [args ...] 141 | 1, 3: args ... 142 | ''' 143 | if min_count == max_count: 144 | return ' '.join([name] * min_count) 145 | if min_count == 1: 146 | return name + ' ' + get_cardinalized_args_label(name, 147 | min_count=0, 148 | max_count=max_count - 1 if max_count is not None else None) 149 | 150 | tmpl = '[%s]' if min_count == 0 else '%s' 151 | if max_count == 1: 152 | return tmpl % name 153 | return tmpl % (pluralize(name) + ' ...') 154 | 155 | 156 | def format_flag_post_doc(flag): 157 | "The default positional argument label formatter, used in help formatting" 158 | if flag.display.post_doc is not None: 159 | return flag.display.post_doc 160 | if flag.missing is face.ERROR: 161 | return '(required)' 162 | if flag.missing is None or repr(flag.missing) == object.__repr__(flag.missing): 163 | # avoid displaying unhelpful defaults 164 | return '' 165 | return f'(defaults to {flag.missing!r})' 166 | 167 | 168 | def get_type_desc(parse_as): 169 | "Kind of a hacky way to improve message readability around argument types" 170 | if not callable(parse_as): 171 | raise TypeError(f'expected parse_as to be callable, not {parse_as!r}') 172 | try: 173 | return 'as', FRIENDLY_TYPE_NAMES[parse_as] 174 | except KeyError: 175 | pass 176 | try: 177 | # return the type name if it looks like a type 178 | return 'as', parse_as.__name__ 179 | except AttributeError: 180 | pass 181 | try: 182 | # return the func name if it looks like a function 183 | return 'with', parse_as.func_name 184 | except AttributeError: 185 | pass 186 | # if all else fails 187 | return 'with', repr(parse_as) 188 | 189 | 190 | def unwrap_text(text): 191 | """Turn wrapped text into flowing paragraphs, ready for rewrapping by 192 | the console, browser, or textwrap. 193 | """ 194 | all_grafs = [] 195 | cur_graf = [] 196 | for line in text.splitlines(): 197 | line = line.strip() 198 | if line: 199 | cur_graf.append(line) 200 | else: 201 | all_grafs.append(' '.join(cur_graf)) 202 | cur_graf = [] 203 | if cur_graf: 204 | all_grafs.append(' '.join(cur_graf)) 205 | return '\n\n'.join(all_grafs) 206 | 207 | 208 | def get_rdep_map(dep_map): 209 | """ 210 | expects and returns a dict of {item: set([deps])} 211 | 212 | item can be a string or any other hashable object. 213 | """ 214 | # TODO: the way this is used, this function doesn't receive 215 | # information about what functions take what args. this ends up 216 | # just being args depending on args, with no mediating middleware 217 | # names. this can make circular dependencies harder to debug. 218 | ret = {} 219 | for key in dep_map: 220 | to_proc, rdeps, cur_chain = [key], set(), [] 221 | while to_proc: 222 | cur = to_proc.pop() 223 | cur_chain.append(cur) 224 | 225 | cur_rdeps = dep_map.get(cur, []) 226 | 227 | if key in cur_rdeps: 228 | raise ValueError('dependency cycle: %r recursively depends' 229 | ' on itself. full dep chain: %r' % (cur, cur_chain)) 230 | 231 | to_proc.extend([c for c in cur_rdeps if c not in to_proc]) 232 | rdeps.update(cur_rdeps) 233 | 234 | ret[key] = rdeps 235 | return ret 236 | 237 | 238 | def get_minimal_executable(executable=None, path=None, environ=None): 239 | """Get the shortest form of a path to an executable, 240 | based on the state of the process environment. 241 | 242 | Args: 243 | executable (str): Name or path of an executable 244 | path (list): List of directories on the "PATH", or ':'-separated 245 | path list, similar to the $PATH env var. Defaults to ``environ['PATH']``. 246 | environ (dict): Mapping of environment variables, will be used 247 | to retrieve *path* if it is None. Ignored if *path* is 248 | set. Defaults to ``os.environ``. 249 | 250 | Used by face's default help renderer for a more readable usage string. 251 | """ 252 | executable = sys.executable if executable is None else executable 253 | environ = os.environ if environ is None else environ 254 | path = environ.get('PATH', '') if path is None else path 255 | if isinstance(path, str): 256 | path = path.split(':') 257 | 258 | executable_basename = os.path.basename(executable) 259 | for p in path: 260 | if os.path.relpath(executable, p) == executable_basename: 261 | return executable_basename 262 | # TODO: support "../python" as a return? 263 | return executable 264 | 265 | 266 | # prompt and echo owe a decent amount of design to click (and 267 | # pocket_protector) 268 | def isatty(stream): 269 | "Returns True if *stream* is a tty" 270 | try: 271 | return stream.isatty() 272 | except Exception: 273 | return False 274 | 275 | 276 | def should_strip_ansi(stream): 277 | "Returns True when ANSI color codes should be stripped from output to *stream*." 278 | return not isatty(stream) 279 | 280 | 281 | def echo(msg: str | bytes | object, *, 282 | err: bool = False, 283 | file: typing.TextIO | None = None, 284 | nl: bool = True, 285 | end: str | None = None, 286 | color: bool | None = None, 287 | indent: str | int = '') -> None: 288 | """A better-behaved :func:`print()` function for command-line applications. 289 | 290 | Writes text or bytes to a file or stream and flushes. Seamlessly 291 | handles stripping ANSI color codes when the output file is not a 292 | TTY. 293 | 294 | >>> echo('test') 295 | test 296 | 297 | Args: 298 | msg: A text or byte string to echo. 299 | err: Set the default output file to ``sys.stderr`` 300 | file: Stream or other file-like object to output 301 | to. Defaults to ``sys.stdout``, or ``sys.stderr`` if *err* is 302 | True. 303 | nl: If ``True``, sets *end* to ``'\\n'``, the newline character. 304 | end: Explicitly set the line-ending character. Setting this overrides *nl*. 305 | color: Set to ``True``/``False`` to always/never echo ANSI color 306 | codes. Defaults to inspecting whether *file* is a TTY. 307 | indent: String prefix or number of spaces to indent the output. 308 | """ 309 | msg = msg or '' 310 | if not isinstance(msg, (str, bytes)): 311 | msg = str(msg) 312 | 313 | _file = file or (sys.stderr if err else sys.stdout) 314 | enable_color = color 315 | space: str = ' ' 316 | if isinstance(indent, int): 317 | indent = space * indent 318 | 319 | if enable_color is None: 320 | enable_color = not should_strip_ansi(_file) 321 | 322 | if end is None: 323 | if nl: 324 | end = '\n' if isinstance(msg, str) else b'\n' 325 | if end: 326 | msg += end 327 | if indent: 328 | msg = textwrap.indent(msg, prefix=indent) 329 | 330 | if msg: 331 | if not enable_color: 332 | msg = strip_ansi(msg) 333 | _file.write(msg) 334 | 335 | _file.flush() 336 | 337 | return 338 | 339 | 340 | def echo_err(*a, **kw): 341 | """ 342 | A convenience function which works exactly like :func:`echo`, but 343 | always defaults the output *file* to ``sys.stderr``. 344 | """ 345 | kw['err'] = True 346 | return echo(*a, **kw) 347 | 348 | 349 | # variant-style shortcut to help minimize kwarg noise and imports 350 | echo.err = echo_err 351 | 352 | 353 | def _get_text(inp): 354 | if not isinstance(inp, str): 355 | return inp.decode('utf8') 356 | return inp 357 | 358 | 359 | def prompt(label, confirm=None, confirm_label=None, hide_input=False, err=False): 360 | """A better-behaved :func:`input()` function for command-line applications. 361 | 362 | Ask a user for input, confirming if necessary, returns a text 363 | string. Handles Ctrl-C and EOF more gracefully than Python's built-ins. 364 | 365 | Args: 366 | 367 | label (str): The prompt to display to the user. 368 | confirm (bool): Pass ``True`` to ask the user to retype the input to confirm it. 369 | Defaults to False, unless *confirm_label* is passed. 370 | confirm_label (str): Override the confirmation prompt. Defaults 371 | to "Retype *label*" if *confirm* is ``True``. 372 | hide_input (bool): If ``True``, disables echoing the user's 373 | input as they type. Useful for passwords and other secret 374 | entry. See :func:`prompt_secret` for a more convenient 375 | interface. Defaults to ``False``. 376 | err (bool): If ``True``, prompts are printed on 377 | ``sys.stderr``. Defaults to ``False``. 378 | 379 | :func:`prompt` is primarily intended for simple plaintext 380 | entry. See :func:`prompt_secret` for handling passwords and other 381 | secret user input. 382 | 383 | Raises :exc:`UsageError` if *confirm* is enabled and inputs do not match. 384 | 385 | """ 386 | do_confirm = confirm or confirm_label 387 | if do_confirm and not confirm_label: 388 | confirm_label = f'Retype {label.lower()}' 389 | 390 | def prompt_func(label): 391 | func = getpass.getpass if hide_input else raw_input 392 | try: 393 | # Write the prompt separately so that we get nice 394 | # coloring through colorama on Windows (someday) 395 | echo(label, nl=False, err=err) 396 | ret = func('') 397 | except (KeyboardInterrupt, EOFError): 398 | # getpass doesn't print a newline if the user aborts input with ^C. 399 | # Allegedly this behavior is inherited from getpass(3). 400 | # A doc bug has been filed at https://bugs.python.org/issue24711 401 | if hide_input: 402 | echo(None, err=err) 403 | raise 404 | 405 | return ret 406 | 407 | ret = prompt_func(label) 408 | ret = _get_text(ret) 409 | if do_confirm: 410 | ret2 = prompt_func(confirm_label) 411 | ret2 = _get_text(ret2) 412 | if ret != ret2: 413 | raise face.UsageError('Sorry, inputs did not match.') 414 | 415 | return ret 416 | 417 | 418 | def prompt_secret(label, **kw): 419 | """A convenience function around :func:`prompt`, which is 420 | preconfigured for secret user input, like passwords. 421 | 422 | All arguments are the same, except *hide_input* is always 423 | ``True``, and *err* defaults to ``True``, for consistency with 424 | :func:`getpass.getpass`. 425 | 426 | """ 427 | kw['hide_input'] = True 428 | kw.setdefault('err', True) # getpass usually puts prompts on stderr 429 | return prompt(label, **kw) 430 | 431 | 432 | # variant-style shortcut to help minimize kwarg noise and imports 433 | prompt.secret = prompt_secret 434 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | sphinx 3 | sphinx-autobuild 4 | sphinx-rtd-theme 5 | sphinxcontrib-napoleon 6 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | boltons>=19.3.0 2 | 3 | # dev deps 4 | pip-tools 5 | coverage 6 | pytest 7 | tox 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.8 3 | # by the following command: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | boltons==24.1.0 8 | # via -r requirements.in 9 | build==1.2.2.post1 10 | # via pip-tools 11 | cachetools==5.5.0 12 | # via tox 13 | chardet==5.2.0 14 | # via tox 15 | click==8.1.7 16 | # via pip-tools 17 | colorama==0.4.6 18 | # via tox 19 | coverage==7.6.1 20 | # via -r requirements.in 21 | distlib==0.3.9 22 | # via virtualenv 23 | exceptiongroup==1.2.2 24 | # via pytest 25 | filelock==3.16.1 26 | # via 27 | # tox 28 | # virtualenv 29 | importlib-metadata==8.5.0 30 | # via build 31 | iniconfig==2.0.0 32 | # via pytest 33 | packaging==24.1 34 | # via 35 | # build 36 | # pyproject-api 37 | # pytest 38 | # tox 39 | pip-tools==7.4.1 40 | # via -r requirements.in 41 | platformdirs==4.3.6 42 | # via 43 | # tox 44 | # virtualenv 45 | pluggy==1.5.0 46 | # via 47 | # pytest 48 | # tox 49 | pyproject-api==1.8.0 50 | # via tox 51 | pyproject-hooks==1.2.0 52 | # via 53 | # build 54 | # pip-tools 55 | pytest==8.3.3 56 | # via -r requirements.in 57 | tomli==2.0.2 58 | # via 59 | # build 60 | # pip-tools 61 | # pyproject-api 62 | # pytest 63 | # tox 64 | tox==4.23.2 65 | # via -r requirements.in 66 | typing-extensions==4.12.2 67 | # via tox 68 | virtualenv==20.27.1 69 | # via tox 70 | wheel==0.44.0 71 | # via pip-tools 72 | zipp==3.20.2 73 | # via importlib-metadata 74 | 75 | # The following packages are considered to be unsafe in a requirements file: 76 | # pip 77 | # setuptools 78 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """A command-line interface parser and framework, friendly for users, 2 | full-featured for developers. 3 | """ 4 | 5 | from setuptools import setup 6 | 7 | 8 | __author__ = 'Mahmoud Hashemi' 9 | __contact__ = 'mahmoud@hatnote.com' 10 | __version__ = '24.0.1dev' 11 | __url__ = 'https://github.com/mahmoud/face' 12 | __license__ = 'BSD' 13 | 14 | 15 | setup(name='face', 16 | version=__version__, 17 | description="A command-line application framework (and CLI parser). Friendly for users, full-featured for developers.", 18 | long_description=__doc__, 19 | author=__author__, 20 | author_email=__contact__, 21 | url=__url__, 22 | packages=['face', 'face.test'], 23 | include_package_data=True, 24 | zip_safe=False, 25 | license=__license__, 26 | platforms='any', 27 | install_requires=['boltons>=20.0.0'], 28 | classifiers=[ 29 | 'Topic :: Utilities', 30 | 'Intended Audience :: Developers', 31 | 'Topic :: Software Development :: Libraries', 32 | 'Programming Language :: Python :: 3.8', 33 | 'Programming Language :: Python :: 3.9', 34 | 'Programming Language :: Python :: 3.10', 35 | 'Programming Language :: Python :: 3.11', 36 | 'Programming Language :: Python :: 3.12', 37 | 'Programming Language :: Python :: 3.13', 38 | 'Programming Language :: Python :: 3 :: Only', 39 | 'Programming Language :: Python :: Implementation :: CPython', 40 | 'Programming Language :: Python :: Implementation :: PyPy', ] 41 | ) 42 | 43 | """ 44 | A brief checklist for release: 45 | 46 | * tox 47 | * git commit (if applicable) 48 | * Bump setup.py version off of -dev 49 | * git commit -a -m "bump version for vx.y.z release" 50 | * rm -rf dist/* 51 | * python setup.py sdist bdist_wheel 52 | * twine upload dist/* 53 | * bump docs/conf.py version 54 | * git commit 55 | * git tag -a vx.y.z -m "brief summary" 56 | * write CHANGELOG (TODO) 57 | * git commit 58 | * bump setup.py version onto n+1 dev 59 | * git commit 60 | * git push 61 | 62 | """ 63 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | python_envs = py38,py39,py310,py311,py312,py313,pypy3 3 | envlist = {[tox]python_envs},coverage-report,packaging 4 | 5 | [testenv] 6 | # setenv = VIRTUALENV_PIP=20.0.0 7 | changedir = .tox 8 | deps = -rrequirements.txt 9 | commands = coverage run --parallel --rcfile {toxinidir}/.tox-coveragerc -m pytest --doctest-modules {envsitepackagesdir}/face {posargs} 10 | 11 | # Uses default basepython otherwise reporting doesn't work on Travis where 12 | # Python 3.6 is only available in 3.6 jobs. 13 | [testenv:coverage-report] 14 | depends = {[tox]python_envs} 15 | changedir = .tox 16 | deps = coverage 17 | commands = coverage combine --rcfile {toxinidir}/.tox-coveragerc 18 | coverage report --rcfile {toxinidir}/.tox-coveragerc 19 | coverage html --rcfile {toxinidir}/.tox-coveragerc -d {toxinidir}/htmlcov 20 | 21 | [testenv:packaging] 22 | changedir = {toxinidir} 23 | deps = 24 | check-manifest==0.50 25 | readme_renderer 26 | commands = 27 | check-manifest --ignore '**/venv/**' 28 | python setup.py check --metadata --restructuredtext --strict 29 | 30 | 31 | [testenv:syntax-upgrade] 32 | changedir = {toxinidir} 33 | deps = 34 | flynt 35 | pyupgrade 36 | commands = 37 | flynt ./face 38 | python -c "import glob; import subprocess; [subprocess.run(['pyupgrade', '--py38-plus', f]) for f in glob.glob('./face/**/*.py', recursive=True)]" --------------------------------------------------------------------------------