├── .editorconfig ├── .gitignore ├── .pylintrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── ayo ├── __init__.py ├── scope.py └── utils.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── test_ayo.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.py] 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #### joe made this: http://goel.io/joe 2 | 3 | #####=== Python ===##### 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg* 26 | .installed.cfg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports / linters 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.sqlite 46 | public_html/ 47 | .mypy_cache 48 | .pytest_cache 49 | .vscode 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | 58 | # Sphinx documentation 59 | docs/_build/ 60 | 61 | # PyBuilder 62 | target/ 63 | 64 | *.pid 65 | *.bz2 66 | *.zip 67 | 68 | # unit tests 69 | twistd.hostname 70 | runtests 71 | htmlcov 72 | 73 | # editors 74 | *.sublime* 75 | .vscode 76 | 77 | # tempfile 78 | *.tmp 79 | *~ 80 | 81 | *.sqlite 82 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS __pycache__ 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns=^\. 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. 21 | jobs=1 22 | 23 | # List of plugins (as comma separated values of python modules names) to load, 24 | # usually to register additional checkers. 25 | load-plugins= 26 | 27 | # Pickle collected data for later comparisons. 28 | persistent=yes 29 | 30 | # Specify a configuration file. 31 | #rcfile= 32 | 33 | # When enabled, pylint would attempt to guess common misconfiguration and emit 34 | # user-friendly hints instead of false-positive error messages 35 | suggestion-mode=yes 36 | 37 | # Allow loading of arbitrary C extensions. Extensions are imported into the 38 | # active Python interpreter and may run arbitrary code. 39 | unsafe-load-any-extension=no 40 | 41 | 42 | [MESSAGES CONTROL] 43 | 44 | # Only show warnings with the listed confidence levels. Leave empty to show 45 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 46 | confidence= 47 | 48 | # Disable the message, report, category or checker with the given id(s). You 49 | # can either give multiple identifiers separated by comma (,) or put this 50 | # option multiple times (only on the command line, not in the configuration 51 | # file where it should appear only once).You can also use "--disable=all" to 52 | # disable everything first and then reenable specific checks. For example, if 53 | # you want to run only the similarities checker, you can use "--disable=all 54 | # --enable=similarities". If you want to run only the classes checker, but have 55 | # no Warning level messages displayed, use"--disable=all --enable=classes 56 | # --disable=W" 57 | disable=print-statement, 58 | parameter-unpacking, 59 | unpacking-in-except, 60 | old-raise-syntax, 61 | backtick, 62 | long-suffix, 63 | old-ne-operator, 64 | old-octal-literal, 65 | import-star-module-level, 66 | non-ascii-bytes-literal, 67 | invalid-unicode-literal, 68 | raw-checker-failed, 69 | bad-inline-option, 70 | locally-disabled, 71 | locally-enabled, 72 | file-ignored, 73 | suppressed-message, 74 | useless-suppression, 75 | deprecated-pragma, 76 | apply-builtin, 77 | basestring-builtin, 78 | buffer-builtin, 79 | cmp-builtin, 80 | coerce-builtin, 81 | execfile-builtin, 82 | file-builtin, 83 | long-builtin, 84 | raw_input-builtin, 85 | reduce-builtin, 86 | standarderror-builtin, 87 | unicode-builtin, 88 | xrange-builtin, 89 | coerce-method, 90 | delslice-method, 91 | getslice-method, 92 | setslice-method, 93 | no-absolute-import, 94 | old-division, 95 | dict-iter-method, 96 | dict-view-method, 97 | next-method-called, 98 | metaclass-assignment, 99 | indexing-exception, 100 | raising-string, 101 | reload-builtin, 102 | oct-method, 103 | hex-method, 104 | nonzero-method, 105 | cmp-method, 106 | input-builtin, 107 | round-builtin, 108 | intern-builtin, 109 | unichr-builtin, 110 | map-builtin-not-iterating, 111 | zip-builtin-not-iterating, 112 | range-builtin-not-iterating, 113 | filter-builtin-not-iterating, 114 | using-cmp-argument, 115 | eq-without-hash, 116 | div-method, 117 | idiv-method, 118 | rdiv-method, 119 | exception-message-attribute, 120 | invalid-str-codec, 121 | sys-max-int, 122 | bad-python3-import, 123 | deprecated-string-function, 124 | deprecated-str-translate-call, 125 | deprecated-itertools-function, 126 | deprecated-types-field, 127 | next-method-defined, 128 | dict-items-not-iterating, 129 | dict-keys-not-iterating, 130 | dict-values-not-iterating, 131 | deprecated-operator-function, 132 | deprecated-urllib-function, 133 | xreadlines-attribute, 134 | deprecated-sys-function, 135 | exception-escape, 136 | comprehension-escape, 137 | expression-not-assigned, 138 | 139 | # custo 140 | bad-continuation, 141 | inconsistent-return-statements, 142 | C0326, 143 | C0330 144 | 145 | # Enable the message, report, category or checker with the given id(s). You can 146 | # either give multiple identifier separated by comma (,) or put this option 147 | # multiple time (only on the command line, not in the configuration file where 148 | # it should appear only once). See also the "--disable" option for examples. 149 | enable=c-extension-no-member 150 | 151 | 152 | [REPORTS] 153 | 154 | # Python expression which should return a note less than 10 (10 is the highest 155 | # note). You have access to the variables errors warning, statement which 156 | # respectively contain the number of errors / warnings messages and the total 157 | # number of statements analyzed. This is used by the global evaluation report 158 | # (RP0004). 159 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 160 | 161 | # Template used to display messages. This is a python new-style format string 162 | # used to format the message information. See doc for all details 163 | #msg-template= 164 | 165 | # Set the output format. Available formats are text, parseable, colorized, json 166 | # and msvs (visual studio).You can also give a reporter class, eg 167 | # mypackage.mymodule.MyReporterClass. 168 | output-format=text 169 | 170 | # Tells whether to display a full report or only the messages 171 | reports=no 172 | 173 | # Activate the evaluation score. 174 | score=yes 175 | 176 | 177 | [REFACTORING] 178 | 179 | # Maximum number of nested blocks for function / method body 180 | max-nested-blocks=5 181 | 182 | # Complete name of functions that never returns. When checking for 183 | # inconsistent-return-statements if a never returning function is called then 184 | # it will be considered as an explicit return statement and no message will be 185 | # printed. 186 | never-returning-functions=optparse.Values,sys.exit 187 | 188 | 189 | [BASIC] 190 | 191 | # Naming style matching correct argument names 192 | argument-naming-style=snake_case 193 | 194 | # Regular expression matching correct argument names. Overrides argument- 195 | # naming-style 196 | #argument-rgx= 197 | 198 | # Naming style matching correct attribute names 199 | attr-naming-style=snake_case 200 | 201 | # Regular expression matching correct attribute names. Overrides attr-naming- 202 | # style 203 | #attr-rgx= 204 | 205 | # Bad variable names which should always be refused, separated by a comma 206 | bad-names=foo, 207 | bar, 208 | baz, 209 | toto, 210 | tutu, 211 | tata 212 | 213 | # Naming style matching correct class attribute names 214 | class-attribute-naming-style=any 215 | 216 | # Regular expression matching correct class attribute names. Overrides class- 217 | # attribute-naming-style 218 | #class-attribute-rgx= 219 | 220 | # Naming style matching correct class names 221 | class-naming-style=PascalCase 222 | 223 | # Regular expression matching correct class names. Overrides class-naming-style 224 | #class-rgx= 225 | 226 | # Naming style matching correct constant names 227 | const-naming-style=UPPER_CASE 228 | 229 | # Regular expression matching correct constant names. Overrides const-naming- 230 | # style 231 | #const-rgx= 232 | 233 | # Minimum line length for functions/classes that require docstrings, shorter 234 | # ones are exempt. 235 | docstring-min-length=-1 236 | 237 | # Naming style matching correct function names 238 | function-naming-style=snake_case 239 | 240 | # Regular expression matching correct function names. Overrides function- 241 | # naming-style 242 | #function-rgx= 243 | 244 | # Good variable names which should always be accepted, separated by a comma 245 | good-names=i, 246 | n, 247 | e, 248 | f, 249 | tb, 250 | ns, 251 | _ 252 | 253 | # Include a hint for the correct naming format with invalid-name 254 | include-naming-hint=no 255 | 256 | # Naming style matching correct inline iteration names 257 | inlinevar-naming-style=any 258 | 259 | # Regular expression matching correct inline iteration names. Overrides 260 | # inlinevar-naming-style 261 | #inlinevar-rgx= 262 | 263 | # Naming style matching correct method names 264 | method-naming-style=snake_case 265 | 266 | # Regular expression matching correct method names. Overrides method-naming- 267 | # style 268 | #method-rgx= 269 | 270 | # Naming style matching correct module names 271 | module-naming-style=snake_case 272 | 273 | # Regular expression matching correct module names. Overrides module-naming- 274 | # style 275 | #module-rgx= 276 | 277 | # Colon-delimited sets of names that determine each other's naming style when 278 | # the name regexes allow several styles. 279 | name-group= 280 | 281 | # Regular expression which should only match function or class names that do 282 | # not require a docstring. 283 | no-docstring-rgx=^_ 284 | 285 | # List of decorators that produce properties, such as abc.abstractproperty. Add 286 | # to this list to register other decorators that produce valid properties. 287 | property-classes=abc.abstractproperty 288 | 289 | # Naming style matching correct variable names 290 | variable-naming-style=snake_case 291 | 292 | # Regular expression matching correct variable names. Overrides variable- 293 | # naming-style 294 | #variable-rgx= 295 | 296 | 297 | [FORMAT] 298 | 299 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 300 | expected-line-ending-format= 301 | 302 | # Regexp for a line that is allowed to be longer than the limit. 303 | ignore-long-lines=^\s*(# )??$ 304 | 305 | # Number of spaces of indent required inside a hanging or continued line. 306 | indent-after-paren=4 307 | 308 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 309 | # tab). 310 | indent-string=' ' 311 | 312 | # Maximum number of characters on a single line. 313 | max-line-length=88 314 | 315 | # Maximum number of lines in a module 316 | max-module-lines=1000 317 | 318 | # List of optional constructs for which whitespace checking is disabled. `dict- 319 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 320 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 321 | # `empty-line` allows space-only lines. 322 | no-space-check=trailing-comma, 323 | dict-separator 324 | 325 | # Allow the body of a class to be on the same line as the declaration if body 326 | # contains single statement. 327 | single-line-class-stmt=no 328 | 329 | # Allow the body of an if to be on the same line as the test if there is no 330 | # else. 331 | single-line-if-stmt=no 332 | 333 | 334 | [LOGGING] 335 | 336 | # Logging modules to check that the string format arguments are in logging 337 | # function parameter format 338 | logging-modules=logging 339 | 340 | 341 | [MISCELLANEOUS] 342 | 343 | # List of note tags to take in consideration, separated by a comma. 344 | notes= 345 | #FIXME, 346 | #XXX, 347 | #TODO 348 | 349 | 350 | [SIMILARITIES] 351 | 352 | # Ignore comments when computing similarities. 353 | ignore-comments=yes 354 | 355 | # Ignore docstrings when computing similarities. 356 | ignore-docstrings=yes 357 | 358 | # Ignore imports when computing similarities. 359 | ignore-imports=no 360 | 361 | # Minimum lines number of a similarity. 362 | min-similarity-lines=4 363 | 364 | 365 | [SPELLING] 366 | 367 | # Limits count of emitted suggestions for spelling mistakes 368 | max-spelling-suggestions=4 369 | 370 | # Spelling dictionary name. Available dictionaries: en (aspell), en_CA 371 | # (aspell), en_GB (aspell), en_US (aspell), fr_BE (myspell), fr_CA (myspell), 372 | # fr_CH (myspell), fr_FR (myspell), fr_LU (myspell), fr_MC (myspell), fr 373 | # (myspell), en_AU (myspell), en_ZA (myspell). 374 | spelling-dict= 375 | 376 | # List of comma separated words that should not be checked. 377 | spelling-ignore-words= 378 | 379 | # A path to a file that contains private dictionary; one word per line. 380 | spelling-private-dict-file= 381 | 382 | # Tells whether to store unknown words to indicated private dictionary in 383 | # --spelling-private-dict-file option instead of raising a message. 384 | spelling-store-unknown-words=no 385 | 386 | 387 | [TYPECHECK] 388 | 389 | # List of decorators that produce context managers, such as 390 | # contextlib.contextmanager. Add to this list to register other decorators that 391 | # produce valid context managers. 392 | contextmanager-decorators=contextlib.contextmanager 393 | 394 | # List of members which are set dynamically and missed by pylint inference 395 | # system, and so shouldn't trigger E1101 when accessed. Python regular 396 | # expressions are accepted. 397 | generated-members= 398 | 399 | # Tells whether missing members accessed in mixin class should be ignored. A 400 | # mixin class is detected if its name ends with "mixin" (case insensitive). 401 | ignore-mixin-members=yes 402 | 403 | # This flag controls whether pylint should warn about no-member and similar 404 | # checks whenever an opaque object is returned when inferring. The inference 405 | # can return multiple potential results while evaluating a Python object, but 406 | # some branches might not be evaluated, which results in partial inference. In 407 | # that case, it might be useful to still emit no-member and other checks for 408 | # the rest of the inferred objects. 409 | ignore-on-opaque-inference=yes 410 | 411 | # List of class names for which member attributes should not be checked (useful 412 | # for classes with dynamically set attributes). This supports the use of 413 | # qualified names. 414 | ignored-classes=optparse.Values,thread._local,_thread._local 415 | 416 | # List of module names for which member attributes should not be checked 417 | # (useful for modules/projects where namespaces are manipulated during runtime 418 | # and thus existing member attributes cannot be deduced by static analysis. It 419 | # supports qualified module names, as well as Unix pattern matching. 420 | ignored-modules= 421 | 422 | # Show a hint with possible names when a member name was not found. The aspect 423 | # of finding the hint is based on edit distance. 424 | missing-member-hint=yes 425 | 426 | # The minimum edit distance a name should have in order to be considered a 427 | # similar match for a missing member name. 428 | missing-member-hint-distance=1 429 | 430 | # The total number of similar names that should be taken in consideration when 431 | # showing a hint for a missing member. 432 | missing-member-max-choices=1 433 | 434 | 435 | [VARIABLES] 436 | 437 | # List of additional names supposed to be defined in builtins. Remember that 438 | # you should avoid to define new builtins when possible. 439 | additional-builtins= 440 | 441 | # Tells whether unused global variables should be treated as a violation. 442 | allow-global-unused-variables=yes 443 | 444 | # List of strings which can identify a callback function by name. A callback 445 | # name must start or end with one of those strings. 446 | callbacks=cb_, 447 | _cb 448 | 449 | # A regular expression matching the name of dummy variables (i.e. expectedly 450 | # not used). 451 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 452 | 453 | # Argument names that match this expression will be ignored. Default to name 454 | # with leading underscore 455 | ignored-argument-names=_.*|^ignored_|^unused_ 456 | 457 | # Tells whether we should check for unused import in __init__ files. 458 | init-import=no 459 | 460 | # List of qualified module names which can have objects that can redefine 461 | # builtins. 462 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,io 463 | 464 | 465 | [CLASSES] 466 | 467 | # List of method names used to declare (i.e. assign) instance attributes. 468 | defining-attr-methods=__init__, 469 | __new__, 470 | setUp 471 | 472 | # List of member names, which should be excluded from the protected access 473 | # warning. 474 | exclude-protected=_asdict, 475 | _fields, 476 | _replace, 477 | _source, 478 | _make 479 | 480 | # List of valid names for the first argument in a class method. 481 | valid-classmethod-first-arg=cls 482 | 483 | # List of valid names for the first argument in a metaclass class method. 484 | valid-metaclass-classmethod-first-arg=mcs 485 | 486 | 487 | [DESIGN] 488 | 489 | # Maximum number of arguments for function / method 490 | max-args=5 491 | 492 | # Maximum number of attributes for a class (see R0902). 493 | max-attributes=10 494 | 495 | # Maximum number of boolean expressions in a if statement 496 | max-bool-expr=5 497 | 498 | # Maximum number of branch for function / method body 499 | max-branches=12 500 | 501 | # Maximum number of locals for function / method body 502 | max-locals=15 503 | 504 | # Maximum number of parents for a class (see R0901). 505 | max-parents=7 506 | 507 | # Maximum number of public methods for a class (see R0904). 508 | max-public-methods=20 509 | 510 | # Maximum number of return / yield for function / method body 511 | max-returns=6 512 | 513 | # Maximum number of statements in function / method body 514 | max-statements=50 515 | 516 | # Minimum number of public methods for a class (see R0903). 517 | min-public-methods=2 518 | 519 | 520 | [IMPORTS] 521 | 522 | # Allow wildcard imports from modules that define __all__. 523 | allow-wildcard-with-all=no 524 | 525 | # Analyse import fallback blocks. This can be used to support both Python 2 and 526 | # 3 compatible code, which means that the block might have code that exists 527 | # only in one or another interpreter, leading to false positives when analysed. 528 | analyse-fallback-blocks=no 529 | 530 | # Deprecated modules which should not be used, separated by a comma 531 | deprecated-modules=optparse,tkinter.tix 532 | 533 | # Create a graph of external dependencies in the given file (report RP0402 must 534 | # not be disabled) 535 | ext-import-graph= 536 | 537 | # Create a graph of every (i.e. internal and external) dependencies in the 538 | # given file (report RP0402 must not be disabled) 539 | import-graph= 540 | 541 | # Create a graph of internal dependencies in the given file (report RP0402 must 542 | # not be disabled) 543 | int-import-graph= 544 | 545 | # Force import order to recognize a module as part of the standard 546 | # compatibility libraries. 547 | known-standard-library= 548 | 549 | # Force import order to recognize a module as part of a third party library. 550 | known-third-party=enchant 551 | 552 | 553 | [EXCEPTIONS] 554 | 555 | # Exceptions that will emit a warning when being caught. Defaults to 556 | # "Exception" 557 | overgeneral-exceptions=Exception,BaseException 558 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tygs/ayo/27b2225770581e19f3abdb8db0721776f0cfb195/CHANGELOG.md -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2018 sametmax.com 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # USE ANYIO INSTEAD 3 | 4 | anyio has become the standard for those kind of things now, so I'm going to archive this project, and you should use anyio instead: http://anyio.readthedocs.io/ 5 | 6 | # WARNING 7 | 8 | While most of the examples work and are unit tested, the API is still moving a lot and we have zero doc. Don't spend too much time on here. 9 | 10 | 11 | # ayo: High level API for asyncio that integrates well with non ayo code 12 | 13 | Ayo let you focus on using asyncio instead of dealing with it. It has shortcuts for common operations, and offer sane tools to do the complicated things. The default behavior is to do most of the boiler plate for you, but you can opt out of anything you want, and take back control, or delegate control to another code that doesn't use ayo. 14 | 15 | Among the features: 16 | 17 | - Minimal boiler plate setup 18 | - A port of Trio's nurseries, including cancellation. We called them `execution scopes` here. 19 | - Syntaxic sugar for common operations 20 | - Easy time out 21 | - Easy concurrency limit 22 | - Well behaved scheduled tasks 23 | 24 | Incomming: 25 | 26 | - Helpers to create cancallable callbacks when you run them in executors. 27 | - Mechanisms to react to changing the loop, the loop policy or the task factory. Or a locking mechanism in the future. Not sure yet. 28 | - Protocols that allow async/await. 29 | 30 | Each feature is optional but on by default and always at reach when you need them. 31 | 32 | ayo does **not** provide a different async system. It embraces asyncio, so you can use ayo inside or above other codes that use asyncio. It's completly compatible with asyncio and requires zero rewrite or lock in. You can mix ayo and pure asyncio code transparently. 33 | 34 | ayo is **not** a framework. It only makes asyncio easier and safer to use. It does nothing else. 35 | 36 | - Documentation: tutorial, index, api, download 37 | - Supported Python : CPython 3.6+ 38 | - Install : `pip install ayo` or download from Pypi 39 | - Licence : MIT 40 | - Source code : `git clone http://github.com/tygs/ayo` 41 | 42 | # Examples 43 | 44 | ## Hello world 45 | 46 | ```python 47 | import ayo 48 | import asyncio 49 | 50 | @ayo.run_as_main() 51 | async def main(run): 52 | await asyncio.sleep(3) 53 | print('Hello !') 54 | ``` 55 | 56 | `run` here, is the root execution scope of your program. It's what set the boundaries in which your tasks can execute safely: tasks you run inside the scope are guaranteed to be done outside of the scope. You can create and nest as many scopes as you need. 57 | 58 | To learn about how this works, follow the tutorial. 59 | 60 | To learn about how you can still use regular asyncio code, opt out of the autorun, use an already running even loop or get the ayo event loop back, read the dedicated part of the documentation. 61 | 62 | ## Execution scope (aka Trio's nursery) 63 | 64 | Execution scopes are a tool to give you garanties about you concurrent tasks: 65 | 66 | ```python 67 | 68 | import random 69 | import asyncio 70 | 71 | import ayo 72 | 73 | async def zzz(): 74 | time = random.randint(0, 15) 75 | await asyncio.sleep(time) 76 | print(f'Slept for {time} seconds') 77 | 78 | async with ayo.scope(max_concurrency=10, timeout=12) as run: 79 | for _ in range(20): 80 | run << zzz() # schedule the coroutine for execution 81 | ``` 82 | 83 | Here, no more than 10 tasks will be run in parallel in this scope, which is delimited by the `async with` block. If an exception is raised in any task of this scope, all the tasks in the same scope are cancelled, then the exception bubbles out of the `with` block like in regular Python code. If no exception is raised, after 12 seconds, if some tasks are still running, they are cancelled and the `with` block exits. 84 | 85 | Any code *after* the `with` block is guaranteed to happend *only* after all the tasks are completed or cancelled. This is one of the benefits of execution scopes. To learn more about the execution scopes, go to the dedicated part of documentation. 86 | 87 | ayo also comes with a lot of short hands. Here you can see `run << stuff()` which is just syntaxic sugar for `run.asap(stuff())`. And `run.asap(stuff())` is nothing more than a safer version of `asyncio.ensure_future(stuff())` restricted to the current scope. 88 | 89 | The running a bunch of task is a common case, and so we provide a shorter way to express it: 90 | 91 | ```python 92 | async with ayo.scope() as run: 93 | run.all(zzz(), zzz(), zzz()) 94 | ``` 95 | 96 | And you can cancel all tasks running in this scope with by calling `run.cancel()`. 97 | 98 | Learn more in the dedicated part of the documentation. 99 | 100 | ## A more advanced example 101 | 102 | ```python 103 | import ayo 104 | 105 | @ayo.run_as_main() 106 | async def main(run_in_top): 107 | 108 | print('Top of the program') 109 | run_in_top << anything() 110 | 111 | async with ayo.scope() as run: 112 | for stuff in stuff_to_do: 113 | print('Deeper into the program') 114 | run << stuff() 115 | 116 | async with ayo.scope(timeout=0.03) as subrun: 117 | print('Deep inside the program') 118 | subrun << foo() 119 | subrun << bar() 120 | 121 | if subrun.timeout: 122 | run.cancel() 123 | else: 124 | for res in subrun.results: 125 | print(res) 126 | ``` 127 | 128 | This example have 3 nested scopes: `run_in_top`, `run` and `subrun`. 129 | 130 | `foo()` and `bar()` execute concurrently, but have a 0.03 seconds time limit to finish. After that, they get cancelled automatially. Later, in the `else`: we print the results of all the tasks, but only if there was no timeout. 131 | 132 | All `stuff()` execute concurrently with each others, they start before `foo()` and `bar()`, execute concurrently with them too, and keep executing after them. However, in the `if` clause, we check if `subrun` has timed out, and if yes, we cancel all the tasks in `run`. 133 | 134 | `anything()` starts before the tasks in `run`, execute concurrently to them, and continue to execute after them. 135 | 136 | The whole program ends when `run` (or `anything()`, since it's the only task in `run`) finishes or get cancelled (e.g: if the user hit ctrl + c). 137 | 138 | This example illustrate the concepts, but can't be executed. For fully functional and realistics examples, check out the example directory in the source code. 139 | 140 | ## Executing blocking code 141 | 142 | Some code will block the event loop for a long time, preventing your tasks to execute. This can happen if the code is doing very long heavy calculations, or wait for I/O without using asyncio. 143 | 144 | Such code may be doing file processing, database querying, using the urllib, smtp or requests modules, etc. 145 | 146 | In that case, you can put the blocking code in a function, and pass it to `aside()`: 147 | 148 | ```python 149 | ayo.aside(callback, foo, bar=1) 150 | ``` 151 | 152 | This will call `callback(foo, bar=1)` in the default asyncio executor, which, if you haved changed done anything, will be a `ThreadPoolExcecutor` with `(os.cpu_count() or 1) * 5` workers. It's similar to `asyncio.get_event_loop().run_in_executor(None, lambda: callback(foo, bar=1))`, but binds the task to the current scope like `asap()`. 153 | 154 | What this mean, is that the blocking code with not lock your event loop anymore. 155 | 156 | You can also choose a different executor doing: 157 | 158 | ```python 159 | ayo.aside_in(executor, callback, foo, bar=1) 160 | ``` 161 | 162 | or: 163 | 164 | ```python 165 | ayo.aside_in('notifications', callback, foo, bar=1) 166 | ``` 167 | 168 | Provided you created an executor named 'notifications' with ayo before that. 169 | 170 | Learn more about handling blocking code in the dedicated part of the documentation. 171 | 172 | **Be careful!** 173 | 174 | Tasks scheduled with `aside()` are NOT included in the limit of `max_concurrency`. Indeed, only tasks running in the event loop are limited by `max_concurrency`. Tasks scheduled by `aside()` and `aside_in()` are running in an executor. 175 | 176 | Executors are "objects executing things", so they potentially could do anything, but in practice, they are mainly doing either: 177 | 178 | - a thread pool 179 | - a process pool 180 | 181 | So your tasks will run in a separate thread or process. If they run in a thread, they won't block I/O, but will share the CPU ressources. If they run in another process, they may run on another CPU but will consume more memory and take longer to be sent back and forth. 182 | 183 | Concurrency in that case, is limited by the number of workers. 184 | 185 | If you just use `aside()`, it will use the default executor, and if you didn't setup anything, the default executor for the default asyncio loop is a thread pool with `(os.cpu_count() or 1) * 5` workers. 186 | 187 | 188 | ## Scheduled tasks 189 | 190 | ```python 191 | 192 | import ayo 193 | 194 | import datetime as dt 195 | 196 | @ayo.run_as_main() 197 | async def main(run): 198 | run.after(2, callback, foo, bar=1) 199 | run.at(dt.datetime(2018, 12, 1), callback, foo, bar=1) 200 | run.every(0.2, callback, foo, bar=1) 201 | ``` 202 | 203 | `run.after(2, callback, foo, bar=1)` calls `callback(foo, bar=1)` after 2 seconds. 204 | 205 | `run.at(dt.datetime(2018, 12, 1), callback, foo, bar=1)` calls `callback(foo, bar=1)` after december 1st, 2018. 206 | 207 | `run.every(0.2, callback, foo, bar=1)` calls `callback(foo, bar=1)` every 200ms again and again. 208 | 209 | There is no task queue and no task peristence. We only use `asyncio` mechanisms. This also means the timing is limited to the precision provided by `asyncio`. 210 | 211 | Learn more about scheduled tasks in the dedicated part of the documentation. 212 | 213 | **Be careful!** 214 | 215 | The scope will exit only when all tasks have finished or have been cancelled. If you set a task far away in the future, the `with` will stay active until then. If you set a recurring task, the `with` will stay active forever, or until you stop the task manually with `unschedule()` or `cancel()`. 216 | 217 | If it's a problem, use `dont_hold_exit()`: 218 | 219 | ```python 220 | 221 | import ayo 222 | 223 | import datetime as dt 224 | 225 | @ayo.run_as_main() 226 | async def main(run): 227 | run.after(2, callback, foo, bar=1).dont_hold_exit() 228 | run.at(dt.datetime(2018, 12, 1), callback, foo, bar=1).dont_hold_exit() 229 | run.every(0.2, callback, foo, bar=1).dont_hold_exit() 230 | ``` 231 | 232 | ## Saving RAM 233 | 234 | If you use `max_concurrency` with a low value but attach a lot of coroutines to your scope, you will have many coroutines objects eating up RAM but not actually being scheduled. 235 | 236 | For this particular case, if you want to save up memory, you can use `Scope.from_callable()`: 237 | 238 | 239 | ```python 240 | import asyncio 241 | 242 | import ayo 243 | 244 | async def zzz(seconds): 245 | await asyncio.sleep(seconds) 246 | print(f'Slept for {seconds} seconds') 247 | 248 | @ayo.run_as_main() 249 | async def main(run_in_top): 250 | 251 | async with ayo.scope(max_concurrency=10) as run: 252 | # A lot of things in the waiting queue, but only 10 can execute at the 253 | # same time, so most of them do nothing and eat up memory. 254 | for _ in range(10000): 255 | # Pass a callable here (e.g: function), not an awaitable (e.g: coroutine) 256 | # So do: 257 | run.from_callable(zzz, 0.01) 258 | # But NOT: 259 | # run.from_callable(zzz(0.01)) 260 | ``` 261 | 262 | The callable must always return an awaitable. Any `async def` function reference will hence naturally be accepted. 263 | 264 | `run.from_callable()` will store the reference of the callable and its parameters, and only call it to get the awaitable at the very last moment. If you use a lot of similar combinaisons of 265 | callables and parameters, this will store only references to them instead of a new coroutine object 266 | everytime. This can add up to a lot of memory. 267 | 268 | This feature is mostly for this specific case, and you should not bother with it unless you 269 | are in this exact situation. 270 | 271 | Another use case would be if you want to dynamically create the awaitable at the last minute from a 272 | factory. 273 | 274 | ## Playing well with others 275 | 276 | ### Just do as usual for simple cases 277 | 278 | Most asyncio code hook on the currently running loop, and so can be used as is. Example with aiohttp client: 279 | 280 | ```python 281 | 282 | import ayo 283 | 284 | import aiohttp 285 | 286 | URLS = [ 287 | "https://www.python.org/", 288 | "https://pypi.python.org/", 289 | "https://docs.python.org/" 290 | ] 291 | 292 | # Regular aiohttp code that send HTTP GET a URL and print the size the response 293 | async def fetch(url): 294 | async with aiohttp.request('GET', 'http://python.org/') as resp: 295 | print(url, "content size:", len(await response.text())) 296 | 297 | @ayo.run_as_main() 298 | async def main(run): 299 | # Just run your coroutine in the scope and you are good to go 300 | for url in URLS: 301 | run << fetch(url) 302 | 303 | # or use run.map(fetch, URLS) 304 | ``` 305 | 306 | This code actually works. Try it. 307 | 308 | ### Giving up the control of the loop 309 | 310 | ayo starts the loop for you, but this may not be what you want. This may not be what the rest of the code expects. You can tell ayo to give up the control of the loop life cycle: 311 | 312 | ```python 313 | 314 | import asyncio 315 | 316 | import ayo 317 | 318 | async def main(): 319 | with ayo.scope as run(): 320 | await run.sleep(3) 321 | print('Hello !') 322 | 323 | loop = asyncio.get_event_loop() 324 | loop.run_until_complete(main()) 325 | ``` 326 | 327 | `ayo` doesn't need a main function to run, it's just a convenient wrapper. It also ensure all your code run in a scope, while you do not have this guaranty if you do everything manuelly. 328 | 329 | To learn more about the tradeoff, read the dedicated part of the documentation. 330 | 331 | ### React to life cycle hooks 332 | 333 | ayo provide several hooks on which you can plug your code to react to things like the `main()` function starting or stopping, the current event loop stopping, or closing, etc. Example, running a countown just before leaving: 334 | 335 | ```python 336 | 337 | import asyncio 338 | 339 | import ayo 340 | 341 | @ayoc.on.stopping() 342 | async def the_final_count_down(run): 343 | for x in (3, 2, 1): 344 | asyncio.sleep(1) 345 | print(x) 346 | print('Good bye !') 347 | 348 | @ayoc.run_with_main(): 349 | ... 350 | ``` 351 | 352 | Available hooks: 353 | 354 | - ayo.on.starting: this ayo context main function is going to start. 355 | - ayo.on.started: this ayo context main function has started. 356 | - ayo.on.stopping: this ayo context main function is going to stop. 357 | - ayo.on.stopped: this ayo context main function has stopped. 358 | - ayo.on.loop.started: the current loop for this ayo context has started. 359 | - ayo.on.loop.closed: the current loop for this ayo context has stopped. 360 | - ayo.on.loop.set: the current loop for this ayo context has been set. 361 | - ayo.on.loop.created: a loop has been created the in the current context. 362 | - ayo.on.policy.set: the global loop policy has been set. 363 | 364 | By default ayo hooks to some of those, in particular to raise some warnings or exception. E.G: something is erasing ayo's custom loop policy. 365 | 366 | You can disable globally or selectively any hook. 367 | 368 | To learn more about life cycle hooks, read the dedicated part of the documentation. 369 | 370 | # TODO 371 | 372 | - Figure out a good story to facilitate multi-threading and enforce good practices 373 | - Figure out a good story to facilitate signal handling 374 | - Help on the integration with trio, twisted, tornado, qt, wx and tkinter event loops 375 | - Create helpers for popular libs. Example: aiohttp session could be also a scope, a helper for aiohttp.request... 376 | - Fast fail mode for when `run_until_complete()` is in used. 377 | 378 | -------------------------------------------------------------------------------- /ayo/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ayo: High level API for asyncio that integrates well with non ayo code 3 | 4 | Ayo let you focus on using asyncio instead of dealing with it. 5 | It has shortcuts for common operations, and offer sane tools to do 6 | the complicated things. The default behavior is to do most boiler plate 7 | for you, but you can opt out of anything you want, and take back control, 8 | or delegate control to another code that doesn't use ayo. 9 | 10 | Among the features: 11 | 12 | - Minimal boiler plate setup 13 | - A port of Trio's nurseries, including cancellation 14 | - Syntaxic sugar for common operations 15 | - Easy time out 16 | - Easy concurrency limit 17 | - Well behaved scheduled tasks 18 | - A proposed structure for your asyncio code 19 | - Mechanism to react to code changing the loop or loop policy 20 | 21 | Each feature is optional but on by default and always 22 | at reach near you need them. 23 | 24 | ayo does **not** provide a different async system. It embraces asyncio, 25 | so you can use ayo with other asyncio using code. 26 | 27 | ayo is **not** a framework. It only makes asyncio easier and safer to use. 28 | It does nothing else. 29 | 30 | - Documentation: 31 | - Supported Python : CPython 3.6+ 32 | - Install : `pip install ayo` or download from Pypi 33 | - Licence : MIT 34 | - Source code : `git clone http://github.com/tygs/ayo` 35 | """ 36 | from .scope import ExecutionScope as scope 37 | from .utils import run_as_main, run, pass_scope_and_run 38 | 39 | __version__ = "0.1.0" 40 | 41 | __all__ = ["scope", "run_as_main", "run", "pass_scope_and_run"] 42 | -------------------------------------------------------------------------------- /ayo/scope.py: -------------------------------------------------------------------------------- 1 | """ 2 | Machinery used to implement the Trio's nursery concept, here name 3 | "Scope" 4 | """ 5 | 6 | import asyncio 7 | 8 | from asyncio import ensure_future, Future 9 | 10 | from collections import deque 11 | from itertools import chain 12 | from enum import Enum 13 | 14 | from typing import Awaitable, Union, Callable 15 | 16 | from ayo.utils import FutureList, AsyncOnlyContextManager, LazyTask, LazyTaskFactory 17 | 18 | 19 | class ExecutionScope(AsyncOnlyContextManager): 20 | """ Attempt at recreating trio.nursery for asyncio """ 21 | 22 | # pylint: disable=too-many-instance-attributes 23 | 24 | class STATE(Enum): 25 | """ Scope life cycle """ 26 | 27 | INIT = 0 28 | ENTERED = 1 29 | EXITED = 2 30 | CANCELLED = 3 31 | TIMEDOUT = 4 32 | 33 | def __init__( 34 | self, loop=None, timeout=None, max_concurrency=None, return_exceptions=False 35 | ): 36 | 37 | assert timeout is None or timeout >= 0, "timeout must be > 0" 38 | 39 | # Parameters we will pass to gather() 40 | self._loop = loop 41 | self.return_exceptions = return_exceptions 42 | 43 | # All the awaitables we process on scope r 44 | self._lazy_task_queue = deque() 45 | self._scheduled_tasks_queue = deque() 46 | self._awaited_tasks = deque() 47 | 48 | # Results of all awaited tasks 49 | self.results = [] 50 | 51 | # How many tasks can run at the same time in the scope 52 | self.max_concurrency = max_concurrency 53 | # No need to make this check if no concurrency limit 54 | if max_concurrency is None: 55 | self._can_schedule_task = lambda: True 56 | else: 57 | self._can_schedule_task = ( 58 | lambda: len(self._scheduled_tasks_queue) < self.max_concurrency 59 | ) 60 | 61 | # Make sure we use the scope in the proper order of states 62 | self.state = self.STATE.INIT 63 | 64 | # To prevent the used of self.cancel() outside of the scope 65 | self._used_as_context_manager = False 66 | 67 | # Store the timeout time and reference to the handler 68 | self._timeout_handler = None 69 | self.timeout = timeout 70 | 71 | async def __aenter__(self): 72 | self._used_as_context_manager = True 73 | return await self.enter() 74 | 75 | async def __aexit__(self, exc_type, exc, tb): 76 | if exc_type: 77 | self._cancel_scope() 78 | else: 79 | # A cancellation may happen in the __aexit__ 80 | try: 81 | await self.exit() 82 | except asyncio.CancelledError: 83 | self._cancel_scope() 84 | return True 85 | 86 | return exc_type == asyncio.CancelledError 87 | 88 | def __lshift__(self, coro): 89 | """ Shortcut for self.assap """ 90 | return self.asap(coro) 91 | 92 | def asap(self, awaitable: Awaitable) -> Union[LazyTask, Future]: 93 | """ Execute the awaitable in the current scope as soon as possible """ 94 | 95 | loop = self._loop or asyncio.get_event_loop() 96 | 97 | if self.max_concurrency: 98 | 99 | if self._can_schedule_task(): 100 | # Schedule the task for execution in the event loop 101 | task = ensure_future(awaitable, loop=loop) 102 | self._scheduled_tasks_queue.append(task) 103 | else: 104 | # This is a future that is not a task. This way 105 | # it's not scheduling the awaitable on the event loop 106 | task = LazyTask(awaitable, loop=loop) 107 | 108 | # This future is in self._scheduled_tasks_queue anyway. So that 109 | # it is awaited at the scope resolution. 110 | self._scheduled_tasks_queue.append(task) 111 | 112 | # We then put the future in the another specific queue 113 | # that we will empty when the number of running concurrent 114 | # tasks goes below the max concurrency 115 | self._lazy_task_queue.append(task) 116 | 117 | # When the task is done, we want to be sure it schedules 118 | # the next task in the queue for execution in the loop 119 | task.add_done_callback(self._schedule_next_task) 120 | return task 121 | 122 | task = ensure_future(awaitable, loop=loop) 123 | self._scheduled_tasks_queue.append(task) 124 | return task 125 | 126 | def from_callable( 127 | self, factory: Callable[..., Awaitable], *args 128 | ) -> Union[LazyTaskFactory, Future]: 129 | """ Like asap(), but we accept a callable that will return the awaitable 130 | 131 | This can be used to save memory in case you have a lot of tasks using the 132 | same function and most are not scheduled for execution. 133 | """ 134 | 135 | # If the task is not scheduled immediatly, then we just store 136 | # the factory and it's args. The awaitable will created and passed 137 | # to a task at the last minute, which will save memory if 138 | # a lot of things are in the waiting queue and not scheduled. 139 | if self.max_concurrency and not self._can_schedule_task(): 140 | 141 | loop = self._loop or asyncio.get_event_loop() 142 | 143 | # Like a the LazyTask in asap(), but we pass what's needed to 144 | # build the awaitable at the last minute instead of the awaitable 145 | # itself 146 | task = LazyTaskFactory(factory, *args, loop=loop) 147 | 148 | # For the rest it's just like asap() 149 | self._scheduled_tasks_queue.append(task) 150 | self._lazy_task_queue.append(task) 151 | task.add_done_callback(self._schedule_next_task) 152 | return task 153 | 154 | # If there is no max_concurrency, we schedule the execution immidiatly 155 | # so it's like a regular asap() call 156 | return self.asap(factory(*args)) 157 | 158 | def _schedule_next_task( 159 | self, future: asyncio.Future = None # pylint: disable=W0613 160 | ) -> None: 161 | """ Schedule the next Lazy tasks from the queue for execution """ 162 | if self._lazy_task_queue: 163 | # TODO: document the fact max_concurrency is not recursive 164 | # TODO: affer an alternative scope design that allow recursive 165 | # max concurrency ? 166 | self._lazy_task_queue.popleft().schedule_for_execution() 167 | 168 | def all(self, *awaitables) -> FutureList: 169 | """ Schedule all tasks to be run in the current scope""" 170 | return FutureList(self.asap(awaitable) for awaitable in awaitables) 171 | 172 | async def enter(self): 173 | """ Set itself as the current scope """ 174 | assert self.state == self.STATE.INIT, "You can't enter a scope twice" 175 | # TODO: in debug mode only: 176 | self.state = self.STATE.ENTERED 177 | 178 | # Cancel all tasks in case of a timeout 179 | if self.timeout: 180 | self._timeout_handler = self.asap(self.trigger_timeout(self.timeout)) 181 | 182 | return self 183 | 184 | async def exit(self): 185 | """ Await all awaitables created in the scope or cancel them all """ 186 | assert self.state == self.STATE.ENTERED, "You can't exit a scope you are not in" 187 | 188 | if not self._scheduled_tasks_queue: 189 | self.cancel_timeout() 190 | return 191 | 192 | # Await all submitted tasks. The tasks may themself submit more 193 | # task, so we do it in a loop to exhaust all potential nested tasks 194 | while self._scheduled_tasks_queue: 195 | # TODO: collecting results 196 | tasks_to_run = tuple(self._scheduled_tasks_queue) 197 | self._scheduled_tasks_queue.clear() 198 | tasks = asyncio.gather( 199 | *tasks_to_run, loop=self._loop, return_exceptions=self.return_exceptions 200 | ) 201 | self._awaited_tasks.extend(tasks_to_run) 202 | self.results.extend(await tasks) 203 | 204 | self.cancel_timeout() 205 | 206 | self.state = self.STATE.EXITED 207 | 208 | async def trigger_timeout(self, seconds): 209 | """ sleep for n seconds and cancel the scope """ 210 | await asyncio.sleep(seconds) 211 | self.cancel() 212 | 213 | def cancel_timeout(self): 214 | """ Disable the timeout """ 215 | if self._timeout_handler: 216 | self._timeout_handler.cancel() 217 | 218 | def cancel(self): 219 | """ Exit the scope `with` block, cancelling all the tasks 220 | 221 | Only to be used inside the `with` block. If you call 222 | `enter()` and `exit()` manually, you should use 223 | `exit(cancel=True)`. 224 | """ 225 | assert ( 226 | self._used_as_context_manager 227 | ), "You can't call cancel() outside a `with` block" 228 | raise asyncio.CancelledError 229 | 230 | @property 231 | def cancelled(self): 232 | """ Has the scope being cancelled """ 233 | return self.state.value >= self.STATE.CANCELLED.value 234 | 235 | def _cancel_scope(self): 236 | assert ( 237 | self.state == self.STATE.ENTERED 238 | ), "You can't cancel a scope you are not in" 239 | 240 | self.cancel_timeout() 241 | 242 | for awaitable in chain(self._scheduled_tasks_queue, self._awaited_tasks): 243 | awaitable.cancel() 244 | self.state = self.STATE.CANCELLED 245 | -------------------------------------------------------------------------------- /ayo/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of helpers 3 | """ 4 | 5 | import asyncio 6 | 7 | from asyncio import gather, Task, Future, AbstractEventLoop, ensure_future 8 | 9 | 10 | from typing import Callable, Coroutine, Union, Awaitable 11 | 12 | import ayo 13 | 14 | __all__ = ["FutureList", "run_as_main", "run", "pass_scope_and_run", "LazyTask"] 15 | 16 | 17 | class FutureList(list): 18 | """ Syntaxic sugar to be able ease mass process of tasks """ 19 | 20 | def gather(self) -> asyncio.Task: 21 | """ Apply asyncio.gather on self""" 22 | return gather(*self) 23 | 24 | # TODO: implement that 25 | # async def as_completed(self): 26 | # for task in asyncio.as_completed(self.tasks): 27 | # yield (await task) 28 | 29 | 30 | class AsyncOnlyContextManager: 31 | """ Prevent the easy mistake of forgetting `async` before `with` """ 32 | 33 | def __enter__(self): 34 | raise TypeError('You must use "async with", not just "with"') 35 | 36 | def __exit__(self, exc_type, exc, tb): 37 | raise TypeError('You muse use "__aexit__" instead of "__exit__"') 38 | 39 | 40 | def run_as_main( 41 | timeout: Union[int, float] = None, 42 | max_concurrency: int = None, 43 | loop: AbstractEventLoop = None, 44 | ) -> Callable: 45 | """ Run this function as the main entry point of the asyncio program """ 46 | 47 | def decorator(coroutine: Coroutine) -> Coroutine: # pylint: disable=C0111 48 | pass_scope_and_run( 49 | coroutine, timeout=timeout, max_concurrency=max_concurrency, loop=loop 50 | ) 51 | return coroutine 52 | 53 | return decorator 54 | 55 | 56 | # TODO: test run 57 | def run( 58 | awaitables: Awaitable, 59 | timeout: Union[int, float] = None, 60 | max_concurrency: int = None, 61 | loop: AbstractEventLoop = None, 62 | return_coroutine: bool = False, 63 | ) -> None: 64 | """ Start the event loop and execute the awaitables in a scope """ 65 | 66 | @run_as_main() 67 | async def main_wrapper(scope): # pylint: disable=C0111 68 | scope.asap(*awaitables) 69 | 70 | return pass_scope_and_run( 71 | main_wrapper, 72 | timeout=timeout, 73 | max_concurrency=max_concurrency, 74 | loop=loop, 75 | return_coroutine=return_coroutine, 76 | ) 77 | 78 | 79 | # TODO: test pass_scope_and_run 80 | def pass_scope_and_run( 81 | *coroutines: Coroutine, 82 | timeout: Union[int, float] = None, 83 | max_concurrency: int = None, 84 | loop: AbstractEventLoop = None, 85 | return_coroutine: bool = False 86 | ) -> None: 87 | """Start the loop and execute the coros in a scope. Pass them the scope ref""" 88 | 89 | assert not ( 90 | loop and return_coroutine 91 | ), "`loop` and `return_coroutine` are incompatible" 92 | 93 | async def main_wrapper(): # pylint: disable=C0111 94 | async with ayo.scope(timeout=timeout, max_concurrency=max_concurrency) as scope: 95 | scope.all(*(coro(scope) for coro in coroutines)) 96 | 97 | if return_coroutine: 98 | return main_wrapper() 99 | 100 | loop = loop or asyncio.get_event_loop() 101 | loop.run_until_complete(main_wrapper()) 102 | 103 | 104 | class LazyTask(Future): 105 | """ A future linked to a unscheduled awaitable, that can be scheduled later """ 106 | 107 | def __init__(self, awaitable, *, loop=None): 108 | super().__init__(loop=loop) 109 | self._awaitable = awaitable 110 | 111 | def schedule_for_execution(self, awaitable: Awaitable = None) -> Future: 112 | """ Create a task from the awaitable 113 | 114 | Link the task resolution to the future resolution 115 | """ 116 | task = ensure_future( 117 | awaitable or self._awaitable, loop=self._loop # type: ignore 118 | ) 119 | task.add_done_callback(self._task_done_callback) # type: ignore 120 | return task 121 | 122 | def _task_done_callback(self, task: Task) -> None: 123 | """ After scheduling, when the related task is done, set the future result """ 124 | try: 125 | self.set_result(task.result()) 126 | except asyncio.CancelledError: 127 | self.cancel() 128 | 129 | 130 | class LazyTaskFactory(LazyTask): 131 | """ A future linked to a factory, creating an awaitable we schedule later """ 132 | 133 | def __init__(self, factory, *args, loop=None): # pylint: disable=W0231 134 | Future.__init__(self, loop=loop) # pylint: disable=W0233 135 | self._factory = factory 136 | self._args = args 137 | 138 | # pylint: disable=W0221 139 | def schedule_for_execution(self) -> Future: # type: ignore 140 | """ Create an awaitable from the factory, then a task from the awaitable 141 | 142 | Link the task resolution to the future resolution 143 | """ 144 | return super().schedule_for_execution(self._factory(*self._args)) 145 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | ; see: https://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files 2 | [metadata] 3 | name = ayo 4 | version = attr: ayo.__version__ 5 | description = "High level API for asyncio, but friendly with code that doesn't use it" 6 | long_description = file: README.md 7 | keywords = asyncio, nursery, event loop 8 | license = MIT 9 | classifiers = 10 | Programming Language :: Python :: 3 11 | Programming Language :: Python :: 3.6 12 | 13 | [options] 14 | zip_safe = False 15 | include_package_data = True 16 | packages = find: 17 | install_requires = 18 | typing; python_version<"3.6" 19 | 20 | [mypy] 21 | ignore_missing_imports=1 22 | follow_imports=silent 23 | 24 | [flake8] 25 | max-line-length = 88 26 | exclude = doc/*,build/*,.tox,.eggs 27 | max-complexity = 7 28 | 29 | [tool:pytest] 30 | addopts = -rsxX -q 31 | testpaths = tests 32 | 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Install: python setup.py install 3 | Dev mode: python setup.py develop 4 | Test: pip install pytest && pytest tests 5 | 6 | All the config is in setup.cfg 7 | """ 8 | 9 | import setuptools 10 | 11 | setuptools.setup() 12 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tygs/ayo/27b2225770581e19f3abdb8db0721776f0cfb195/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_ayo.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621,C0111,W0613,W0612,C0103,C0102 2 | 3 | """ 4 | Most basic tests for ayo 5 | """ 6 | 7 | 8 | import time 9 | import asyncio 10 | import itertools 11 | 12 | import datetime as dt 13 | 14 | from typing import Callable 15 | 16 | import pytest 17 | 18 | import ayo 19 | 20 | from ayo.scope import ExecutionScope 21 | from ayo.utils import LazyTask 22 | 23 | 24 | class Timer: 25 | """ Helper to calculate elapsed time """ 26 | 27 | def __init__(self): 28 | self.record() 29 | 30 | def record(self): 31 | """ Store the current time """ 32 | self.last_time = time.time() 33 | 34 | @property 35 | def elapsed(self): 36 | """ Return the time that has elapsed since the last record """ 37 | return time.time() - self.last_time 38 | 39 | def has_almost_elapsed(self, seconds, precision=1): 40 | """ Return True if `seconds` have approxitly passed since the last record """ 41 | return round(self.elapsed - seconds, precision) == 0 42 | 43 | 44 | class ExecutionCounter: 45 | def __init__(self, start=1): 46 | self.count = itertools.count(start) 47 | self.value = 0 48 | self.marks = set() 49 | 50 | def __call__(self, mark=None): 51 | """ Increment the counter. 52 | 53 | Pass a mark if you should make this call only once per unique 54 | value 55 | """ 56 | if mark is not None: 57 | if mark in self.marks: 58 | raise ValueError( 59 | "The mark '{mark}' has already been used".format(mark=mark) 60 | ) 61 | self.marks.add(mark) 62 | self.value = next(self.count) 63 | return self.value 64 | 65 | def __eq__(self, other): 66 | return self.value == other 67 | 68 | 69 | @pytest.fixture 70 | def count() -> Callable[[], int]: # pylint: disable=C0103 71 | return ExecutionCounter() 72 | 73 | 74 | @pytest.fixture 75 | def timer() -> Timer: 76 | return Timer() 77 | 78 | 79 | def test_version(): 80 | """ The version is accessible programmatically """ 81 | assert ayo.__version__ == "0.1.0" 82 | 83 | 84 | def test_context_run_with_main(count): 85 | """ run_with_main execute the coroutine """ 86 | 87 | @ayo.run_as_main() 88 | async def main(run): 89 | assert isinstance(run, ExecutionScope) 90 | count() 91 | 92 | assert count(), "The main() coroutine is called" 93 | 94 | 95 | def test_ayo_sleep(timer): 96 | """ ayo.sleep does block for the number of seconds expected """ 97 | 98 | @ayo.run_as_main() 99 | async def main(run): 100 | await asyncio.sleep(0.1) 101 | 102 | assert timer.has_almost_elapsed(0.1), "Sleep does make the code wait" 103 | 104 | 105 | def test_forgetting_async_with_on_scope_raises_exception(): 106 | """ We raise an exeption if sync with is used on scopes """ 107 | with pytest.raises(TypeError): 108 | with ayo.scope(): 109 | pass 110 | 111 | with pytest.raises(TypeError): 112 | ayo.scope().__exit__(None, None, None) 113 | 114 | 115 | def test_asap(count): 116 | """ asap execute the coroutine in the scope """ 117 | 118 | async def foo(mark): 119 | count(mark) 120 | 121 | @ayo.run_as_main() 122 | async def main(run): 123 | run.asap(foo(1)) 124 | run.asap(foo(2)) 125 | 126 | assert count == 2, "All coroutines have been called exactly once" 127 | 128 | 129 | def test_asap_shortcut(count): 130 | """ lsfhit is a shorthand for asap """ 131 | 132 | async def foo(mark): 133 | count(mark) 134 | 135 | @ayo.run_as_main() 136 | async def main(run): 137 | run << foo(1) 138 | run << foo(2) 139 | 140 | assert count == 2, "All coroutines have been called exactly once" 141 | 142 | 143 | def test_all_shorthand(count): 144 | """ scope.all is a shorthand for creating a scope and runninig things in it """ 145 | 146 | async def foo(mark): 147 | count(mark) 148 | 149 | @ayo.run_as_main() 150 | async def main(run): 151 | run.all(foo(1), foo(2), foo(3)) 152 | 153 | assert count == 3, "All coroutines have been called exactly once" 154 | 155 | 156 | def test_all_then_gather(count): 157 | """ gather() can be used to get results before the end of the scope """ 158 | 159 | async def foo(mark): 160 | await asyncio.sleep(0.1) 161 | return count(mark) 162 | 163 | @ayo.run_as_main() 164 | async def main(run): 165 | tasks = run.all(foo(1), foo(2), foo(3)) 166 | assert count.value < 3, "All coroutines should have not run yet" 167 | results = await tasks.gather() 168 | assert sorted(results) == [1, 2, 3] 169 | 170 | 171 | # TODO: test what happen if we cancel a task before inserting it in asyncio 172 | def test_cancel_scope(count): 173 | """ cancel() exit a scope and cancel all tasks in it """ 174 | 175 | async def foo(run): 176 | await asyncio.sleep(1) 177 | count() 178 | return True 179 | 180 | @ayo.run_as_main() 181 | async def main(run): 182 | run << foo(run) 183 | 184 | # TODO: check what happens if I cancel from the parent scope 185 | # TODO: test stuff with the parent scope 186 | async with ayo.scope() as runalso: 187 | runalso.all(foo(runalso), foo(runalso), foo(runalso)) 188 | assert not runalso.cancelled 189 | runalso.cancel() 190 | assert False, "We should never reach this point" 191 | 192 | assert not run.cancelled 193 | assert runalso.cancelled 194 | assert not count.value, "No coroutine has finished" 195 | 196 | assert count.value == 1, "One coroutine only has finished" 197 | 198 | 199 | def test_timeout(count): 200 | """setting a timeout limit the time it can execute in """ 201 | 202 | async def foo(s): 203 | await asyncio.sleep(s) 204 | count() 205 | return True 206 | 207 | # TODO: put timeout on run_with_main() 208 | @ayo.run_as_main() 209 | async def main1(run): 210 | async with ayo.scope(timeout=0.1) as runalso: 211 | runalso.all(foo(0.05), foo(0.2), foo(0.3)) 212 | 213 | assert count.value == 1, "2 coroutines has been cancelled" 214 | 215 | @ayo.run_as_main() 216 | async def main2(run): 217 | async with ayo.scope(timeout=1) as runalso: 218 | runalso.all(foo(0.05), foo(0.2), foo(0.3)) 219 | 220 | assert count.value == 4, "all coroutines has ran" 221 | 222 | @ayo.run_as_main(timeout=0.1) 223 | async def main3(run): 224 | async with ayo.scope() as runalso: 225 | runalso.all(foo(0.05), foo(0.2), foo(0.3)) 226 | 227 | assert count.value == 5, "2 coroutines has been cancelled" 228 | 229 | # TODO: make the timeout a separate task, outside of the self._running_tasks 230 | @ayo.run_as_main(timeout=0.1) 231 | async def main4(run): 232 | async with ayo.scope() as runalso: 233 | runalso.all(foo(0.5), foo(2), foo(3)) 234 | 235 | assert count.value == 5, "all coroutines has been cancelled" 236 | 237 | 238 | # TODO: test timeout with a long sleep in the scope 239 | 240 | 241 | def test_all_scope_results(count): 242 | """ A scope remembers the results of all awaited tasks """ 243 | 244 | async def foo(mark): 245 | return count(mark) 246 | 247 | @ayo.run_as_main() 248 | async def main(s): 249 | 250 | async with ayo.scope() as run: 251 | run.all(foo(1), foo(2), foo(3)) 252 | 253 | assert sorted(run.results) == [1, 2, 3] 254 | 255 | 256 | def test_delayed_task_execution(count): 257 | """ Using a lazy task allow later schedule execution """ 258 | 259 | async def foo(mark): 260 | return count(mark) 261 | 262 | @ayo.run_as_main() 263 | async def main2(s): 264 | task = LazyTask(foo(1)) 265 | await asyncio.sleep(0.1) 266 | assert count.value == 0 267 | task.schedule_for_execution() 268 | await task 269 | assert count.value == 1 270 | count() 271 | 272 | assert count.value == 2 273 | 274 | 275 | def test_from_callable(count): 276 | """ from_callable() creates the awaitable and schedules it the scope """ 277 | 278 | async def foo(mark): 279 | count(mark) 280 | 281 | @ayo.run_as_main() 282 | async def main(run): 283 | run.from_callable(foo, 1) 284 | run.from_callable(foo, 2) 285 | 286 | assert count == 2, "All coroutines have been called exactly once" 287 | 288 | 289 | def test_max_concurrency(count): 290 | """setting a timeout limit the time it can execute in """ 291 | 292 | async def foo(): 293 | start = dt.datetime.now() 294 | await asyncio.sleep(0.1) 295 | return start 296 | 297 | def diff_in_seconds(x, y): 298 | return abs(round(x.timestamp() - y.timestamp(), 1)) 299 | 300 | @ayo.run_as_main() 301 | async def main1(run): 302 | async with ayo.scope(max_concurrency=2) as runalso: 303 | runalso.all(foo(), foo(), foo(), foo(), foo(), foo()) 304 | 305 | a, b, c, d, e, f = runalso.results 306 | assert diff_in_seconds(a, b) == 0.0 307 | assert diff_in_seconds(c, d) == 0.0 308 | assert diff_in_seconds(e, f) == 0.0 309 | 310 | assert diff_in_seconds(c, b) == 0.1 311 | assert diff_in_seconds(d, e) == 0.1 312 | 313 | @ayo.run_as_main(max_concurrency=2) 314 | async def main2(run): 315 | results = run.all(foo(), foo(), foo(), foo(), foo(), foo()).gather() 316 | a, b, c, d, e, f = await results 317 | 318 | assert diff_in_seconds(a, b) == 0.0 319 | assert diff_in_seconds(c, d) == 0.0 320 | assert diff_in_seconds(e, f) == 0.0 321 | 322 | assert diff_in_seconds(c, b) == 0.1 323 | assert diff_in_seconds(d, e) == 0.1 324 | 325 | @ayo.run_as_main() 326 | async def main3(run): 327 | async with ayo.scope(max_concurrency=2) as runalso: 328 | runalso.from_callable(foo) 329 | runalso.from_callable(foo) 330 | runalso.from_callable(foo) 331 | runalso.from_callable(foo) 332 | runalso.from_callable(foo) 333 | runalso.from_callable(foo) 334 | 335 | a, b, c, d, e, f = runalso.results 336 | assert diff_in_seconds(a, b) == 0.0 337 | assert diff_in_seconds(c, d) == 0.0 338 | assert diff_in_seconds(e, f) == 0.0 339 | 340 | assert diff_in_seconds(c, b) == 0.1 341 | assert diff_in_seconds(d, e) == 0.1 342 | 343 | 344 | # TODO: test concurrency with aside 345 | 346 | # TODO: TEST cancelling the top task to see if the bottom tasks are 347 | # cancelled 348 | 349 | # TODO: test assertions preventing missuse of scopes 350 | 351 | # TODO: make shield work ? 352 | # TODO: check passing a custom loop 353 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # content of: tox.ini , put in same dir as setup.py 2 | [tox] 3 | envlist = mypy,black,flake8,pylint,py36,py3.7,pypy3 4 | skipsdist = true 5 | 6 | [testenv:mypy] 7 | basepython=python3.6 8 | deps=mypy 9 | commands=python -m mypy {toxinidir} 10 | 11 | [testenv:black] 12 | basepython=python3.6 13 | deps=black 14 | commands=python -m black {toxinidir} 15 | 16 | [testenv:flake8] 17 | basepython=python3.6 18 | deps=flake8 19 | commands=python -m flake8 {toxinidir} 20 | 21 | [testenv:pylint] 22 | basepython=python3.6 23 | deps=pylint 24 | pytest 25 | commands=python -m pylint ayo tests 26 | 27 | [testenv] 28 | deps = pytest 29 | commands = 30 | pytest 31 | --------------------------------------------------------------------------------