Compare commits
	
		
			458 Commits 
		
	
	
		
			a1d75625e4
			...
			b7aa72465d
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | b7aa72465d | |
|  | 1ff79f86b7 | |
|  | f26d487000 | |
|  | 1075ea3687 | |
|  | 2bd4cc9727 | |
|  | a60837550e | |
|  | 72035a20d7 | |
|  | 32e760284f | |
|  | 14fb56329b | |
|  | 46f644e748 | |
|  | cdd0c5384a | |
|  | 1afef149d4 | |
|  | 11d4c83aed | |
|  | 72fc6fce24 | |
|  | 4a195eef4c | |
|  | a5b8e009fd | |
|  | ddf6222eb6 | |
|  | 9412745aaf | |
|  | 4a5ab155e2 | |
|  | 526187d1a0 | |
|  | c738f8b540 | |
|  | 962941c56c | |
|  | b692979dda | |
|  | 5fcb46bbb9 | |
|  | ec6b2e8738 | |
|  | e1575051f0 | |
|  | 5f8ec63b0c | |
|  | a356233b47 | |
|  | 9af6271e99 | |
|  | 36021d1f2b | |
|  | 7443e387b5 | |
|  | d9662d9b34 | |
|  | 84dbf53817 | |
|  | e898a41e22 | |
|  | 46c9ee2551 | |
|  | e7adeee549 | |
|  | e10616fa4d | |
|  | f24e6f6e48 | |
|  | aac013ae5c | |
|  | ccbd35f273 | |
|  | 346e009730 | |
|  | 4ada92d2f7 | |
|  | 5cdd012417 | |
|  | 701dd135eb | |
|  | 060ee1457e | |
|  | 32e12c8b03 | |
|  | 50ba23e602 | |
|  | ddbda17338 | |
|  | 199247309e | |
|  | 10558b0986 | |
|  | eaa5d23543 | |
|  | 904d8ce8ff | |
|  | f14fb53958 | |
|  | 49cd00232e | |
|  | ae16368949 | |
|  | aa7448793a | |
|  | 2df7ffd702 | |
|  | dba2d87baf | |
|  | 276f88fd0c | |
|  | b2087404e3 | |
|  | 9bc7be30bf | |
|  | 1d9e60626c | |
|  | ef7f34ca1c | |
|  | 417f4f7255 | |
|  | 8de79372b7 | |
|  | d105da0fcf | |
|  | 3eef9aeac5 | |
|  | 521a2e353d | |
|  | 6927767d39 | |
|  | bd66450a79 | |
|  | 9811db9ac5 | |
|  | 6af320273b | |
|  | 74048b06a7 | |
|  | 5b9a2642f6 | |
|  | 778710efbb | |
|  | 4792ffcc04 | |
|  | 3c1f56f8d9 | |
|  | 682cf884c4 | |
|  | 8dcc49fce2 | |
|  | b517dacf0a | |
|  | d3680bfe6a | |
|  | e863159c7f | |
|  | ed42aa7e65 | |
|  | e8fee54534 | |
|  | aee1bf8456 | |
|  | 69fb7beff8 | |
|  | f5b1d0179e | |
|  | dee312cae1 | |
|  | 85fd312c22 | |
|  | 6754a80186 | |
|  | d3f7b83ea0 | |
|  | d8dd0c0a81 | |
|  | 0c8bb88cc5 | |
|  | 0687dac97a | |
|  | 4589ff307c | |
|  | c39427dc15 | |
|  | dc5d622e70 | |
|  | 319dda77b4 | |
|  | 59a3449455 | |
|  | 1ef1ebfa99 | |
|  | a95b84e4fb | |
|  | 54d397b726 | |
|  | 33e646fd6a | |
|  | f120ee72f5 | |
|  | 08dc32fbb7 | |
|  | fd0c14df80 | |
|  | a1779a8fa9 | |
|  | d154afd678 | |
|  | f05abbcfee | |
|  | 9330a75255 | |
|  | 235db17c9c | |
|  | f227ce6080 | |
|  | aa17635c4b | |
|  | b673d10e1b | |
|  | 46a1a54aeb | |
|  | d7ca1dfd94 | |
|  | deb61423c4 | |
|  | ea5eeba0a0 | |
|  | 3ea4617120 | |
|  | 6819ec01d0 | |
|  | 71518ea94a | |
|  | 4520183cdc | |
|  | 5b14baaf58 | |
|  | 18de9c1693 | |
|  | eb88511a8c | |
|  | 66048da832 | |
|  | 6c992a2fea | |
|  | d530002d66 | |
|  | 904c6895f7 | |
|  | f0912c9859 | |
|  | 3b5970f12b | |
|  | 5668328c8f | |
|  | e133911a44 | |
|  | 09948d71c6 | |
|  | 452094df27 | |
|  | e0dc1d73b2 | |
|  | 8881219eae | |
|  | 26d3ba7cc7 | |
|  | 6734dbb3cd | |
|  | 29a001c4ef | |
|  | 2ddfe11d71 | |
|  | 316afdec55 | |
|  | bc660a533c | |
|  | 61183f6a97 | |
|  | 8d5b40507c | |
|  | 194bb8f7fb | |
|  | c1747a290a | |
|  | 0c57e1a808 | |
|  | 17cf3d45ba | |
|  | 04bd53ff10 | |
|  | 332ce97650 | |
|  | d3e13658ab | |
|  | d680e31e4f | |
|  | 048c60f112 | |
|  | 219d5c1745 | |
|  | 467764d45e | |
|  | 998c0f0bd5 | |
|  | ceaafc064e | |
|  | 7b6881cf0a | |
|  | 2cdd5b5b8f | |
|  | 1f4c780b98 | |
|  | f9de439b87 | |
|  | 49443d3a7e | |
|  | b78732781f | |
|  | bf08066031 | |
|  | 3b38fa8673 | |
|  | 7910e1297b | |
|  | 0efc4c1b87 | |
|  | 83e3a75c10 | |
|  | 3fb99f2ba5 | |
|  | 94d8bef2d6 | |
|  | e46046a746 | |
|  | 875081e7a2 | |
|  | 6819cf908a | |
|  | 9e5bdd26d7 | |
|  | 5d4681df4b | |
|  | baee808654 | |
|  | 2ed43373c5 | |
|  | d982daa886 | |
|  | 97fc2a6628 | |
|  | 5bf27aca2c | |
|  | 85c9a8e628 | |
|  | 69b509d09e | |
|  | 41499c6d9e | |
|  | be0ded2a22 | |
|  | 7d71fce558 | |
|  | cbb9bbcbca | |
|  | ef3a7fbaa8 | |
|  | 14583307ee | |
|  | 59966e5650 | |
|  | ca43f15aa0 | |
|  | 36bf58887d | |
|  | 7ca746e96e | |
|  | 956ff11863 | |
|  | 515d5faa0a | |
|  | 2995a6afb7 | |
|  | 9381d21281 | |
|  | 9ea5aa1cde | |
|  | 304590abaa | |
|  | 797f7f6d63 | |
|  | d4d1dca812 | |
|  | 213e7dbb67 | |
|  | 162feec6e9 | |
|  | 7bb6a53581 | |
|  | 6628fa00d9 | |
|  | 7a050e5edb | |
|  | 6e72f2ef13 | |
|  | 28a8d15071 | |
|  | c9d2993338 | |
|  | a13160d920 | |
|  | e9f1d8e8be | |
|  | 6c672a67e2 | |
|  | 344d8ebc0c | |
|  | 78b08e2a91 | |
|  | 4e769e45e4 | |
|  | dbb5e7dc78 | |
|  | abc9e68f33 | |
|  | 1544849bbf | |
|  | fc6419251b | |
|  | f1dd6474bf | |
|  | 5a79a17dbb | |
|  | 13ecb151db | |
|  | 335997966c | |
|  | e72bc5c208 | |
|  | 7908c9575e | |
|  | 8d8a47ef7b | |
|  | afabef166e | |
|  | b5bdd20eb5 | |
|  | 405c2a27e6 | |
|  | 8d716f2113 | |
|  | c79c2d7ffd | |
|  | e0d7ed48e8 | |
|  | 9e16cfe8fd | |
|  | 6cd74a5dba | |
|  | fe9406be9b | |
|  | b589bef1b6 | |
|  | 79c71bfbaf | |
|  | 68f170fde1 | |
|  | 10b52ba98a | |
|  | 65192e80c1 | |
|  | 4e71b57bf5 | |
|  | ba81e5106c | |
|  | d927ed82d8 | |
|  | 9324d82ff1 | |
|  | 7f70e09c33 | |
|  | a80829a702 | |
|  | 3a7e3505b4 | |
|  | e27d63b75f | |
|  | e8bd834b5b | |
|  | 863751b47b | |
|  | 46c8dbef1f | |
|  | e7dbb52b34 | |
|  | d044629cce | |
|  | 8832cdfe0d | |
|  | f6fc43d58d | |
|  | cdc513f25d | |
|  | 9eaee7a060 | |
|  | 63c087f08d | |
|  | d5f80365b5 | |
|  | d20f711fb0 | |
|  | 21509791e3 | |
|  | ce6974690b | |
|  | 972325a28d | |
|  | b4f890bd58 | |
|  | e2fa5a4d05 | |
|  | 2f4c019f39 | |
|  | 2b1dbcb541 | |
|  | 49ebdc2e6a | |
|  | daf37ed24c | |
|  | 0701874033 | |
|  | 4621c8c1b9 | |
|  | a69f1a61a5 | |
|  | 0c9e1be883 | |
|  | 8731ab3134 | |
|  | b38ff36e04 | |
|  | 819889702f | |
|  | a36ee01592 | |
|  | dd9fe0b043 | |
|  | e10ab9741d | |
|  | 91a970091f | |
|  | 5bf550b64a | |
|  | a3a3d0b8cb | |
|  | c1e0328669 | |
|  | cfb74e588d | |
|  | 3d2b6613e8 | |
|  | 2b124447c8 | |
|  | 5ffdda762a | |
|  | 9082efbe68 | |
|  | 14f34c111a | |
|  | f947bdf80c | |
|  | dbd79d8beb | |
|  | 15a4a2a51e | |
|  | ebf9909cc4 | |
|  | 2d541fdd9b | |
|  | 5f0bfeae57 | |
|  | 8b0b4abb3c | |
|  | 51bd38976f | |
|  | 4868bf225c | |
|  | f834b35aa9 | |
|  | 6d671f69b8 | |
|  | 94c89fd425 | |
|  | 0246c824b9 | |
|  | 2e17b084b2 | |
|  | 61d82d47c2 | |
|  | 7246749137 | |
|  | 4db377c01d | |
|  | ef4c4be0bb | |
|  | 7ce4bc489e | |
|  | dec2b1f0f5 | |
|  | 3ccbfd7e54 | |
|  | 8d318a8ac5 | |
|  | d5eec6eb6c | |
|  | a88564549a | |
|  | f028181e19 | |
|  | 3a317c1581 | |
|  | 65e49696e7 | |
|  | e834297503 | |
|  | e3bb9c914c | |
|  | 526add2cae | |
|  | 1fb4d7318b | |
|  | 199ca48cc4 | |
|  | 5b3bcbaa7d | |
|  | 8647421ef9 | |
|  | ba9448d52f | |
|  | f5c35dca55 | |
|  | cebc2cb515 | |
|  | 5042f1fdb8 | |
|  | 5912fecdc9 | |
|  | cca4f952ed | |
|  | ab0c0fb71d | |
|  | b00ba158f1 | |
|  | 93f489e263 | |
|  | fa5f458de0 | |
|  | 6de4a5a9f3 | |
|  | ab8bd9b787 | |
|  | 1deed8dbee | |
|  | 36d2aa1852 | |
|  | f0417d802b | |
|  | 62a0fff2fd | |
|  | d65e4bbad7 | |
|  | ee372933a7 | |
|  | 96cdcd8f39 | |
|  | bc13599e1f | |
|  | 54576851e9 | |
|  | 2a5ff82061 | |
|  | f2d3f0cc21 | |
|  | 6b282bfa06 | |
|  | 11bab13a06 | |
|  | 9a8cd13894 | |
|  | 3706abca71 | |
|  | 771fc33801 | |
|  | a87df3009f | |
|  | 05f28c8728 | |
|  | 85825cdd76 | |
|  | a5bc113fde | |
|  | 4f7823cf55 | |
|  | 544cb40533 | |
|  | 389b305d3b | |
|  | 1975b92dba | |
|  | 31ccdd79d7 | |
|  | cbaf4fc05b | |
|  | 68a3969585 | |
|  | cf68e075c9 | |
|  | f730749dc9 | |
|  | c8775dee41 | |
|  | fd2391539e | |
|  | 8e3a2a9297 | |
|  | f90ca0668b | |
|  | 36a81a60cd | |
|  | c2480c2b97 | |
|  | 7b1528abed | |
|  | c5228e7be5 | |
|  | 9966dbdfc1 | |
|  | 7fb1c45ac7 | |
|  | 59d6d0cd7f | |
|  | ffed35e263 | |
|  | 885ba04908 | |
|  | 1879243257 | |
|  | 4fb34772e7 | |
|  | 1c9589cfc4 | |
|  | 910c07db06 | |
|  | d8d206b93f | |
|  | fb55784798 | |
|  | 1bc858cd00 | |
|  | 04aea5c4db | |
|  | 7bb44e6930 | |
|  | 2cc712cd81 | |
|  | c421f7e722 | |
|  | 1c217ef36f | |
|  | d7f2f51f7f | |
|  | cfcbc4da01 | |
|  | 664ae87588 | |
|  | e1d7004aec | |
|  | a97b45d90b | |
|  | a388d3185b | |
|  | 4d0df1bb4a | |
|  | 5eb62b3e9b | |
|  | 1be296c725 | |
|  | 9420ea0c14 | |
|  | 9194e5774b | |
|  | 51a3f1bef4 | |
|  | ca1b8e0224 | |
|  | e403d63eb7 | |
|  | 3c385c6949 | |
|  | b28df738fe | |
|  | 5fa040c7db | |
|  | 27b750e907 | |
|  | 96150600fb | |
|  | 338ea5529c | |
|  | 6bc67338cf | |
|  | fd20004757 | |
|  | ddc2e5f0f8 | |
|  | 4b0aa5e379 | |
|  | 6a303358df | |
|  | c85757aee1 | |
|  | 9fc9b10b53 | |
|  | a86275996c | |
|  | b5431c0343 | |
|  | cdee6f9354 | |
|  | a2f1bcc23f | |
|  | 4aa89bf391 | |
|  | 45e9cb4d09 | |
|  | 27c5ffe5a7 | |
|  | 914efd80eb | |
|  | 2d2d1ca1c4 | |
|  | 74aa5aa9cd | |
|  | 44e386dd99 | |
|  | 13fbcc723f | |
|  | 315f0fc7eb | |
|  | fea111e882 | |
|  | a1bf4db1e3 | |
|  | bac9523ecf | |
|  | abe31e9e2c | |
|  | 0222180c11 | |
|  | 7d5fda4485 | |
|  | f5fcd8ca2e | |
|  | 04217f319a | |
|  | 8cb8390201 | |
|  | 5035617adf | |
|  | 715348c5c2 | |
|  | fdf0c43bfa | |
|  | f895c96600 | |
|  | ca1a1476bb | |
|  | a7c36a9cbe | |
|  | 22e4b324b1 | |
|  | 89ed8b67ff | |
|  | 11bbf15817 | |
|  | a18663213a | |
|  | d4d09b6071 | |
|  | 6d10f0c516 | |
|  | fa9b57bae0 | |
|  | 81776a6238 | |
|  | 144d1f4d94 | |
|  | 51fdf3524c | |
|  | cff69d07fe | |
|  | ee94d6d62c | |
|  | 89b84ed6c0 | |
|  | f33f689f34 | 
|  | @ -20,7 +20,7 @@ jobs: | ||||||
|       - name: Setup python |       - name: Setup python | ||||||
|         uses: actions/setup-python@v2 |         uses: actions/setup-python@v2 | ||||||
|         with: |         with: | ||||||
|           python-version: '3.10' |           python-version: '3.11' | ||||||
| 
 | 
 | ||||||
|       - name: Install dependencies |       - name: Install dependencies | ||||||
|         run: pip install -U . --upgrade-strategy eager -r requirements-test.txt |         run: pip install -U . --upgrade-strategy eager -r requirements-test.txt | ||||||
|  | @ -41,7 +41,7 @@ jobs: | ||||||
|       - name: Setup python |       - name: Setup python | ||||||
|         uses: actions/setup-python@v2 |         uses: actions/setup-python@v2 | ||||||
|         with: |         with: | ||||||
|           python-version: '3.10' |           python-version: '3.11' | ||||||
| 
 | 
 | ||||||
|       - name: Build sdist |       - name: Build sdist | ||||||
|         run: python setup.py sdist --formats=zip |         run: python setup.py sdist --formats=zip | ||||||
|  | @ -59,7 +59,7 @@ jobs: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
|         os: [ubuntu-latest] |         os: [ubuntu-latest] | ||||||
|         python: ['3.10'] |         python: ['3.11'] | ||||||
|         spawn_backend: [ |         spawn_backend: [ | ||||||
|           'trio', |           'trio', | ||||||
|           'mp_spawn', |           'mp_spawn', | ||||||
|  |  | ||||||
|  | @ -3,8 +3,8 @@ | ||||||
| |gh_actions| | |gh_actions| | ||||||
| |docs| | |docs| | ||||||
| 
 | 
 | ||||||
| ``tractor`` is a `structured concurrent`_, multi-processing_ runtime | ``tractor`` is a `structured concurrent`_, (optionally | ||||||
| built on trio_. | distributed_) multi-processing_ runtime built on trio_. | ||||||
| 
 | 
 | ||||||
| Fundamentally, ``tractor`` gives you parallelism via | Fundamentally, ``tractor`` gives you parallelism via | ||||||
| ``trio``-"*actors*": independent Python processes (aka | ``trio``-"*actors*": independent Python processes (aka | ||||||
|  | @ -17,11 +17,20 @@ protocol" constructed on top of multiple Pythons each running a ``trio`` | ||||||
| scheduled runtime - a call to ``trio.run()``. | scheduled runtime - a call to ``trio.run()``. | ||||||
| 
 | 
 | ||||||
| We believe the system adheres to the `3 axioms`_ of an "`actor model`_" | We believe the system adheres to the `3 axioms`_ of an "`actor model`_" | ||||||
| but likely *does not* look like what *you* probably think an "actor | but likely **does not** look like what **you** probably *think* an "actor | ||||||
| model" looks like, and that's *intentional*. | model" looks like, and that's **intentional**. | ||||||
| 
 | 
 | ||||||
| The first step to grok ``tractor`` is to get the basics of ``trio`` down. | 
 | ||||||
| A great place to start is the `trio docs`_ and this `blog post`_. | Where do i start!? | ||||||
|  | ------------------ | ||||||
|  | The first step to grok ``tractor`` is to get an intermediate | ||||||
|  | knowledge of ``trio`` and **structured concurrency** B) | ||||||
|  | 
 | ||||||
|  | Some great places to start are, | ||||||
|  | - the seminal `blog post`_ | ||||||
|  | - obviously the `trio docs`_ | ||||||
|  | - wikipedia's nascent SC_ page | ||||||
|  | - the fancy diagrams @ libdill-docs_ | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| Features | Features | ||||||
|  | @ -593,6 +602,7 @@ matrix seems too hip, we're also mostly all in the the `trio gitter | ||||||
| channel`_! | channel`_! | ||||||
| 
 | 
 | ||||||
| .. _structured concurrent: https://trio.discourse.group/t/concise-definition-of-structured-concurrency/228 | .. _structured concurrent: https://trio.discourse.group/t/concise-definition-of-structured-concurrency/228 | ||||||
|  | .. _distributed: https://en.wikipedia.org/wiki/Distributed_computing | ||||||
| .. _multi-processing: https://en.wikipedia.org/wiki/Multiprocessing | .. _multi-processing: https://en.wikipedia.org/wiki/Multiprocessing | ||||||
| .. _trio: https://github.com/python-trio/trio | .. _trio: https://github.com/python-trio/trio | ||||||
| .. _nurseries: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/#nurseries-a-structured-replacement-for-go-statements | .. _nurseries: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/#nurseries-a-structured-replacement-for-go-statements | ||||||
|  | @ -611,8 +621,9 @@ channel`_! | ||||||
| .. _trio docs: https://trio.readthedocs.io/en/latest/ | .. _trio docs: https://trio.readthedocs.io/en/latest/ | ||||||
| .. _blog post: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/ | .. _blog post: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/ | ||||||
| .. _structured concurrency: https://en.wikipedia.org/wiki/Structured_concurrency | .. _structured concurrency: https://en.wikipedia.org/wiki/Structured_concurrency | ||||||
|  | .. _SC: https://en.wikipedia.org/wiki/Structured_concurrency | ||||||
|  | .. _libdill-docs: https://sustrik.github.io/libdill/structured-concurrency.html | ||||||
| .. _structured chadcurrency: https://en.wikipedia.org/wiki/Structured_concurrency | .. _structured chadcurrency: https://en.wikipedia.org/wiki/Structured_concurrency | ||||||
| .. _structured concurrency: https://en.wikipedia.org/wiki/Structured_concurrency |  | ||||||
| .. _unrequirements: https://en.wikipedia.org/wiki/Actor_model#Direct_communication_and_asynchrony | .. _unrequirements: https://en.wikipedia.org/wiki/Actor_model#Direct_communication_and_asynchrony | ||||||
| .. _async generators: https://www.python.org/dev/peps/pep-0525/ | .. _async generators: https://www.python.org/dev/peps/pep-0525/ | ||||||
| .. _trio-parallel: https://github.com/richardsheridan/trio-parallel | .. _trio-parallel: https://github.com/richardsheridan/trio-parallel | ||||||
|  |  | ||||||
|  | @ -6,53 +6,59 @@ been an outage) and we want to ensure that despite being in debug mode | ||||||
| actor tree will eventually be cancelled without leaving any zombies. | actor tree will eventually be cancelled without leaving any zombies. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| import trio | from contextlib import asynccontextmanager as acm | ||||||
|  | from functools import partial | ||||||
|  | 
 | ||||||
| from tractor import ( | from tractor import ( | ||||||
|     open_nursery, |     open_nursery, | ||||||
|     context, |     context, | ||||||
|     Context, |     Context, | ||||||
|  |     ContextCancelled, | ||||||
|     MsgStream, |     MsgStream, | ||||||
|  |     _testing, | ||||||
| ) | ) | ||||||
|  | import trio | ||||||
|  | import pytest | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def break_channel_silently_then_error( | async def break_ipc_then_error( | ||||||
|     stream: MsgStream, |     stream: MsgStream, | ||||||
|  |     break_ipc_with: str|None = None, | ||||||
|  |     pre_close: bool = False, | ||||||
| ): | ): | ||||||
|  |     await _testing.break_ipc( | ||||||
|  |         stream=stream, | ||||||
|  |         method=break_ipc_with, | ||||||
|  |         pre_close=pre_close, | ||||||
|  |     ) | ||||||
|     async for msg in stream: |     async for msg in stream: | ||||||
|         await stream.send(msg) |         await stream.send(msg) | ||||||
| 
 | 
 | ||||||
|         # XXX: close the channel right after an error is raised |  | ||||||
|         # purposely breaking the IPC transport to make sure the parent |  | ||||||
|         # doesn't get stuck in debug or hang on the connection join. |  | ||||||
|         # this more or less simulates an infinite msg-receive hang on |  | ||||||
|         # the other end. |  | ||||||
|         await stream._ctx.chan.send(None) |  | ||||||
|     assert 0 |     assert 0 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def close_stream_and_error( | async def iter_ipc_stream( | ||||||
|     stream: MsgStream, |     stream: MsgStream, | ||||||
|  |     break_ipc_with: str|None = None, | ||||||
|  |     pre_close: bool = False, | ||||||
| ): | ): | ||||||
|     async for msg in stream: |     async for msg in stream: | ||||||
|         await stream.send(msg) |         await stream.send(msg) | ||||||
| 
 | 
 | ||||||
|         # wipe out channel right before raising |  | ||||||
|         await stream._ctx.chan.send(None) |  | ||||||
|         await stream.aclose() |  | ||||||
|         assert 0 |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| @context | @context | ||||||
| async def recv_and_spawn_net_killers( | async def recv_and_spawn_net_killers( | ||||||
| 
 | 
 | ||||||
|     ctx: Context, |     ctx: Context, | ||||||
|     break_ipc_after: bool | int = False, |     break_ipc_after: bool|int = False, | ||||||
|  |     pre_close: bool = False, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     ''' |     ''' | ||||||
|     Receive stream msgs and spawn some IPC killers mid-stream. |     Receive stream msgs and spawn some IPC killers mid-stream. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|  |     broke_ipc: bool = False | ||||||
|     await ctx.started() |     await ctx.started() | ||||||
|     async with ( |     async with ( | ||||||
|         ctx.open_stream() as stream, |         ctx.open_stream() as stream, | ||||||
|  | @ -60,27 +66,58 @@ async def recv_and_spawn_net_killers( | ||||||
|     ): |     ): | ||||||
|         async for i in stream: |         async for i in stream: | ||||||
|             print(f'child echoing {i}') |             print(f'child echoing {i}') | ||||||
|  |             if not broke_ipc: | ||||||
|                 await stream.send(i) |                 await stream.send(i) | ||||||
|  |             else: | ||||||
|  |                 await trio.sleep(0.01) | ||||||
|  | 
 | ||||||
|             if ( |             if ( | ||||||
|                 break_ipc_after |                 break_ipc_after | ||||||
|                 and i > break_ipc_after |                 and | ||||||
|  |                 i >= break_ipc_after | ||||||
|             ): |             ): | ||||||
|                 '#################################\n' |                 broke_ipc = True | ||||||
|                 'Simulating child-side IPC BREAK!\n' |                 n.start_soon( | ||||||
|                 '#################################' |                     iter_ipc_stream, | ||||||
|                 n.start_soon(break_channel_silently_then_error, stream) |                     stream, | ||||||
|                 n.start_soon(close_stream_and_error, stream) |                 ) | ||||||
|  |                 n.start_soon( | ||||||
|  |                     partial( | ||||||
|  |                         break_ipc_then_error, | ||||||
|  |                         stream=stream, | ||||||
|  |                         pre_close=pre_close, | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @acm | ||||||
|  | async def stuff_hangin_ctlc(timeout: float = 1) -> None: | ||||||
|  | 
 | ||||||
|  |     with trio.move_on_after(timeout) as cs: | ||||||
|  |         yield timeout | ||||||
|  | 
 | ||||||
|  |     if cs.cancelled_caught: | ||||||
|  |         # pretend to be a user seeing no streaming action | ||||||
|  |         # thinking it's a hang, and then hitting ctl-c.. | ||||||
|  |         print( | ||||||
|  |             f"i'm a user on the PARENT side and thingz hangin " | ||||||
|  |             f'after timeout={timeout} ???\n\n' | ||||||
|  |             'MASHING CTlR-C..!?\n' | ||||||
|  |         ) | ||||||
|  |         raise KeyboardInterrupt | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def main( | async def main( | ||||||
|     debug_mode: bool = False, |     debug_mode: bool = False, | ||||||
|     start_method: str = 'trio', |     start_method: str = 'trio', | ||||||
|  |     loglevel: str = 'cancel', | ||||||
| 
 | 
 | ||||||
|     # by default we break the parent IPC first (if configured to break |     # by default we break the parent IPC first (if configured to break | ||||||
|     # at all), but this can be changed so the child does first (even if |     # at all), but this can be changed so the child does first (even if | ||||||
|     # both are set to break). |     # both are set to break). | ||||||
|     break_parent_ipc_after: int | bool = False, |     break_parent_ipc_after: int|bool = False, | ||||||
|     break_child_ipc_after: int | bool = False, |     break_child_ipc_after: int|bool = False, | ||||||
|  |     pre_close: bool = False, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
| 
 | 
 | ||||||
|  | @ -91,59 +128,128 @@ async def main( | ||||||
|             # NOTE: even debugger is used we shouldn't get |             # NOTE: even debugger is used we shouldn't get | ||||||
|             # a hang since it never engages due to broken IPC |             # a hang since it never engages due to broken IPC | ||||||
|             debug_mode=debug_mode, |             debug_mode=debug_mode, | ||||||
|             loglevel='warning', |             loglevel=loglevel, | ||||||
| 
 | 
 | ||||||
|         ) as an, |         ) as an, | ||||||
|     ): |     ): | ||||||
|  |         sub_name: str = 'chitty_hijo' | ||||||
|         portal = await an.start_actor( |         portal = await an.start_actor( | ||||||
|             'chitty_hijo', |             sub_name, | ||||||
|             enable_modules=[__name__], |             enable_modules=[__name__], | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         async with portal.open_context( |         async with ( | ||||||
|  |             stuff_hangin_ctlc(timeout=2) as timeout, | ||||||
|  |             _testing.expect_ctxc( | ||||||
|  |                 yay=( | ||||||
|  |                     break_parent_ipc_after | ||||||
|  |                     or break_child_ipc_after | ||||||
|  |                 ), | ||||||
|  |                 # TODO: we CAN'T remove this right? | ||||||
|  |                 # since we need the ctxc to bubble up from either | ||||||
|  |                 # the stream API after the `None` msg is sent | ||||||
|  |                 # (which actually implicitly cancels all remote | ||||||
|  |                 # tasks in the hijo) or from simluated | ||||||
|  |                 # KBI-mash-from-user | ||||||
|  |                 # or should we expect that a KBI triggers the ctxc | ||||||
|  |                 # and KBI in an eg? | ||||||
|  |                 reraise=True, | ||||||
|  |             ), | ||||||
|  | 
 | ||||||
|  |             portal.open_context( | ||||||
|                 recv_and_spawn_net_killers, |                 recv_and_spawn_net_killers, | ||||||
|                 break_ipc_after=break_child_ipc_after, |                 break_ipc_after=break_child_ipc_after, | ||||||
| 
 |                 pre_close=pre_close, | ||||||
|         ) as (ctx, sent): |             ) as (ctx, sent), | ||||||
|  |         ): | ||||||
|  |             rx_eoc: bool = False | ||||||
|  |             ipc_break_sent: bool = False | ||||||
|             async with ctx.open_stream() as stream: |             async with ctx.open_stream() as stream: | ||||||
|                 for i in range(1000): |                 for i in range(1000): | ||||||
| 
 | 
 | ||||||
|                     if ( |                     if ( | ||||||
|                         break_parent_ipc_after |                         break_parent_ipc_after | ||||||
|                         and i > break_parent_ipc_after |                         and | ||||||
|  |                         i > break_parent_ipc_after | ||||||
|  |                         and | ||||||
|  |                         not ipc_break_sent | ||||||
|                     ): |                     ): | ||||||
|                         print( |                         print( | ||||||
|                             '#################################\n' |                             '#################################\n' | ||||||
|                             'Simulating parent-side IPC BREAK!\n' |                             'Simulating PARENT-side IPC BREAK!\n' | ||||||
|                             '#################################' |                             '#################################\n' | ||||||
|                         ) |                         ) | ||||||
|                         await stream._ctx.chan.send(None) | 
 | ||||||
|  |                         # TODO: other methods? see break func above. | ||||||
|  |                         # await stream._ctx.chan.send(None) | ||||||
|  |                         # await stream._ctx.chan.transport.stream.send_eof() | ||||||
|  |                         await stream._ctx.chan.transport.stream.aclose() | ||||||
|  |                         ipc_break_sent = True | ||||||
| 
 | 
 | ||||||
|                     # it actually breaks right here in the |                     # it actually breaks right here in the | ||||||
|                     # mp_spawn/forkserver backends and thus the zombie |                     # mp_spawn/forkserver backends and thus the | ||||||
|                     # reaper never even kicks in? |                     # zombie reaper never even kicks in? | ||||||
|  |                     try: | ||||||
|                         print(f'parent sending {i}') |                         print(f'parent sending {i}') | ||||||
|                         await stream.send(i) |                         await stream.send(i) | ||||||
|  |                     except ContextCancelled as ctxc: | ||||||
|  |                         print( | ||||||
|  |                             'parent received ctxc on `stream.send()`\n' | ||||||
|  |                             f'{ctxc}\n' | ||||||
|  |                         ) | ||||||
|  |                         assert 'root' in ctxc.canceller | ||||||
|  |                         assert sub_name in ctx.canceller | ||||||
| 
 | 
 | ||||||
|                     with trio.move_on_after(2) as cs: |                         # TODO: is this needed or no? | ||||||
|  |                         raise | ||||||
| 
 | 
 | ||||||
|  |                     except trio.ClosedResourceError: | ||||||
|  |                         # NOTE: don't send if we already broke the | ||||||
|  |                         # connection to avoid raising a closed-error | ||||||
|  |                         # such that we drop through to the ctl-c | ||||||
|  |                         # mashing by user. | ||||||
|  |                         await trio.sleep(0.01) | ||||||
|  | 
 | ||||||
|  |                     # timeout: int = 1 | ||||||
|  |                     # with trio.move_on_after(timeout) as cs: | ||||||
|  |                     async with stuff_hangin_ctlc() as timeout: | ||||||
|  |                         print( | ||||||
|  |                             f'PARENT `stream.receive()` with timeout={timeout}\n' | ||||||
|  |                         ) | ||||||
|                         # NOTE: in the parent side IPC failure case this |                         # NOTE: in the parent side IPC failure case this | ||||||
|                         # will raise an ``EndOfChannel`` after the child |                         # will raise an ``EndOfChannel`` after the child | ||||||
|                         # is killed and sends a stop msg back to it's |                         # is killed and sends a stop msg back to it's | ||||||
|                         # caller/this-parent. |                         # caller/this-parent. | ||||||
|  |                         try: | ||||||
|                             rx = await stream.receive() |                             rx = await stream.receive() | ||||||
| 
 |                             print( | ||||||
|                         print(f"I'm a happy user and echoed to me is {rx}") |                                 "I'm a happy PARENT user and echoed to me is\n" | ||||||
| 
 |                                 f'{rx}\n' | ||||||
|                     if cs.cancelled_caught: |                             ) | ||||||
|                         # pretend to be a user seeing no streaming action |                         except trio.EndOfChannel: | ||||||
|                         # thinking it's a hang, and then hitting ctl-c.. |                             rx_eoc: bool = True | ||||||
|                         print("YOO i'm a user anddd thingz hangin..") |                             print('MsgStream got EoC for PARENT') | ||||||
|  |                             raise | ||||||
| 
 | 
 | ||||||
|             print( |             print( | ||||||
|                     "YOO i'm mad send side dun but thingz hangin..\n" |                 'Streaming finished and we got Eoc.\n' | ||||||
|                     'MASHING CTlR-C Ctl-c..' |                 'Canceling `.open_context()` in root with\n' | ||||||
|  |                 'CTlR-C..' | ||||||
|             ) |             ) | ||||||
|  |             if rx_eoc: | ||||||
|  |                 assert stream.closed | ||||||
|  |                 try: | ||||||
|  |                     await stream.send(i) | ||||||
|  |                     pytest.fail('stream not closed?') | ||||||
|  |                 except ( | ||||||
|  |                     trio.ClosedResourceError, | ||||||
|  |                     trio.EndOfChannel, | ||||||
|  |                 ) as send_err: | ||||||
|  |                     if rx_eoc: | ||||||
|  |                         assert send_err is stream._eoc | ||||||
|  |                     else: | ||||||
|  |                         assert send_err is stream._closed | ||||||
|  | 
 | ||||||
|             raise KeyboardInterrupt |             raise KeyboardInterrupt | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,138 @@ | ||||||
|  | ''' | ||||||
|  | Examples of using the builtin `breakpoint()` from an `asyncio.Task` | ||||||
|  | running in a subactor spawned with `infect_asyncio=True`. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | import asyncio | ||||||
|  | 
 | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | from tractor import ( | ||||||
|  |     to_asyncio, | ||||||
|  |     Portal, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def aio_sleep_forever(): | ||||||
|  |     await asyncio.sleep(float('inf')) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def bp_then_error( | ||||||
|  |     to_trio: trio.MemorySendChannel, | ||||||
|  |     from_trio: asyncio.Queue, | ||||||
|  | 
 | ||||||
|  |     raise_after_bp: bool = True, | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  | 
 | ||||||
|  |     # sync with ``trio``-side (caller) task | ||||||
|  |     to_trio.send_nowait('start') | ||||||
|  | 
 | ||||||
|  |     # NOTE: what happens here inside the hook needs some refinement.. | ||||||
|  |     # => seems like it's still `._debug._set_trace()` but | ||||||
|  |     #    we set `Lock.local_task_in_debug = 'sync'`, we probably want | ||||||
|  |     #    some further, at least, meta-data about the task/actor in debug | ||||||
|  |     #    in terms of making it clear it's `asyncio` mucking about. | ||||||
|  |     breakpoint() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     # short checkpoint / delay | ||||||
|  |     await asyncio.sleep(0.5)  # asyncio-side | ||||||
|  | 
 | ||||||
|  |     if raise_after_bp: | ||||||
|  |         raise ValueError('asyncio side error!') | ||||||
|  | 
 | ||||||
|  |     # TODO: test case with this so that it gets cancelled? | ||||||
|  |     else: | ||||||
|  |         # XXX NOTE: this is required in order to get the SIGINT-ignored | ||||||
|  |         # hang case documented in the module script section! | ||||||
|  |         await aio_sleep_forever() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def trio_ctx( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  |     bp_before_started: bool = False, | ||||||
|  | ): | ||||||
|  | 
 | ||||||
|  |     # this will block until the ``asyncio`` task sends a "first" | ||||||
|  |     # message, see first line in above func. | ||||||
|  |     async with ( | ||||||
|  | 
 | ||||||
|  |         to_asyncio.open_channel_from( | ||||||
|  |             bp_then_error, | ||||||
|  |             # raise_after_bp=not bp_before_started, | ||||||
|  |         ) as (first, chan), | ||||||
|  | 
 | ||||||
|  |         trio.open_nursery() as tn, | ||||||
|  |     ): | ||||||
|  |         assert first == 'start' | ||||||
|  | 
 | ||||||
|  |         if bp_before_started: | ||||||
|  |             await tractor.pause() | ||||||
|  | 
 | ||||||
|  |         await ctx.started(first)  # trio-side | ||||||
|  | 
 | ||||||
|  |         tn.start_soon( | ||||||
|  |             to_asyncio.run_task, | ||||||
|  |             aio_sleep_forever, | ||||||
|  |         ) | ||||||
|  |         await trio.sleep_forever() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def main( | ||||||
|  |     bps_all_over: bool = True, | ||||||
|  | 
 | ||||||
|  |     # TODO, WHICH OF THESE HAZ BUGZ? | ||||||
|  |     cancel_from_root: bool = False, | ||||||
|  |     err_from_root: bool = False, | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  | 
 | ||||||
|  |     async with tractor.open_nursery( | ||||||
|  |         debug_mode=True, | ||||||
|  |         maybe_enable_greenback=True, | ||||||
|  |         # loglevel='devx', | ||||||
|  |     ) as an: | ||||||
|  |         ptl: Portal = await an.start_actor( | ||||||
|  |             'aio_daemon', | ||||||
|  |             enable_modules=[__name__], | ||||||
|  |             infect_asyncio=True, | ||||||
|  |             debug_mode=True, | ||||||
|  |             # loglevel='cancel', | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         async with ptl.open_context( | ||||||
|  |             trio_ctx, | ||||||
|  |             bp_before_started=bps_all_over, | ||||||
|  |         ) as (ctx, first): | ||||||
|  | 
 | ||||||
|  |             assert first == 'start' | ||||||
|  | 
 | ||||||
|  |             # pause in parent to ensure no cross-actor | ||||||
|  |             # locking problems exist! | ||||||
|  |             await tractor.pause() | ||||||
|  | 
 | ||||||
|  |             if cancel_from_root: | ||||||
|  |                 await ctx.cancel() | ||||||
|  | 
 | ||||||
|  |             if err_from_root: | ||||||
|  |                 assert 0 | ||||||
|  |             else: | ||||||
|  |                 await trio.sleep_forever() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |         # TODO: case where we cancel from trio-side while asyncio task | ||||||
|  |         # has debugger lock? | ||||||
|  |         # await ptl.cancel_actor() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if __name__ == '__main__': | ||||||
|  | 
 | ||||||
|  |     # works fine B) | ||||||
|  |     trio.run(main) | ||||||
|  | 
 | ||||||
|  |     # will hang and ignores SIGINT !! | ||||||
|  |     # NOTE: you'll need to send a SIGQUIT (via ctl-\) to kill it | ||||||
|  |     # manually.. | ||||||
|  |     # trio.run(main, True) | ||||||
|  | @ -0,0 +1,9 @@ | ||||||
|  | ''' | ||||||
|  | Reproduce a bug where enabling debug mode for a sub-actor actually causes | ||||||
|  | a hang on teardown... | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | import asyncio | ||||||
|  | 
 | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| ''' | ''' | ||||||
| Fast fail test with a context. | Fast fail test with a `Context`. | ||||||
| 
 | 
 | ||||||
| Ensure the partially initialized sub-actor process | Ensure the partially initialized sub-actor process | ||||||
| doesn't cause a hang on error/cancel of the parent | doesn't cause a hang on error/cancel of the parent | ||||||
|  |  | ||||||
|  | @ -4,9 +4,15 @@ import trio | ||||||
| 
 | 
 | ||||||
| async def breakpoint_forever(): | async def breakpoint_forever(): | ||||||
|     "Indefinitely re-enter debugger in child actor." |     "Indefinitely re-enter debugger in child actor." | ||||||
|  |     try: | ||||||
|         while True: |         while True: | ||||||
|             yield 'yo' |             yield 'yo' | ||||||
|         await tractor.breakpoint() |             await tractor.pause() | ||||||
|  |     except BaseException: | ||||||
|  |         tractor.log.get_console_log().exception( | ||||||
|  |             'Cancelled while trying to enter pause point!' | ||||||
|  |         ) | ||||||
|  |         raise | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def name_error(): | async def name_error(): | ||||||
|  | @ -19,7 +25,8 @@ async def main(): | ||||||
|     """ |     """ | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         debug_mode=True, |         debug_mode=True, | ||||||
|         loglevel='error', |         # loglevel='cancel', | ||||||
|  |         # loglevel='devx', | ||||||
|     ) as n: |     ) as n: | ||||||
| 
 | 
 | ||||||
|         p0 = await n.start_actor('bp_forever', enable_modules=[__name__]) |         p0 = await n.start_actor('bp_forever', enable_modules=[__name__]) | ||||||
|  | @ -32,7 +39,7 @@ async def main(): | ||||||
|             try: |             try: | ||||||
|                 await p1.run(name_error) |                 await p1.run(name_error) | ||||||
|             except tractor.RemoteActorError as rae: |             except tractor.RemoteActorError as rae: | ||||||
|                 assert rae.type is NameError |                 assert rae.boxed_type is NameError | ||||||
| 
 | 
 | ||||||
|             async for i in stream: |             async for i in stream: | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -10,7 +10,7 @@ async def name_error(): | ||||||
| async def breakpoint_forever(): | async def breakpoint_forever(): | ||||||
|     "Indefinitely re-enter debugger in child actor." |     "Indefinitely re-enter debugger in child actor." | ||||||
|     while True: |     while True: | ||||||
|         await tractor.breakpoint() |         await tractor.pause() | ||||||
| 
 | 
 | ||||||
|         # NOTE: if the test never sent 'q'/'quit' commands |         # NOTE: if the test never sent 'q'/'quit' commands | ||||||
|         # on the pdb repl, without this checkpoint line the |         # on the pdb repl, without this checkpoint line the | ||||||
|  | @ -45,6 +45,7 @@ async def spawn_until(depth=0): | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | # TODO: notes on the new boxed-relayed errors through proxy actors | ||||||
| async def main(): | async def main(): | ||||||
|     """The main ``tractor`` routine. |     """The main ``tractor`` routine. | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ async def breakpoint_forever(): | ||||||
|     "Indefinitely re-enter debugger in child actor." |     "Indefinitely re-enter debugger in child actor." | ||||||
|     while True: |     while True: | ||||||
|         await trio.sleep(0.1) |         await trio.sleep(0.1) | ||||||
|         await tractor.breakpoint() |         await tractor.pause() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def name_error(): | async def name_error(): | ||||||
|  | @ -38,6 +38,7 @@ async def main(): | ||||||
|     """ |     """ | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         debug_mode=True, |         debug_mode=True, | ||||||
|  |         # loglevel='runtime', | ||||||
|     ) as n: |     ) as n: | ||||||
| 
 | 
 | ||||||
|         # Spawn both actors, don't bother with collecting results |         # Spawn both actors, don't bother with collecting results | ||||||
|  |  | ||||||
|  | @ -23,5 +23,6 @@ async def main(): | ||||||
|             n.start_soon(debug_actor.run, die) |             n.start_soon(debug_actor.run, die) | ||||||
|             n.start_soon(crash_boi.run, die) |             n.start_soon(crash_boi.run, die) | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
| if __name__ == '__main__': | if __name__ == '__main__': | ||||||
|     trio.run(main) |     trio.run(main) | ||||||
|  |  | ||||||
|  | @ -0,0 +1,56 @@ | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def name_error( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Raise a `NameError`, catch it and enter `.post_mortem()`, then | ||||||
|  |     expect the `._rpc._invoke()` crash handler to also engage. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     try: | ||||||
|  |         getattr(doggypants)  # noqa (on purpose) | ||||||
|  |     except NameError: | ||||||
|  |         await tractor.post_mortem() | ||||||
|  |         raise | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def main(): | ||||||
|  |     ''' | ||||||
|  |     Test 3 `PdbREPL` entries: | ||||||
|  |       - one in the child due to manual `.post_mortem()`, | ||||||
|  |       - another in the child due to runtime RPC crash handling. | ||||||
|  |       - final one here in parent from the RAE. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # XXX NOTE: ideally the REPL arrives at this frame in the parent | ||||||
|  |     # ONE UP FROM the inner ctx block below! | ||||||
|  |     async with tractor.open_nursery( | ||||||
|  |         debug_mode=True, | ||||||
|  |         # loglevel='cancel', | ||||||
|  |     ) as an: | ||||||
|  |         p: tractor.Portal = await an.start_actor( | ||||||
|  |             'child', | ||||||
|  |             enable_modules=[__name__], | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # XXX should raise `RemoteActorError[NameError]` | ||||||
|  |         # AND be the active frame when REPL enters! | ||||||
|  |         try: | ||||||
|  |             async with p.open_context(name_error) as (ctx, first): | ||||||
|  |                 assert first | ||||||
|  |         except tractor.RemoteActorError as rae: | ||||||
|  |             assert rae.boxed_type is NameError | ||||||
|  | 
 | ||||||
|  |             # manually handle in root's parent task | ||||||
|  |             await tractor.post_mortem() | ||||||
|  |             raise | ||||||
|  |         else: | ||||||
|  |             raise RuntimeError('IPC ctx should have remote errored!?') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if __name__ == '__main__': | ||||||
|  |     trio.run(main) | ||||||
|  | @ -6,19 +6,46 @@ import tractor | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def main() -> None: | async def main() -> None: | ||||||
|     async with tractor.open_nursery(debug_mode=True) as an: |  | ||||||
| 
 | 
 | ||||||
|         assert os.environ['PYTHONBREAKPOINT'] == 'tractor._debug._set_trace' |     # intially unset, no entry. | ||||||
|  |     orig_pybp_var: int = os.environ.get('PYTHONBREAKPOINT') | ||||||
|  |     assert orig_pybp_var in {None, "0"} | ||||||
|  | 
 | ||||||
|  |     async with tractor.open_nursery( | ||||||
|  |         debug_mode=True, | ||||||
|  |     ) as an: | ||||||
|  |         assert an | ||||||
|  |         assert ( | ||||||
|  |             (pybp_var := os.environ['PYTHONBREAKPOINT']) | ||||||
|  |             == | ||||||
|  |             'tractor.devx._debug._sync_pause_from_builtin' | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         # TODO: an assert that verifies the hook has indeed been, hooked |         # TODO: an assert that verifies the hook has indeed been, hooked | ||||||
|         # XD |         # XD | ||||||
|         assert sys.breakpointhook is not tractor._debug._set_trace |         assert ( | ||||||
|  |             (pybp_hook := sys.breakpointhook) | ||||||
|  |             is not tractor.devx._debug._set_trace | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|  |         print( | ||||||
|  |             f'$PYTHONOBREAKPOINT: {pybp_var!r}\n' | ||||||
|  |             f'`sys.breakpointhook`: {pybp_hook!r}\n' | ||||||
|  |         ) | ||||||
|         breakpoint() |         breakpoint() | ||||||
|  |         pass  # first bp, tractor hook set. | ||||||
| 
 | 
 | ||||||
|     # TODO: an assert that verifies the hook is unhooked.. |     # XXX AFTER EXIT (of actor-runtime) verify the hook is unset.. | ||||||
|  |     # | ||||||
|  |     # YES, this is weird but it's how stdlib docs say to do it.. | ||||||
|  |     # https://docs.python.org/3/library/sys.html#sys.breakpointhook | ||||||
|  |     assert os.environ.get('PYTHONBREAKPOINT') is orig_pybp_var | ||||||
|     assert sys.breakpointhook |     assert sys.breakpointhook | ||||||
|  | 
 | ||||||
|  |     # now ensure a regular builtin pause still works | ||||||
|     breakpoint() |     breakpoint() | ||||||
|  |     pass  # last bp, stdlib hook restored | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| if __name__ == '__main__': | if __name__ == '__main__': | ||||||
|     trio.run(main) |     trio.run(main) | ||||||
|  |  | ||||||
|  | @ -10,7 +10,7 @@ async def main(): | ||||||
| 
 | 
 | ||||||
|         await trio.sleep(0.1) |         await trio.sleep(0.1) | ||||||
| 
 | 
 | ||||||
|         await tractor.breakpoint() |         await tractor.pause() | ||||||
| 
 | 
 | ||||||
|         await trio.sleep(0.1) |         await trio.sleep(0.1) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,13 +2,16 @@ import trio | ||||||
| import tractor | import tractor | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def main(): | async def main( | ||||||
|  |     registry_addrs: tuple[str, int]|None = None | ||||||
|  | ): | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_root_actor( |     async with tractor.open_root_actor( | ||||||
|         debug_mode=True, |         debug_mode=True, | ||||||
|  |         # loglevel='runtime', | ||||||
|     ): |     ): | ||||||
|         while True: |         while True: | ||||||
|             await tractor.breakpoint() |             await tractor.pause() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| if __name__ == '__main__': | if __name__ == '__main__': | ||||||
|  |  | ||||||
|  | @ -0,0 +1,81 @@ | ||||||
|  | ''' | ||||||
|  | Verify we can dump a `stackscope` tree on a hang. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | import os | ||||||
|  | import signal | ||||||
|  | 
 | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def start_n_shield_hang( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  | ): | ||||||
|  |     # actor: tractor.Actor = tractor.current_actor() | ||||||
|  | 
 | ||||||
|  |     # sync to parent-side task | ||||||
|  |     await ctx.started(os.getpid()) | ||||||
|  | 
 | ||||||
|  |     print('Entering shield sleep..') | ||||||
|  |     with trio.CancelScope(shield=True): | ||||||
|  |         await trio.sleep_forever()  # in subactor | ||||||
|  | 
 | ||||||
|  |     # XXX NOTE ^^^ since this shields, we expect | ||||||
|  |     # the zombie reaper (aka T800) to engage on | ||||||
|  |     # SIGINT from the user and eventually hard-kill | ||||||
|  |     # this subprocess! | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def main( | ||||||
|  |     from_test: bool = False, | ||||||
|  | ) -> None: | ||||||
|  | 
 | ||||||
|  |     async with ( | ||||||
|  |         tractor.open_nursery( | ||||||
|  |             debug_mode=True, | ||||||
|  |             enable_stack_on_sig=True, | ||||||
|  |             # maybe_enable_greenback=False, | ||||||
|  |             loglevel='devx', | ||||||
|  |         ) as an, | ||||||
|  |     ): | ||||||
|  | 
 | ||||||
|  |         ptl: tractor.Portal  = await an.start_actor( | ||||||
|  |             'hanger', | ||||||
|  |             enable_modules=[__name__], | ||||||
|  |             debug_mode=True, | ||||||
|  |         ) | ||||||
|  |         async with ptl.open_context( | ||||||
|  |             start_n_shield_hang, | ||||||
|  |         ) as (ctx, cpid): | ||||||
|  | 
 | ||||||
|  |             _, proc, _ = an._children[ptl.chan.uid] | ||||||
|  |             assert cpid == proc.pid | ||||||
|  | 
 | ||||||
|  |             print( | ||||||
|  |                 'Yo my child hanging..?\n' | ||||||
|  |                 'Sending SIGUSR1 to see a tree-trace!\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             # XXX simulate the wrapping test's "user actions" | ||||||
|  |             # (i.e. if a human didn't run this manually but wants to | ||||||
|  |             # know what they should do to reproduce test behaviour) | ||||||
|  |             if from_test: | ||||||
|  |                 os.kill( | ||||||
|  |                     cpid, | ||||||
|  |                     signal.SIGUSR1, | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |                 # simulate user cancelling program | ||||||
|  |                 await trio.sleep(0.5) | ||||||
|  |                 os.kill( | ||||||
|  |                     os.getpid(), | ||||||
|  |                     signal.SIGINT, | ||||||
|  |                 ) | ||||||
|  |             else: | ||||||
|  |                 # actually let user send the ctl-c | ||||||
|  |                 await trio.sleep_forever()  # in root | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if __name__ == '__main__': | ||||||
|  |     trio.run(main) | ||||||
|  | @ -0,0 +1,88 @@ | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def cancellable_pause_loop( | ||||||
|  |     task_status: trio.TaskStatus[trio.CancelScope] = trio.TASK_STATUS_IGNORED | ||||||
|  | ): | ||||||
|  |     with trio.CancelScope() as cs: | ||||||
|  |         task_status.started(cs) | ||||||
|  |         for _ in range(3): | ||||||
|  |             try: | ||||||
|  |                 # ON first entry, there is no level triggered | ||||||
|  |                 # cancellation yet, so this cp does a parent task | ||||||
|  |                 # ctx-switch so that this scope raises for the NEXT | ||||||
|  |                 # checkpoint we hit. | ||||||
|  |                 await trio.lowlevel.checkpoint() | ||||||
|  |                 await tractor.pause() | ||||||
|  | 
 | ||||||
|  |                 cs.cancel() | ||||||
|  | 
 | ||||||
|  |                 # parent should have called `cs.cancel()` by now | ||||||
|  |                 await trio.lowlevel.checkpoint() | ||||||
|  | 
 | ||||||
|  |             except trio.Cancelled: | ||||||
|  |                 print('INSIDE SHIELDED PAUSE') | ||||||
|  |                 await tractor.pause(shield=True) | ||||||
|  |         else: | ||||||
|  |             # should raise it again, bubbling up to parent | ||||||
|  |             print('BUBBLING trio.Cancelled to parent task-nursery') | ||||||
|  |             await trio.lowlevel.checkpoint() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def pm_on_cancelled(): | ||||||
|  |     async with trio.open_nursery() as tn: | ||||||
|  |         tn.cancel_scope.cancel() | ||||||
|  |         try: | ||||||
|  |             await trio.sleep_forever() | ||||||
|  |         except trio.Cancelled: | ||||||
|  |             # should also raise `Cancelled` since | ||||||
|  |             # we didn't pass `shield=True`. | ||||||
|  |             try: | ||||||
|  |                 await tractor.post_mortem(hide_tb=False) | ||||||
|  |             except trio.Cancelled as taskc: | ||||||
|  | 
 | ||||||
|  |                 # should enter just fine, in fact it should | ||||||
|  |                 # be debugging the internals of the previous | ||||||
|  |                 # sin-shield call above Bo | ||||||
|  |                 await tractor.post_mortem( | ||||||
|  |                     hide_tb=False, | ||||||
|  |                     shield=True, | ||||||
|  |                 ) | ||||||
|  |                 raise taskc | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             raise RuntimeError('Dint cancel as expected!?') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def cancelled_before_pause( | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify that using a shielded pause works despite surrounding | ||||||
|  |     cancellation called state in the calling task. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     async with trio.open_nursery() as tn: | ||||||
|  |         cs: trio.CancelScope = await tn.start(cancellable_pause_loop) | ||||||
|  |         await trio.sleep(0.1) | ||||||
|  | 
 | ||||||
|  |     assert cs.cancelled_caught | ||||||
|  | 
 | ||||||
|  |     await pm_on_cancelled() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def main(): | ||||||
|  |     async with tractor.open_nursery( | ||||||
|  |         debug_mode=True, | ||||||
|  |     ) as n: | ||||||
|  |         portal: tractor.Portal = await n.run_in_actor( | ||||||
|  |             cancelled_before_pause, | ||||||
|  |         ) | ||||||
|  |         await portal.result() | ||||||
|  | 
 | ||||||
|  |         # ensure the same works in the root actor! | ||||||
|  |         await pm_on_cancelled() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if __name__ == '__main__': | ||||||
|  |     trio.run(main) | ||||||
|  | @ -4,9 +4,9 @@ import trio | ||||||
| 
 | 
 | ||||||
| async def gen(): | async def gen(): | ||||||
|     yield 'yo' |     yield 'yo' | ||||||
|     await tractor.breakpoint() |     await tractor.pause() | ||||||
|     yield 'yo' |     yield 'yo' | ||||||
|     await tractor.breakpoint() |     await tractor.pause() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
|  | @ -15,7 +15,7 @@ async def just_bp( | ||||||
| ) -> None: | ) -> None: | ||||||
| 
 | 
 | ||||||
|     await ctx.started() |     await ctx.started() | ||||||
|     await tractor.breakpoint() |     await tractor.pause() | ||||||
| 
 | 
 | ||||||
|     # TODO: bps and errors in this call.. |     # TODO: bps and errors in this call.. | ||||||
|     async for val in gen(): |     async for val in gen(): | ||||||
|  |  | ||||||
|  | @ -3,17 +3,20 @@ import tractor | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def breakpoint_forever(): | async def breakpoint_forever(): | ||||||
|     """Indefinitely re-enter debugger in child actor. |     ''' | ||||||
|     """ |     Indefinitely re-enter debugger in child actor. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|     while True: |     while True: | ||||||
|         await trio.sleep(0.1) |         await trio.sleep(0.1) | ||||||
|         await tractor.breakpoint() |         await tractor.pause() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def main(): | async def main(): | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         debug_mode=True, |         debug_mode=True, | ||||||
|  |         loglevel='cancel', | ||||||
|     ) as n: |     ) as n: | ||||||
| 
 | 
 | ||||||
|         portal = await n.run_in_actor( |         portal = await n.run_in_actor( | ||||||
|  |  | ||||||
|  | @ -3,16 +3,26 @@ import tractor | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def name_error(): | async def name_error(): | ||||||
|     getattr(doggypants) |     getattr(doggypants)  # noqa (on purpose) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def main(): | async def main(): | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         debug_mode=True, |         debug_mode=True, | ||||||
|     ) as n: |         # loglevel='transport', | ||||||
|  |     ) as an: | ||||||
| 
 | 
 | ||||||
|         portal = await n.run_in_actor(name_error) |         # TODO: ideally the REPL arrives at this frame in the parent, | ||||||
|         await portal.result() |         # ABOVE the @api_frame of `Portal.run_in_actor()` (which | ||||||
|  |         # should eventually not even be a portal method ... XD) | ||||||
|  |         # await tractor.pause() | ||||||
|  |         p: tractor.Portal = await an.run_in_actor(name_error) | ||||||
|  | 
 | ||||||
|  |         # with this style, should raise on this line | ||||||
|  |         await p.result() | ||||||
|  | 
 | ||||||
|  |         # with this alt style should raise at `open_nusery()` | ||||||
|  |         # return await p.result() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| if __name__ == '__main__': | if __name__ == '__main__': | ||||||
|  |  | ||||||
|  | @ -0,0 +1,169 @@ | ||||||
|  | from functools import partial | ||||||
|  | import time | ||||||
|  | 
 | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | 
 | ||||||
|  | # TODO: only import these when not running from test harness? | ||||||
|  | # can we detect `pexpect` usage maybe? | ||||||
|  | # from tractor.devx._debug import ( | ||||||
|  | #     get_lock, | ||||||
|  | #     get_debug_req, | ||||||
|  | # ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def sync_pause( | ||||||
|  |     use_builtin: bool = False, | ||||||
|  |     error: bool = False, | ||||||
|  |     hide_tb: bool = True, | ||||||
|  |     pre_sleep: float|None = None, | ||||||
|  | ): | ||||||
|  |     if pre_sleep: | ||||||
|  |         time.sleep(pre_sleep) | ||||||
|  | 
 | ||||||
|  |     if use_builtin: | ||||||
|  |         breakpoint(hide_tb=hide_tb) | ||||||
|  | 
 | ||||||
|  |     else: | ||||||
|  |         # TODO: maybe for testing some kind of cm style interface | ||||||
|  |         # where the `._set_trace()` call doesn't happen until block | ||||||
|  |         # exit? | ||||||
|  |         # assert get_lock().ctx_in_debug is None | ||||||
|  |         # assert get_debug_req().repl is None | ||||||
|  |         tractor.pause_from_sync() | ||||||
|  |         # assert get_debug_req().repl is None | ||||||
|  | 
 | ||||||
|  |     if error: | ||||||
|  |         raise RuntimeError('yoyo sync code error') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def start_n_sync_pause( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  | ): | ||||||
|  |     actor: tractor.Actor = tractor.current_actor() | ||||||
|  | 
 | ||||||
|  |     # sync to parent-side task | ||||||
|  |     await ctx.started() | ||||||
|  | 
 | ||||||
|  |     print(f'Entering `sync_pause()` in subactor: {actor.uid}\n') | ||||||
|  |     sync_pause() | ||||||
|  |     print(f'Exited `sync_pause()` in subactor: {actor.uid}\n') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def main() -> None: | ||||||
|  |     async with ( | ||||||
|  |         tractor.open_nursery( | ||||||
|  |             debug_mode=True, | ||||||
|  |             maybe_enable_greenback=True, | ||||||
|  |             enable_stack_on_sig=True, | ||||||
|  |             # loglevel='warning', | ||||||
|  |             # loglevel='devx', | ||||||
|  |         ) as an, | ||||||
|  |         trio.open_nursery() as tn, | ||||||
|  |     ): | ||||||
|  |         # just from root task | ||||||
|  |         sync_pause() | ||||||
|  | 
 | ||||||
|  |         p: tractor.Portal  = await an.start_actor( | ||||||
|  |             'subactor', | ||||||
|  |             enable_modules=[__name__], | ||||||
|  |             # infect_asyncio=True, | ||||||
|  |             debug_mode=True, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # TODO: 3 sub-actor usage cases: | ||||||
|  |         # -[x] via a `.open_context()` | ||||||
|  |         # -[ ] via a `.run_in_actor()` call | ||||||
|  |         # -[ ] via a `.run()` | ||||||
|  |         # -[ ] via a `.to_thread.run_sync()` in subactor | ||||||
|  |         async with p.open_context( | ||||||
|  |             start_n_sync_pause, | ||||||
|  |         ) as (ctx, first): | ||||||
|  |             assert first is None | ||||||
|  | 
 | ||||||
|  |             # TODO: handle bg-thread-in-root-actor special cases! | ||||||
|  |             # | ||||||
|  |             # there are a couple very subtle situations possible here | ||||||
|  |             # and they are likely to become more important as cpython | ||||||
|  |             # moves to support no-GIL. | ||||||
|  |             # | ||||||
|  |             # Cases: | ||||||
|  |             # 1. root-actor bg-threads that call `.pause_from_sync()` | ||||||
|  |             #   whilst an in-tree subactor also is using ` .pause()`. | ||||||
|  |             # |_ since the root-actor bg thread can not | ||||||
|  |             #   `Lock._debug_lock.acquire_nowait()` without running | ||||||
|  |             #   a `trio.Task`, AND because the | ||||||
|  |             #   `PdbREPL.set_continue()` is called from that | ||||||
|  |             #   bg-thread, we can not `._debug_lock.release()` | ||||||
|  |             #   either! | ||||||
|  |             #  |_ this results in no actor-tree `Lock` being used | ||||||
|  |             #    on behalf of the bg-thread and thus the subactor's | ||||||
|  |             #    task and the thread trying to to use stdio | ||||||
|  |             #    simultaneously which results in the classic TTY | ||||||
|  |             #    clobbering! | ||||||
|  |             # | ||||||
|  |             # 2. mutiple sync-bg-threads that call | ||||||
|  |             #   `.pause_from_sync()` where one is scheduled via | ||||||
|  |             #   `Nursery.start_soon(to_thread.run_sync)` in a bg | ||||||
|  |             #   task. | ||||||
|  |             # | ||||||
|  |             #   Due to the GIL, the threads never truly try to step | ||||||
|  |             #   through the REPL simultaneously, BUT their `logging` | ||||||
|  |             #   and traceback outputs are interleaved since the GIL | ||||||
|  |             #   (seemingly) on every REPL-input from the user | ||||||
|  |             #   switches threads.. | ||||||
|  |             # | ||||||
|  |             #   Soo, the context switching semantics of the GIL | ||||||
|  |             #   result in a very confusing and messy interaction UX | ||||||
|  |             #   since eval and (tb) print output is NOT synced to | ||||||
|  |             #   each REPL-cycle (like we normally make it via | ||||||
|  |             #   a `.set_continue()` callback triggering the | ||||||
|  |             #   `Lock.release()`). Ideally we can solve this | ||||||
|  |             #   usability issue NOW because this will of course be | ||||||
|  |             #   that much more important when eventually there is no | ||||||
|  |             #   GIL! | ||||||
|  | 
 | ||||||
|  |             # XXX should cause double REPL entry and thus TTY | ||||||
|  |             # clobbering due to case 1. above! | ||||||
|  |             tn.start_soon( | ||||||
|  |                 partial( | ||||||
|  |                     trio.to_thread.run_sync, | ||||||
|  |                     partial( | ||||||
|  |                         sync_pause, | ||||||
|  |                         use_builtin=False, | ||||||
|  |                         # pre_sleep=0.5, | ||||||
|  |                     ), | ||||||
|  |                     abandon_on_cancel=True, | ||||||
|  |                     thread_name='start_soon_root_bg_thread', | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             await tractor.pause() | ||||||
|  | 
 | ||||||
|  |             # XXX should cause double REPL entry and thus TTY | ||||||
|  |             # clobbering due to case 2. above! | ||||||
|  |             await trio.to_thread.run_sync( | ||||||
|  |                 partial( | ||||||
|  |                     sync_pause, | ||||||
|  |                     # NOTE this already works fine since in the new | ||||||
|  |                     # thread the `breakpoint()` built-in is never | ||||||
|  |                     # overloaded, thus NO locking is used, HOWEVER | ||||||
|  |                     # the case 2. from above still exists! | ||||||
|  |                     use_builtin=True, | ||||||
|  |                 ), | ||||||
|  |                 # TODO: with this `False` we can hang!??! | ||||||
|  |                 # abandon_on_cancel=False, | ||||||
|  |                 abandon_on_cancel=True, | ||||||
|  |                 thread_name='inline_root_bg_thread', | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         await ctx.cancel() | ||||||
|  | 
 | ||||||
|  |         # TODO: case where we cancel from trio-side while asyncio task | ||||||
|  |         # has debugger lock? | ||||||
|  |         await p.cancel_actor() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if __name__ == '__main__': | ||||||
|  |     trio.run(main) | ||||||
|  | @ -1,6 +1,11 @@ | ||||||
| import time | import time | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
|  | from tractor import ( | ||||||
|  |     ActorNursery, | ||||||
|  |     MsgStream, | ||||||
|  |     Portal, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # this is the first 2 actors, streamer_1 and streamer_2 | # this is the first 2 actors, streamer_1 and streamer_2 | ||||||
|  | @ -12,14 +17,18 @@ async def stream_data(seed): | ||||||
| 
 | 
 | ||||||
| # this is the third actor; the aggregator | # this is the third actor; the aggregator | ||||||
| async def aggregate(seed): | async def aggregate(seed): | ||||||
|     """Ensure that the two streams we receive match but only stream |     ''' | ||||||
|  |     Ensure that the two streams we receive match but only stream | ||||||
|     a single set of values to the parent. |     a single set of values to the parent. | ||||||
|     """ | 
 | ||||||
|     async with tractor.open_nursery() as nursery: |     ''' | ||||||
|         portals = [] |     an: ActorNursery | ||||||
|  |     async with tractor.open_nursery() as an: | ||||||
|  |         portals: list[Portal] = [] | ||||||
|         for i in range(1, 3): |         for i in range(1, 3): | ||||||
|             # fork point | 
 | ||||||
|             portal = await nursery.start_actor( |             # fork/spawn call | ||||||
|  |             portal = await an.start_actor( | ||||||
|                 name=f'streamer_{i}', |                 name=f'streamer_{i}', | ||||||
|                 enable_modules=[__name__], |                 enable_modules=[__name__], | ||||||
|             ) |             ) | ||||||
|  | @ -43,7 +52,11 @@ async def aggregate(seed): | ||||||
|         async with trio.open_nursery() as n: |         async with trio.open_nursery() as n: | ||||||
| 
 | 
 | ||||||
|             for portal in portals: |             for portal in portals: | ||||||
|                 n.start_soon(push_to_chan, portal, send_chan.clone()) |                 n.start_soon( | ||||||
|  |                     push_to_chan, | ||||||
|  |                     portal, | ||||||
|  |                     send_chan.clone(), | ||||||
|  |                 ) | ||||||
| 
 | 
 | ||||||
|             # close this local task's reference to send side |             # close this local task's reference to send side | ||||||
|             await send_chan.aclose() |             await send_chan.aclose() | ||||||
|  | @ -60,26 +73,36 @@ async def aggregate(seed): | ||||||
| 
 | 
 | ||||||
|             print("FINISHED ITERATING in aggregator") |             print("FINISHED ITERATING in aggregator") | ||||||
| 
 | 
 | ||||||
|         await nursery.cancel() |         await an.cancel() | ||||||
|         print("WAITING on `ActorNursery` to finish") |         print("WAITING on `ActorNursery` to finish") | ||||||
|     print("AGGREGATOR COMPLETE!") |     print("AGGREGATOR COMPLETE!") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # this is the main actor and *arbiter* | async def main() -> list[int]: | ||||||
| async def main(): |     ''' | ||||||
|     # a nursery which spawns "actors" |     This is the "root" actor's main task's entrypoint. | ||||||
|  | 
 | ||||||
|  |     By default (and if not otherwise specified) that root process | ||||||
|  |     also acts as a "registry actor" / "registrar" on the localhost | ||||||
|  |     for the purposes of multi-actor "service discovery". | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # yes, a nursery which spawns `trio`-"actors" B) | ||||||
|  |     an: ActorNursery | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         arbiter_addr=('127.0.0.1', 1616) |         loglevel='cancel', | ||||||
|     ) as nursery: |         debug_mode=True, | ||||||
|  |     ) as an: | ||||||
| 
 | 
 | ||||||
|         seed = int(1e3) |         seed = int(1e3) | ||||||
|         pre_start = time.time() |         pre_start = time.time() | ||||||
| 
 | 
 | ||||||
|         portal = await nursery.start_actor( |         portal: Portal = await an.start_actor( | ||||||
|             name='aggregator', |             name='aggregator', | ||||||
|             enable_modules=[__name__], |             enable_modules=[__name__], | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |         stream: MsgStream | ||||||
|         async with portal.open_stream_from( |         async with portal.open_stream_from( | ||||||
|             aggregate, |             aggregate, | ||||||
|             seed=seed, |             seed=seed, | ||||||
|  | @ -88,11 +111,12 @@ async def main(): | ||||||
|             start = time.time() |             start = time.time() | ||||||
|             # the portal call returns exactly what you'd expect |             # the portal call returns exactly what you'd expect | ||||||
|             # as if the remote "aggregate" function was called locally |             # as if the remote "aggregate" function was called locally | ||||||
|             result_stream = [] |             result_stream: list[int] = [] | ||||||
|             async for value in stream: |             async for value in stream: | ||||||
|                 result_stream.append(value) |                 result_stream.append(value) | ||||||
| 
 | 
 | ||||||
|         await portal.cancel_actor() |         cancelled: bool = await portal.cancel_actor() | ||||||
|  |         assert cancelled | ||||||
| 
 | 
 | ||||||
|         print(f"STREAM TIME = {time.time() - start}") |         print(f"STREAM TIME = {time.time() - start}") | ||||||
|         print(f"STREAM + SPAWN TIME = {time.time() - pre_start}") |         print(f"STREAM + SPAWN TIME = {time.time() - pre_start}") | ||||||
|  |  | ||||||
|  | @ -8,7 +8,10 @@ This uses no extra threads, fancy semaphores or futures; all we need | ||||||
| is ``tractor``'s channels. | is ``tractor``'s channels. | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
| from contextlib import asynccontextmanager | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  |     aclosing, | ||||||
|  | ) | ||||||
| from typing import Callable | from typing import Callable | ||||||
| import itertools | import itertools | ||||||
| import math | import math | ||||||
|  | @ -16,7 +19,6 @@ import time | ||||||
| 
 | 
 | ||||||
| import tractor | import tractor | ||||||
| import trio | import trio | ||||||
| from async_generator import aclosing |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| PRIMES = [ | PRIMES = [ | ||||||
|  | @ -44,7 +46,7 @@ async def is_prime(n): | ||||||
|     return True |     return True | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @asynccontextmanager | @acm | ||||||
| async def worker_pool(workers=4): | async def worker_pool(workers=4): | ||||||
|     """Though it's a trivial special case for ``tractor``, the well |     """Though it's a trivial special case for ``tractor``, the well | ||||||
|     known "worker pool" seems to be the defacto "but, I want this |     known "worker pool" seems to be the defacto "but, I want this | ||||||
|  |  | ||||||
|  | @ -13,7 +13,7 @@ async def simple_rpc( | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     # signal to parent that we're up much like |     # signal to parent that we're up much like | ||||||
|     # ``trio_typing.TaskStatus.started()`` |     # ``trio.TaskStatus.started()`` | ||||||
|     await ctx.started(data + 1) |     await ctx.started(data + 1) | ||||||
| 
 | 
 | ||||||
|     async with ctx.open_stream() as stream: |     async with ctx.open_stream() as stream: | ||||||
|  |  | ||||||
|  | @ -9,7 +9,7 @@ async def main(service_name): | ||||||
|     async with tractor.open_nursery() as an: |     async with tractor.open_nursery() as an: | ||||||
|         await an.start_actor(service_name) |         await an.start_actor(service_name) | ||||||
| 
 | 
 | ||||||
|         async with tractor.get_arbiter('127.0.0.1', 1616) as portal: |         async with tractor.get_registry('127.0.0.1', 1616) as portal: | ||||||
|             print(f"Arbiter is listening on {portal.channel}") |             print(f"Arbiter is listening on {portal.channel}") | ||||||
| 
 | 
 | ||||||
|         async with tractor.wait_for_actor(service_name) as sockaddr: |         async with tractor.wait_for_actor(service_name) as sockaddr: | ||||||
|  |  | ||||||
|  | @ -0,0 +1,18 @@ | ||||||
|  | First generate a built disti: | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | python -m pip install --upgrade build | ||||||
|  | python -m build --sdist --outdir dist/alpha5/ | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | Then try a test ``pypi`` upload: | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | python -m twine upload --repository testpypi dist/alpha5/* | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | The push to `pypi` for realz. | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | python -m twine upload --repository testpypi dist/alpha5/* | ||||||
|  | ``` | ||||||
							
								
								
									
										120
									
								
								pyproject.toml
								
								
								
								
							
							
						
						
									
										120
									
								
								pyproject.toml
								
								
								
								
							|  | @ -1,3 +1,95 @@ | ||||||
|  | [build-system] | ||||||
|  | requires = ["hatchling"] | ||||||
|  | build-backend = "hatchling.build" | ||||||
|  | 
 | ||||||
|  | # ------ build-system ------ | ||||||
|  | 
 | ||||||
|  | [project] | ||||||
|  | name = "tractor" | ||||||
|  | version = "0.1.0a6dev0" | ||||||
|  | description = 'structured concurrent `trio`-"actors"' | ||||||
|  | authors = [{ name = "Tyler Goodlet", email = "goodboy_foss@protonmail.com" }] | ||||||
|  | requires-python = ">= 3.11" | ||||||
|  | readme = "docs/README.rst" | ||||||
|  | license = "AGPL-3.0-or-later" | ||||||
|  | keywords = [ | ||||||
|  |   "trio", | ||||||
|  |   "async", | ||||||
|  |   "concurrency", | ||||||
|  |   "structured concurrency", | ||||||
|  |   "actor model", | ||||||
|  |   "distributed", | ||||||
|  |   "multiprocessing", | ||||||
|  | ] | ||||||
|  | classifiers = [ | ||||||
|  |   "Development Status :: 3 - Alpha", | ||||||
|  |   "Operating System :: POSIX :: Linux", | ||||||
|  |   "Framework :: Trio", | ||||||
|  |   "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", | ||||||
|  |   "Programming Language :: Python :: Implementation :: CPython", | ||||||
|  |   "Programming Language :: Python :: 3 :: Only", | ||||||
|  |   "Programming Language :: Python :: 3.11", | ||||||
|  |   "Topic :: System :: Distributed Computing", | ||||||
|  | ] | ||||||
|  | dependencies = [ | ||||||
|  | # trio runtime and friends | ||||||
|  |   # (poetry) proper range specs, | ||||||
|  |   # https://packaging.python.org/en/latest/discussions/install-requires-vs-requirements/#id5 | ||||||
|  |   # TODO, for 3.13 we must go go `0.27` which means we have to | ||||||
|  |   # disable strict egs or port to handling them internally! | ||||||
|  |   # trio='^0.27' | ||||||
|  |   "trio>=0.24,<0.25", | ||||||
|  |   "tricycle>=0.4.1,<0.5", | ||||||
|  |   "trio-typing>=0.10.0,<0.11", | ||||||
|  | 
 | ||||||
|  |   "wrapt>=1.16.0,<2", | ||||||
|  |   "colorlog>=6.8.2,<7", | ||||||
|  | 
 | ||||||
|  | # built-in multi-actor `pdb` REPL | ||||||
|  |   "pdbp>=1.5.0,<2", | ||||||
|  | 
 | ||||||
|  | # typed IPC msging | ||||||
|  | # TODO, get back on release once 3.13 support is out! | ||||||
|  |   "msgspec", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | # ------ project ------ | ||||||
|  | 
 | ||||||
|  | [dependency-groups] | ||||||
|  | dev = [ | ||||||
|  |   # test suite | ||||||
|  |   # TODO: maybe some of these layout choices? | ||||||
|  |   # https://docs.pytest.org/en/8.0.x/explanation/goodpractices.html#choosing-a-test-layout-import-rules | ||||||
|  |   "pytest>=8.2.0,<9", | ||||||
|  |   "pexpect>=4.9.0,<5", | ||||||
|  |   # `tractor.devx` tooling | ||||||
|  |   "greenback>=1.2.1,<2", | ||||||
|  |   "stackscope>=0.2.2,<0.3", | ||||||
|  | 
 | ||||||
|  |   # xonsh usage/integration (namely as @goodboy's sh of choice Bp) | ||||||
|  |   "xonsh>=0.19.1", | ||||||
|  |   "xontrib-vox>=0.0.1,<0.0.2", | ||||||
|  |   "prompt-toolkit>=3.0.43,<4", | ||||||
|  |   "xonsh-vox-tabcomplete>=0.5,<0.6", | ||||||
|  |   "pyperclip>=1.9.0", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [tool.uv.sources] | ||||||
|  | msgspec = { git = "https://github.com/jcrist/msgspec.git" } | ||||||
|  | 
 | ||||||
|  | # ------ tool.uv.sources ------ | ||||||
|  | # TODO, distributed (multi-host) extensions | ||||||
|  | # linux kernel networking | ||||||
|  | # 'pyroute2 | ||||||
|  | 
 | ||||||
|  | [tool.hatch.build.targets.sdist] | ||||||
|  | include = ["tractor"] | ||||||
|  | 
 | ||||||
|  | [tool.hatch.build.targets.wheel] | ||||||
|  | include = ["tractor"] | ||||||
|  | 
 | ||||||
|  | # ------ dependency-groups ------ | ||||||
|  | 
 | ||||||
| [tool.towncrier] | [tool.towncrier] | ||||||
| package = "tractor" | package = "tractor" | ||||||
| filename = "NEWS.rst" | filename = "NEWS.rst" | ||||||
|  | @ -7,22 +99,42 @@ title_format = "tractor {version} ({project_date})" | ||||||
| template = "nooz/_template.rst" | template = "nooz/_template.rst" | ||||||
| all_bullets = true | all_bullets = true | ||||||
| 
 | 
 | ||||||
|   [[tool.towncrier.type]] | [[tool.towncrier.type]] | ||||||
|   directory = "feature" |   directory = "feature" | ||||||
|   name = "Features" |   name = "Features" | ||||||
|   showcontent = true |   showcontent = true | ||||||
| 
 | 
 | ||||||
|   [[tool.towncrier.type]] | [[tool.towncrier.type]] | ||||||
|   directory = "bugfix" |   directory = "bugfix" | ||||||
|   name = "Bug Fixes" |   name = "Bug Fixes" | ||||||
|   showcontent = true |   showcontent = true | ||||||
| 
 | 
 | ||||||
|   [[tool.towncrier.type]] | [[tool.towncrier.type]] | ||||||
|   directory = "doc" |   directory = "doc" | ||||||
|   name = "Improved Documentation" |   name = "Improved Documentation" | ||||||
|   showcontent = true |   showcontent = true | ||||||
| 
 | 
 | ||||||
|   [[tool.towncrier.type]] | [[tool.towncrier.type]] | ||||||
|   directory = "trivial" |   directory = "trivial" | ||||||
|   name = "Trivial/Internal Changes" |   name = "Trivial/Internal Changes" | ||||||
|   showcontent = true |   showcontent = true | ||||||
|  | 
 | ||||||
|  | # ------ tool.towncrier ------ | ||||||
|  | 
 | ||||||
|  | [tool.pytest.ini_options] | ||||||
|  | minversion = '6.0' | ||||||
|  | testpaths = [ | ||||||
|  |   'tests' | ||||||
|  | ] | ||||||
|  | addopts = [ | ||||||
|  |   # TODO: figure out why this isn't working.. | ||||||
|  |   '--rootdir=./tests', | ||||||
|  | 
 | ||||||
|  |   '--import-mode=importlib', | ||||||
|  |   # don't show frickin captured logs AGAIN in the report.. | ||||||
|  |   '--show-capture=no', | ||||||
|  | ] | ||||||
|  | log_cli = false | ||||||
|  | # TODO: maybe some of these layout choices? | ||||||
|  | # https://docs.pytest.org/en/8.0.x/explanation/goodpractices.html#choosing-a-test-layout-import-rules | ||||||
|  | # pythonpath = "src" | ||||||
|  |  | ||||||
|  | @ -0,0 +1,8 @@ | ||||||
|  | # vim: ft=ini | ||||||
|  | # pytest.ini for tractor | ||||||
|  | 
 | ||||||
|  | [pytest] | ||||||
|  | # don't show frickin captured logs AGAIN in the report.. | ||||||
|  | addopts = --show-capture='no' | ||||||
|  | log_cli = false | ||||||
|  | ; minversion = 6.0 | ||||||
|  | @ -0,0 +1,82 @@ | ||||||
|  | # from default `ruff.toml` @ | ||||||
|  | # https://docs.astral.sh/ruff/configuration/ | ||||||
|  | 
 | ||||||
|  | # Exclude a variety of commonly ignored directories. | ||||||
|  | exclude = [ | ||||||
|  |     ".bzr", | ||||||
|  |     ".direnv", | ||||||
|  |     ".eggs", | ||||||
|  |     ".git", | ||||||
|  |     ".git-rewrite", | ||||||
|  |     ".hg", | ||||||
|  |     ".ipynb_checkpoints", | ||||||
|  |     ".mypy_cache", | ||||||
|  |     ".nox", | ||||||
|  |     ".pants.d", | ||||||
|  |     ".pyenv", | ||||||
|  |     ".pytest_cache", | ||||||
|  |     ".pytype", | ||||||
|  |     ".ruff_cache", | ||||||
|  |     ".svn", | ||||||
|  |     ".tox", | ||||||
|  |     ".venv", | ||||||
|  |     ".vscode", | ||||||
|  |     "__pypackages__", | ||||||
|  |     "_build", | ||||||
|  |     "buck-out", | ||||||
|  |     "build", | ||||||
|  |     "dist", | ||||||
|  |     "node_modules", | ||||||
|  |     "site-packages", | ||||||
|  |     "venv", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | # Same as Black. | ||||||
|  | line-length = 88 | ||||||
|  | indent-width = 4 | ||||||
|  | 
 | ||||||
|  | # Assume Python 3.9 | ||||||
|  | target-version = "py311" | ||||||
|  | 
 | ||||||
|  | [lint] | ||||||
|  | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`)  codes by default. | ||||||
|  | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or | ||||||
|  | # McCabe complexity (`C901`) by default. | ||||||
|  | select = ["E4", "E7", "E9", "F"] | ||||||
|  | ignore = [ | ||||||
|  |   'E402',  # https://docs.astral.sh/ruff/rules/module-import-not-at-top-of-file/ | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | # Allow fix for all enabled rules (when `--fix`) is provided. | ||||||
|  | fixable = ["ALL"] | ||||||
|  | unfixable = [] | ||||||
|  | 
 | ||||||
|  | # Allow unused variables when underscore-prefixed. | ||||||
|  | # dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" | ||||||
|  | 
 | ||||||
|  | [format] | ||||||
|  | # Use single quotes in `ruff format`. | ||||||
|  | quote-style = "single" | ||||||
|  | 
 | ||||||
|  | # Like Black, indent with spaces, rather than tabs. | ||||||
|  | indent-style = "space" | ||||||
|  | 
 | ||||||
|  | # Like Black, respect magic trailing commas. | ||||||
|  | skip-magic-trailing-comma = false | ||||||
|  | 
 | ||||||
|  | # Like Black, automatically detect the appropriate line ending. | ||||||
|  | line-ending = "auto" | ||||||
|  | 
 | ||||||
|  | # Enable auto-formatting of code examples in docstrings. Markdown, | ||||||
|  | # reStructuredText code/literal blocks and doctests are all supported. | ||||||
|  | # | ||||||
|  | # This is currently disabled by default, but it is planned for this | ||||||
|  | # to be opt-out in the future. | ||||||
|  | docstring-code-format = false | ||||||
|  | 
 | ||||||
|  | # Set the line length limit used when formatting code snippets in | ||||||
|  | # docstrings. | ||||||
|  | # | ||||||
|  | # This only has an effect when the `docstring-code-format` setting is | ||||||
|  | # enabled. | ||||||
|  | docstring-code-line-length = "dynamic" | ||||||
							
								
								
									
										32
									
								
								setup.py
								
								
								
								
							
							
						
						
									
										32
									
								
								setup.py
								
								
								
								
							|  | @ -26,7 +26,7 @@ with open('docs/README.rst', encoding='utf-8') as f: | ||||||
| setup( | setup( | ||||||
|     name="tractor", |     name="tractor", | ||||||
|     version='0.1.0a6dev0',  # alpha zone |     version='0.1.0a6dev0',  # alpha zone | ||||||
|     description='structured concurrrent `trio`-"actors"', |     description='structured concurrent `trio`-"actors"', | ||||||
|     long_description=readme, |     long_description=readme, | ||||||
|     license='AGPLv3', |     license='AGPLv3', | ||||||
|     author='Tyler Goodlet', |     author='Tyler Goodlet', | ||||||
|  | @ -36,44 +36,48 @@ setup( | ||||||
|     platforms=['linux', 'windows'], |     platforms=['linux', 'windows'], | ||||||
|     packages=[ |     packages=[ | ||||||
|         'tractor', |         'tractor', | ||||||
|         'tractor.experimental', |         'tractor.experimental',  # wacky ideas | ||||||
|         'tractor.trionics', |         'tractor.trionics',  # trio extensions | ||||||
|  |         'tractor.msg',  # lowlevel data types | ||||||
|  |         'tractor._testing',  # internal cross-subsys suite utils | ||||||
|  |         'tractor.devx',  # "dev-experience" | ||||||
|     ], |     ], | ||||||
|     install_requires=[ |     install_requires=[ | ||||||
| 
 | 
 | ||||||
|         # trio related |         # trio related | ||||||
|         # proper range spec: |         # proper range spec: | ||||||
|         # https://packaging.python.org/en/latest/discussions/install-requires-vs-requirements/#id5 |         # https://packaging.python.org/en/latest/discussions/install-requires-vs-requirements/#id5 | ||||||
|         'trio >= 0.22', |         'trio == 0.24', | ||||||
|         'async_generator', | 
 | ||||||
|         'trio_typing', |         # 'async_generator',  # in stdlib mostly! | ||||||
|         'exceptiongroup', |         # 'trio_typing',  # trio==0.23.0 has type hints! | ||||||
|  |         # 'exceptiongroup',  # in stdlib as of 3.11! | ||||||
| 
 | 
 | ||||||
|         # tooling |         # tooling | ||||||
|  |         'stackscope', | ||||||
|         'tricycle', |         'tricycle', | ||||||
|         'trio_typing', |         'trio_typing', | ||||||
|         'colorlog', |         'colorlog', | ||||||
|         'wrapt', |         'wrapt', | ||||||
| 
 | 
 | ||||||
|         # IPC serialization |         # IPC serialization | ||||||
|         'msgspec', |         'msgspec>=0.18.5', | ||||||
| 
 | 
 | ||||||
|         # debug mode REPL |         # debug mode REPL | ||||||
|         'pdbp', |         'pdbp', | ||||||
| 
 | 
 | ||||||
|  |         # TODO: distributed transport using | ||||||
|  |         # linux kernel networking | ||||||
|  |         # 'pyroute2', | ||||||
|  | 
 | ||||||
|         # pip ref docs on these specs: |         # pip ref docs on these specs: | ||||||
|         # https://pip.pypa.io/en/stable/reference/requirement-specifiers/#examples |         # https://pip.pypa.io/en/stable/reference/requirement-specifiers/#examples | ||||||
|         # and pep: |         # and pep: | ||||||
|         # https://peps.python.org/pep-0440/#version-specifiers |         # https://peps.python.org/pep-0440/#version-specifiers | ||||||
| 
 | 
 | ||||||
|         # windows deps workaround for ``pdbpp`` |  | ||||||
|         # https://github.com/pdbpp/pdbpp/issues/498 |  | ||||||
|         # https://github.com/pdbpp/fancycompleter/issues/37 |  | ||||||
|         'pyreadline3 ; platform_system == "Windows"', |  | ||||||
| 
 |  | ||||||
|     ], |     ], | ||||||
|     tests_require=['pytest'], |     tests_require=['pytest'], | ||||||
|     python_requires=">=3.10", |     python_requires=">=3.11", | ||||||
|     keywords=[ |     keywords=[ | ||||||
|         'trio', |         'trio', | ||||||
|         'async', |         'async', | ||||||
|  |  | ||||||
|  | @ -7,94 +7,19 @@ import os | ||||||
| import random | import random | ||||||
| import signal | import signal | ||||||
| import platform | import platform | ||||||
| import pathlib |  | ||||||
| import time | import time | ||||||
| import inspect |  | ||||||
| from functools import partial, wraps |  | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| import trio |  | ||||||
| import tractor | import tractor | ||||||
|  | from tractor._testing import ( | ||||||
|  |     examples_dir as examples_dir, | ||||||
|  |     tractor_test as tractor_test, | ||||||
|  |     expect_ctxc as expect_ctxc, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
|  | # TODO: include wtv plugin(s) we build in `._testing.pytest`? | ||||||
| pytest_plugins = ['pytester'] | pytest_plugins = ['pytester'] | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| def tractor_test(fn): |  | ||||||
|     """ |  | ||||||
|     Use: |  | ||||||
| 
 |  | ||||||
|     @tractor_test |  | ||||||
|     async def test_whatever(): |  | ||||||
|         await ... |  | ||||||
| 
 |  | ||||||
|     If fixtures: |  | ||||||
| 
 |  | ||||||
|         - ``arb_addr`` (a socket addr tuple where arbiter is listening) |  | ||||||
|         - ``loglevel`` (logging level passed to tractor internals) |  | ||||||
|         - ``start_method`` (subprocess spawning backend) |  | ||||||
| 
 |  | ||||||
|     are defined in the `pytest` fixture space they will be automatically |  | ||||||
|     injected to tests declaring these funcargs. |  | ||||||
|     """ |  | ||||||
|     @wraps(fn) |  | ||||||
|     def wrapper( |  | ||||||
|         *args, |  | ||||||
|         loglevel=None, |  | ||||||
|         arb_addr=None, |  | ||||||
|         start_method=None, |  | ||||||
|         **kwargs |  | ||||||
|     ): |  | ||||||
|         # __tracebackhide__ = True |  | ||||||
| 
 |  | ||||||
|         if 'arb_addr' in inspect.signature(fn).parameters: |  | ||||||
|             # injects test suite fixture value to test as well |  | ||||||
|             # as `run()` |  | ||||||
|             kwargs['arb_addr'] = arb_addr |  | ||||||
| 
 |  | ||||||
|         if 'loglevel' in inspect.signature(fn).parameters: |  | ||||||
|             # allows test suites to define a 'loglevel' fixture |  | ||||||
|             # that activates the internal logging |  | ||||||
|             kwargs['loglevel'] = loglevel |  | ||||||
| 
 |  | ||||||
|         if start_method is None: |  | ||||||
|             if platform.system() == "Windows": |  | ||||||
|                 start_method = 'trio' |  | ||||||
| 
 |  | ||||||
|         if 'start_method' in inspect.signature(fn).parameters: |  | ||||||
|             # set of subprocess spawning backends |  | ||||||
|             kwargs['start_method'] = start_method |  | ||||||
| 
 |  | ||||||
|         if kwargs: |  | ||||||
| 
 |  | ||||||
|             # use explicit root actor start |  | ||||||
| 
 |  | ||||||
|             async def _main(): |  | ||||||
|                 async with tractor.open_root_actor( |  | ||||||
|                     # **kwargs, |  | ||||||
|                     arbiter_addr=arb_addr, |  | ||||||
|                     loglevel=loglevel, |  | ||||||
|                     start_method=start_method, |  | ||||||
| 
 |  | ||||||
|                     # TODO: only enable when pytest is passed --pdb |  | ||||||
|                     # debug_mode=True, |  | ||||||
| 
 |  | ||||||
|                 ): |  | ||||||
|                     await fn(*args, **kwargs) |  | ||||||
| 
 |  | ||||||
|             main = _main |  | ||||||
| 
 |  | ||||||
|         else: |  | ||||||
|             # use implicit root actor start |  | ||||||
|             main = partial(fn, *args, **kwargs) |  | ||||||
| 
 |  | ||||||
|         return trio.run(main) |  | ||||||
| 
 |  | ||||||
|     return wrapper |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| _arb_addr = '127.0.0.1', random.randint(1000, 9999) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| # Sending signal.SIGINT on subprocess fails on windows. Use CTRL_* alternatives | # Sending signal.SIGINT on subprocess fails on windows. Use CTRL_* alternatives | ||||||
| if platform.system() == 'Windows': | if platform.system() == 'Windows': | ||||||
|     _KILL_SIGNAL = signal.CTRL_BREAK_EVENT |     _KILL_SIGNAL = signal.CTRL_BREAK_EVENT | ||||||
|  | @ -114,41 +39,45 @@ no_windows = pytest.mark.skipif( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def repodir() -> pathlib.Path: |  | ||||||
|     ''' |  | ||||||
|     Return the abspath to the repo directory. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     # 2 parents up to step up through tests/<repo_dir> |  | ||||||
|     return pathlib.Path(__file__).parent.parent.absolute() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def examples_dir() -> pathlib.Path: |  | ||||||
|     ''' |  | ||||||
|     Return the abspath to the examples directory as `pathlib.Path`. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     return repodir() / 'examples' |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def pytest_addoption(parser): | def pytest_addoption(parser): | ||||||
|     parser.addoption( |     parser.addoption( | ||||||
|         "--ll", action="store", dest='loglevel', |         "--ll", | ||||||
|  |         action="store", | ||||||
|  |         dest='loglevel', | ||||||
|         default='ERROR', help="logging level to set when testing" |         default='ERROR', help="logging level to set when testing" | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     parser.addoption( |     parser.addoption( | ||||||
|         "--spawn-backend", action="store", dest='spawn_backend', |         "--spawn-backend", | ||||||
|  |         action="store", | ||||||
|  |         dest='spawn_backend', | ||||||
|         default='trio', |         default='trio', | ||||||
|         help="Processing spawning backend to use for test run", |         help="Processing spawning backend to use for test run", | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  |     parser.addoption( | ||||||
|  |         "--tpdb", "--debug-mode", | ||||||
|  |         action="store_true", | ||||||
|  |         dest='tractor_debug_mode', | ||||||
|  |         # default=False, | ||||||
|  |         help=( | ||||||
|  |             'Enable a flag that can be used by tests to to set the ' | ||||||
|  |             '`debug_mode: bool` for engaging the internal ' | ||||||
|  |             'multi-proc debugger sys.' | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| def pytest_configure(config): | def pytest_configure(config): | ||||||
|     backend = config.option.spawn_backend |     backend = config.option.spawn_backend | ||||||
|     tractor._spawn.try_set_start_method(backend) |     tractor._spawn.try_set_start_method(backend) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.fixture(scope='session') | ||||||
|  | def debug_mode(request): | ||||||
|  |     return request.config.option.tractor_debug_mode | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @pytest.fixture(scope='session', autouse=True) | @pytest.fixture(scope='session', autouse=True) | ||||||
| def loglevel(request): | def loglevel(request): | ||||||
|     orig = tractor.log._default_loglevel |     orig = tractor.log._default_loglevel | ||||||
|  | @ -168,14 +97,35 @@ _ci_env: bool = os.environ.get('CI', False) | ||||||
| 
 | 
 | ||||||
| @pytest.fixture(scope='session') | @pytest.fixture(scope='session') | ||||||
| def ci_env() -> bool: | def ci_env() -> bool: | ||||||
|     """Detect CI envoirment. |     ''' | ||||||
|     """ |     Detect CI envoirment. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|     return _ci_env |     return _ci_env | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | # TODO: also move this to `._testing` for now? | ||||||
|  | # -[ ] possibly generalize and re-use for multi-tree spawning | ||||||
|  | #    along with the new stuff for multi-addrs in distribute_dis | ||||||
|  | #    branch? | ||||||
|  | # | ||||||
|  | # choose randomly at import time | ||||||
|  | _reg_addr: tuple[str, int] = ( | ||||||
|  |     '127.0.0.1', | ||||||
|  |     random.randint(1000, 9999), | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @pytest.fixture(scope='session') | @pytest.fixture(scope='session') | ||||||
| def arb_addr(): | def reg_addr() -> tuple[str, int]: | ||||||
|     return _arb_addr | 
 | ||||||
|  |     # globally override the runtime to the per-test-session-dynamic | ||||||
|  |     # addr so that all tests never conflict with any other actor | ||||||
|  |     # tree using the default. | ||||||
|  |     from tractor import _root | ||||||
|  |     _root._default_lo_addrs = [_reg_addr] | ||||||
|  | 
 | ||||||
|  |     return _reg_addr | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def pytest_generate_tests(metafunc): | def pytest_generate_tests(metafunc): | ||||||
|  | @ -200,6 +150,18 @@ def pytest_generate_tests(metafunc): | ||||||
|         metafunc.parametrize("start_method", [spawn_backend], scope='module') |         metafunc.parametrize("start_method", [spawn_backend], scope='module') | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | # TODO: a way to let test scripts (like from `examples/`) | ||||||
|  | # guarantee they won't registry addr collide! | ||||||
|  | # @pytest.fixture | ||||||
|  | # def open_test_runtime( | ||||||
|  | #     reg_addr: tuple, | ||||||
|  | # ) -> AsyncContextManager: | ||||||
|  | #     return partial( | ||||||
|  | #         tractor.open_nursery, | ||||||
|  | #         registry_addrs=[reg_addr], | ||||||
|  | #     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| def sig_prog(proc, sig): | def sig_prog(proc, sig): | ||||||
|     "Kill the actor-process with ``sig``." |     "Kill the actor-process with ``sig``." | ||||||
|     proc.send_signal(sig) |     proc.send_signal(sig) | ||||||
|  | @ -212,34 +174,40 @@ def sig_prog(proc, sig): | ||||||
|     assert ret |     assert ret | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | # TODO: factor into @cm and move to `._testing`? | ||||||
| @pytest.fixture | @pytest.fixture | ||||||
| def daemon( | def daemon( | ||||||
|     loglevel: str, |     loglevel: str, | ||||||
|     testdir, |     testdir, | ||||||
|     arb_addr: tuple[str, int], |     reg_addr: tuple[str, int], | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     Run a daemon actor as a "remote arbiter". |     Run a daemon root actor as a separate actor-process tree and | ||||||
|  |     "remote registrar" for discovery-protocol related tests. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     if loglevel in ('trace', 'debug'): |     if loglevel in ('trace', 'debug'): | ||||||
|         # too much logging will lock up the subproc (smh) |         # XXX: too much logging will lock up the subproc (smh) | ||||||
|         loglevel = 'info' |         loglevel: str = 'info' | ||||||
| 
 | 
 | ||||||
|     cmdargs = [ |     code: str = ( | ||||||
|         sys.executable, '-c', |             "import tractor; " | ||||||
|         "import tractor; tractor.run_daemon([], registry_addr={}, loglevel={})" |             "tractor.run_daemon([], registry_addrs={reg_addrs}, loglevel={ll})" | ||||||
|         .format( |     ).format( | ||||||
|             arb_addr, |         reg_addrs=str([reg_addr]), | ||||||
|             "'{}'".format(loglevel) if loglevel else None) |         ll="'{}'".format(loglevel) if loglevel else None, | ||||||
|  |     ) | ||||||
|  |     cmd: list[str] = [ | ||||||
|  |         sys.executable, | ||||||
|  |         '-c', code, | ||||||
|     ] |     ] | ||||||
|     kwargs = dict() |     kwargs = {} | ||||||
|     if platform.system() == 'Windows': |     if platform.system() == 'Windows': | ||||||
|         # without this, tests hang on windows forever |         # without this, tests hang on windows forever | ||||||
|         kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP |         kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP | ||||||
| 
 | 
 | ||||||
|     proc = testdir.popen( |     proc = testdir.popen( | ||||||
|         cmdargs, |         cmd, | ||||||
|         stdout=subprocess.PIPE, |         stdout=subprocess.PIPE, | ||||||
|         stderr=subprocess.PIPE, |         stderr=subprocess.PIPE, | ||||||
|         **kwargs, |         **kwargs, | ||||||
|  |  | ||||||
|  | @ -0,0 +1,219 @@ | ||||||
|  | ''' | ||||||
|  | `tractor.devx.*` tooling sub-pkg test space. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | import time | ||||||
|  | from typing import ( | ||||||
|  |     Callable, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | import pytest | ||||||
|  | from pexpect.exceptions import ( | ||||||
|  |     TIMEOUT, | ||||||
|  | ) | ||||||
|  | from pexpect.spawnbase import SpawnBase | ||||||
|  | 
 | ||||||
|  | from tractor._testing import ( | ||||||
|  |     mk_cmd, | ||||||
|  | ) | ||||||
|  | from tractor.devx._debug import ( | ||||||
|  |     _pause_msg as _pause_msg, | ||||||
|  |     _crash_msg as _crash_msg, | ||||||
|  |     _repl_fail_msg as _repl_fail_msg, | ||||||
|  |     _ctlc_ignore_header as _ctlc_ignore_header, | ||||||
|  | ) | ||||||
|  | from conftest import ( | ||||||
|  |     _ci_env, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.fixture | ||||||
|  | def spawn( | ||||||
|  |     start_method, | ||||||
|  |     testdir: pytest.Testdir, | ||||||
|  |     reg_addr: tuple[str, int], | ||||||
|  | 
 | ||||||
|  | ) -> Callable[[str], None]: | ||||||
|  |     ''' | ||||||
|  |     Use the `pexpect` module shipped via `testdir.spawn()` to | ||||||
|  |     run an `./examples/..` script by name. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     if start_method != 'trio': | ||||||
|  |         pytest.skip( | ||||||
|  |             '`pexpect` based tests only supported on `trio` backend' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     def _spawn( | ||||||
|  |         cmd: str, | ||||||
|  |         **mkcmd_kwargs, | ||||||
|  |     ): | ||||||
|  |         return testdir.spawn( | ||||||
|  |             cmd=mk_cmd( | ||||||
|  |                 cmd, | ||||||
|  |                 **mkcmd_kwargs, | ||||||
|  |             ), | ||||||
|  |             expect_timeout=3, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # such that test-dep can pass input script name. | ||||||
|  |     return _spawn | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.fixture( | ||||||
|  |     params=[False, True], | ||||||
|  |     ids='ctl-c={}'.format, | ||||||
|  | ) | ||||||
|  | def ctlc( | ||||||
|  |     request, | ||||||
|  |     ci_env: bool, | ||||||
|  | 
 | ||||||
|  | ) -> bool: | ||||||
|  | 
 | ||||||
|  |     use_ctlc = request.param | ||||||
|  | 
 | ||||||
|  |     node = request.node | ||||||
|  |     markers = node.own_markers | ||||||
|  |     for mark in markers: | ||||||
|  |         if mark.name == 'has_nested_actors': | ||||||
|  |             pytest.skip( | ||||||
|  |                 f'Test {node} has nested actors and fails with Ctrl-C.\n' | ||||||
|  |                 f'The test can sometimes run fine locally but until' | ||||||
|  |                 ' we solve' 'this issue this CI test will be xfail:\n' | ||||||
|  |                 'https://github.com/goodboy/tractor/issues/320' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     if use_ctlc: | ||||||
|  |         # XXX: disable pygments highlighting for auto-tests | ||||||
|  |         # since some envs (like actions CI) will struggle | ||||||
|  |         # the the added color-char encoding.. | ||||||
|  |         from tractor.devx._debug import TractorConfig | ||||||
|  |         TractorConfig.use_pygements = False | ||||||
|  | 
 | ||||||
|  |     yield use_ctlc | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def expect( | ||||||
|  |     child, | ||||||
|  | 
 | ||||||
|  |     # normally a `pdb` prompt by default | ||||||
|  |     patt: str, | ||||||
|  | 
 | ||||||
|  |     **kwargs, | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     Expect wrapper that prints last seen console | ||||||
|  |     data before failing. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     try: | ||||||
|  |         child.expect( | ||||||
|  |             patt, | ||||||
|  |             **kwargs, | ||||||
|  |         ) | ||||||
|  |     except TIMEOUT: | ||||||
|  |         before = str(child.before.decode()) | ||||||
|  |         print(before) | ||||||
|  |         raise | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | PROMPT = r"\(Pdb\+\)" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def in_prompt_msg( | ||||||
|  |     child: SpawnBase, | ||||||
|  |     parts: list[str], | ||||||
|  | 
 | ||||||
|  |     pause_on_false: bool = False, | ||||||
|  |     err_on_false: bool = False, | ||||||
|  |     print_prompt_on_false: bool = True, | ||||||
|  | 
 | ||||||
|  | ) -> bool: | ||||||
|  |     ''' | ||||||
|  |     Predicate check if (the prompt's) std-streams output has all | ||||||
|  |     `str`-parts in it. | ||||||
|  | 
 | ||||||
|  |     Can be used in test asserts for bulk matching expected | ||||||
|  |     log/REPL output for a given `pdb` interact point. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     __tracebackhide__: bool = False | ||||||
|  | 
 | ||||||
|  |     before: str = str(child.before.decode()) | ||||||
|  |     for part in parts: | ||||||
|  |         if part not in before: | ||||||
|  |             if pause_on_false: | ||||||
|  |                 import pdbp | ||||||
|  |                 pdbp.set_trace() | ||||||
|  | 
 | ||||||
|  |             if print_prompt_on_false: | ||||||
|  |                 print(before) | ||||||
|  | 
 | ||||||
|  |             if err_on_false: | ||||||
|  |                 raise ValueError( | ||||||
|  |                     f'Could not find pattern in `before` output?\n' | ||||||
|  |                     f'part: {part!r}\n' | ||||||
|  |                 ) | ||||||
|  |             return False | ||||||
|  | 
 | ||||||
|  |     return True | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: todo support terminal color-chars stripping so we can match | ||||||
|  | # against call stack frame output from the the 'll' command the like! | ||||||
|  | # -[ ] SO answer for stipping ANSI codes: https://stackoverflow.com/a/14693789 | ||||||
|  | def assert_before( | ||||||
|  |     child: SpawnBase, | ||||||
|  |     patts: list[str], | ||||||
|  | 
 | ||||||
|  |     **kwargs, | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  |     __tracebackhide__: bool = False | ||||||
|  | 
 | ||||||
|  |     assert in_prompt_msg( | ||||||
|  |         child=child, | ||||||
|  |         parts=patts, | ||||||
|  | 
 | ||||||
|  |         # since this is an "assert" helper ;) | ||||||
|  |         err_on_false=True, | ||||||
|  |         **kwargs | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def do_ctlc( | ||||||
|  |     child, | ||||||
|  |     count: int = 3, | ||||||
|  |     delay: float = 0.1, | ||||||
|  |     patt: str|None = None, | ||||||
|  | 
 | ||||||
|  |     # expect repl UX to reprint the prompt after every | ||||||
|  |     # ctrl-c send. | ||||||
|  |     # XXX: no idea but, in CI this never seems to work even on 3.10 so | ||||||
|  |     # needs some further investigation potentially... | ||||||
|  |     expect_prompt: bool = not _ci_env, | ||||||
|  | 
 | ||||||
|  | ) -> str|None: | ||||||
|  | 
 | ||||||
|  |     before: str|None = None | ||||||
|  | 
 | ||||||
|  |     # make sure ctl-c sends don't do anything but repeat output | ||||||
|  |     for _ in range(count): | ||||||
|  |         time.sleep(delay) | ||||||
|  |         child.sendcontrol('c') | ||||||
|  | 
 | ||||||
|  |         # TODO: figure out why this makes CI fail.. | ||||||
|  |         # if you run this test manually it works just fine.. | ||||||
|  |         if expect_prompt: | ||||||
|  |             time.sleep(delay) | ||||||
|  |             child.expect(PROMPT) | ||||||
|  |             before = str(child.before.decode()) | ||||||
|  |             time.sleep(delay) | ||||||
|  | 
 | ||||||
|  |             if patt: | ||||||
|  |                 # should see the last line on console | ||||||
|  |                 assert patt in before | ||||||
|  | 
 | ||||||
|  |     # return the console content up to the final prompt | ||||||
|  |     return before | ||||||
|  | @ -10,24 +10,29 @@ TODO: | ||||||
|     - wonder if any of it'll work on OS X? |     - wonder if any of it'll work on OS X? | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
|  | from functools import partial | ||||||
| import itertools | import itertools | ||||||
| from os import path |  | ||||||
| from typing import Optional |  | ||||||
| import platform | import platform | ||||||
| import pathlib |  | ||||||
| import sys |  | ||||||
| import time | import time | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| import pexpect |  | ||||||
| from pexpect.exceptions import ( | from pexpect.exceptions import ( | ||||||
|     TIMEOUT, |     TIMEOUT, | ||||||
|     EOF, |     EOF, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| from conftest import ( | from .conftest import ( | ||||||
|     examples_dir, |     do_ctlc, | ||||||
|  |     PROMPT, | ||||||
|  |     _pause_msg, | ||||||
|  |     _crash_msg, | ||||||
|  |     _repl_fail_msg, | ||||||
|  | ) | ||||||
|  | from .conftest import ( | ||||||
|     _ci_env, |     _ci_env, | ||||||
|  |     expect, | ||||||
|  |     in_prompt_msg, | ||||||
|  |     assert_before, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| # TODO: The next great debugger audit could be done by you! | # TODO: The next great debugger audit could be done by you! | ||||||
|  | @ -47,15 +52,6 @@ if platform.system() == 'Windows': | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def mk_cmd(ex_name: str) -> str: |  | ||||||
|     ''' |  | ||||||
|     Generate a command suitable to pass to ``pexpect.spawn()``. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     script_path: pathlib.Path = examples_dir() / 'debugging' / f'{ex_name}.py' |  | ||||||
|     return ' '.join(['python', str(script_path)]) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| # TODO: was trying to this xfail style but some weird bug i see in CI | # TODO: was trying to this xfail style but some weird bug i see in CI | ||||||
| # that's happening at collect time.. pretty soon gonna dump actions i'm | # that's happening at collect time.. pretty soon gonna dump actions i'm | ||||||
| # thinkin... | # thinkin... | ||||||
|  | @ -74,104 +70,6 @@ has_nested_actors = pytest.mark.has_nested_actors | ||||||
| # ) | # ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.fixture |  | ||||||
| def spawn( |  | ||||||
|     start_method, |  | ||||||
|     testdir, |  | ||||||
|     arb_addr, |  | ||||||
| ) -> 'pexpect.spawn': |  | ||||||
| 
 |  | ||||||
|     if start_method != 'trio': |  | ||||||
|         pytest.skip( |  | ||||||
|             "Debugger tests are only supported on the trio backend" |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|     def _spawn(cmd): |  | ||||||
|         return testdir.spawn( |  | ||||||
|             cmd=mk_cmd(cmd), |  | ||||||
|             expect_timeout=3, |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|     return _spawn |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| PROMPT = r"\(Pdb\+\)" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def expect( |  | ||||||
|     child, |  | ||||||
| 
 |  | ||||||
|     # prompt by default |  | ||||||
|     patt: str = PROMPT, |  | ||||||
| 
 |  | ||||||
|     **kwargs, |  | ||||||
| 
 |  | ||||||
| ) -> None: |  | ||||||
|     ''' |  | ||||||
|     Expect wrapper that prints last seen console |  | ||||||
|     data before failing. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     try: |  | ||||||
|         child.expect( |  | ||||||
|             patt, |  | ||||||
|             **kwargs, |  | ||||||
|         ) |  | ||||||
|     except TIMEOUT: |  | ||||||
|         before = str(child.before.decode()) |  | ||||||
|         print(before) |  | ||||||
|         raise |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def assert_before( |  | ||||||
|     child, |  | ||||||
|     patts: list[str], |  | ||||||
| 
 |  | ||||||
| ) -> None: |  | ||||||
| 
 |  | ||||||
|     before = str(child.before.decode()) |  | ||||||
| 
 |  | ||||||
|     for patt in patts: |  | ||||||
|         try: |  | ||||||
|             assert patt in before |  | ||||||
|         except AssertionError: |  | ||||||
|             print(before) |  | ||||||
|             raise |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @pytest.fixture( |  | ||||||
|     params=[False, True], |  | ||||||
|     ids='ctl-c={}'.format, |  | ||||||
| ) |  | ||||||
| def ctlc( |  | ||||||
|     request, |  | ||||||
|     ci_env: bool, |  | ||||||
| 
 |  | ||||||
| ) -> bool: |  | ||||||
| 
 |  | ||||||
|     use_ctlc = request.param |  | ||||||
| 
 |  | ||||||
|     node = request.node |  | ||||||
|     markers = node.own_markers |  | ||||||
|     for mark in markers: |  | ||||||
|         if mark.name == 'has_nested_actors': |  | ||||||
|             pytest.skip( |  | ||||||
|                 f'Test {node} has nested actors and fails with Ctrl-C.\n' |  | ||||||
|                 f'The test can sometimes run fine locally but until' |  | ||||||
|                 ' we solve' 'this issue this CI test will be xfail:\n' |  | ||||||
|                 'https://github.com/goodboy/tractor/issues/320' |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|     if use_ctlc: |  | ||||||
|         # XXX: disable pygments highlighting for auto-tests |  | ||||||
|         # since some envs (like actions CI) will struggle |  | ||||||
|         # the the added color-char encoding.. |  | ||||||
|         from tractor._debug import TractorConfig |  | ||||||
|         TractorConfig.use_pygements = False |  | ||||||
| 
 |  | ||||||
|     yield use_ctlc |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'user_in_out', |     'user_in_out', | ||||||
|     [ |     [ | ||||||
|  | @ -180,7 +78,10 @@ def ctlc( | ||||||
|     ], |     ], | ||||||
|     ids=lambda item: f'{item[0]} -> {item[1]}', |     ids=lambda item: f'{item[0]} -> {item[1]}', | ||||||
| ) | ) | ||||||
| def test_root_actor_error(spawn, user_in_out): | def test_root_actor_error( | ||||||
|  |     spawn, | ||||||
|  |     user_in_out, | ||||||
|  | ): | ||||||
|     ''' |     ''' | ||||||
|     Demonstrate crash handler entering pdb from basic error in root actor. |     Demonstrate crash handler entering pdb from basic error in root actor. | ||||||
| 
 | 
 | ||||||
|  | @ -192,11 +93,15 @@ def test_root_actor_error(spawn, user_in_out): | ||||||
|     # scan for the prompt |     # scan for the prompt | ||||||
|     expect(child, PROMPT) |     expect(child, PROMPT) | ||||||
| 
 | 
 | ||||||
|     before = str(child.before.decode()) |  | ||||||
| 
 |  | ||||||
|     # make sure expected logging and error arrives |     # make sure expected logging and error arrives | ||||||
|     assert "Attaching to pdb in crashed actor: ('root'" in before |     assert in_prompt_msg( | ||||||
|     assert 'AssertionError' in before |         child, | ||||||
|  |         [ | ||||||
|  |             _crash_msg, | ||||||
|  |             "('root'", | ||||||
|  |             'AssertionError', | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     # send user command |     # send user command | ||||||
|     child.sendline(user_input) |     child.sendline(user_input) | ||||||
|  | @ -215,8 +120,10 @@ def test_root_actor_error(spawn, user_in_out): | ||||||
|     ids=lambda item: f'{item[0]} -> {item[1]}', |     ids=lambda item: f'{item[0]} -> {item[1]}', | ||||||
| ) | ) | ||||||
| def test_root_actor_bp(spawn, user_in_out): | def test_root_actor_bp(spawn, user_in_out): | ||||||
|     """Demonstrate breakpoint from in root actor. |     ''' | ||||||
|     """ |     Demonstrate breakpoint from in root actor. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|     user_input, expect_err_str = user_in_out |     user_input, expect_err_str = user_in_out | ||||||
|     child = spawn('root_actor_breakpoint') |     child = spawn('root_actor_breakpoint') | ||||||
| 
 | 
 | ||||||
|  | @ -230,7 +137,7 @@ def test_root_actor_bp(spawn, user_in_out): | ||||||
|     child.expect('\r\n') |     child.expect('\r\n') | ||||||
| 
 | 
 | ||||||
|     # process should exit |     # process should exit | ||||||
|     child.expect(pexpect.EOF) |     child.expect(EOF) | ||||||
| 
 | 
 | ||||||
|     if expect_err_str is None: |     if expect_err_str is None: | ||||||
|         assert 'Error' not in str(child.before) |         assert 'Error' not in str(child.before) | ||||||
|  | @ -238,38 +145,6 @@ def test_root_actor_bp(spawn, user_in_out): | ||||||
|         assert expect_err_str in str(child.before) |         assert expect_err_str in str(child.before) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def do_ctlc( |  | ||||||
|     child, |  | ||||||
|     count: int = 3, |  | ||||||
|     delay: float = 0.1, |  | ||||||
|     patt: Optional[str] = None, |  | ||||||
| 
 |  | ||||||
|     # expect repl UX to reprint the prompt after every |  | ||||||
|     # ctrl-c send. |  | ||||||
|     # XXX: no idea but, in CI this never seems to work even on 3.10 so |  | ||||||
|     # needs some further investigation potentially... |  | ||||||
|     expect_prompt: bool = not _ci_env, |  | ||||||
| 
 |  | ||||||
| ) -> None: |  | ||||||
| 
 |  | ||||||
|     # make sure ctl-c sends don't do anything but repeat output |  | ||||||
|     for _ in range(count): |  | ||||||
|         time.sleep(delay) |  | ||||||
|         child.sendcontrol('c') |  | ||||||
| 
 |  | ||||||
|         # TODO: figure out why this makes CI fail.. |  | ||||||
|         # if you run this test manually it works just fine.. |  | ||||||
|         if expect_prompt: |  | ||||||
|             before = str(child.before.decode()) |  | ||||||
|             time.sleep(delay) |  | ||||||
|             child.expect(PROMPT) |  | ||||||
|             time.sleep(delay) |  | ||||||
| 
 |  | ||||||
|             if patt: |  | ||||||
|                 # should see the last line on console |  | ||||||
|                 assert patt in before |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_root_actor_bp_forever( | def test_root_actor_bp_forever( | ||||||
|     spawn, |     spawn, | ||||||
|     ctlc: bool, |     ctlc: bool, | ||||||
|  | @ -309,7 +184,7 @@ def test_root_actor_bp_forever( | ||||||
| 
 | 
 | ||||||
|     # quit out of the loop |     # quit out of the loop | ||||||
|     child.sendline('q') |     child.sendline('q') | ||||||
|     child.expect(pexpect.EOF) |     child.expect(EOF) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|  | @ -331,8 +206,13 @@ def test_subactor_error( | ||||||
|     # scan for the prompt |     # scan for the prompt | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
| 
 | 
 | ||||||
|     before = str(child.before.decode()) |     assert in_prompt_msg( | ||||||
|     assert "Attaching to pdb in crashed actor: ('name_error'" in before |         child, | ||||||
|  |         [ | ||||||
|  |             _crash_msg, | ||||||
|  |             "('name_error'", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     if do_next: |     if do_next: | ||||||
|         child.sendline('n') |         child.sendline('n') | ||||||
|  | @ -350,12 +230,16 @@ def test_subactor_error( | ||||||
|         child.sendline('continue') |         child.sendline('continue') | ||||||
| 
 | 
 | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
|     before = str(child.before.decode()) |     assert in_prompt_msg( | ||||||
| 
 |         child, | ||||||
|  |         [ | ||||||
|  |             _crash_msg, | ||||||
|             # root actor gets debugger engaged |             # root actor gets debugger engaged | ||||||
|     assert "Attaching to pdb in crashed actor: ('root'" in before |             "('root'", | ||||||
|             # error is a remote error propagated from the subactor |             # error is a remote error propagated from the subactor | ||||||
|     assert "RemoteActorError: ('name_error'" in before |             "('name_error'", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     # another round |     # another round | ||||||
|     if ctlc: |     if ctlc: | ||||||
|  | @ -365,7 +249,7 @@ def test_subactor_error( | ||||||
|     child.expect('\r\n') |     child.expect('\r\n') | ||||||
| 
 | 
 | ||||||
|     # process should exit |     # process should exit | ||||||
|     child.expect(pexpect.EOF) |     child.expect(EOF) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_subactor_breakpoint( | def test_subactor_breakpoint( | ||||||
|  | @ -375,12 +259,12 @@ def test_subactor_breakpoint( | ||||||
|     "Single subactor with an infinite breakpoint loop" |     "Single subactor with an infinite breakpoint loop" | ||||||
| 
 | 
 | ||||||
|     child = spawn('subactor_breakpoint') |     child = spawn('subactor_breakpoint') | ||||||
| 
 |  | ||||||
|     # scan for the prompt |  | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
| 
 |     assert in_prompt_msg( | ||||||
|     before = str(child.before.decode()) |         child, | ||||||
|     assert "Attaching pdb to actor: ('breakpoint_forever'" in before |         [_pause_msg, | ||||||
|  |          "('breakpoint_forever'",] | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     # do some "next" commands to demonstrate recurrent breakpoint |     # do some "next" commands to demonstrate recurrent breakpoint | ||||||
|     # entries |     # entries | ||||||
|  | @ -395,8 +279,10 @@ def test_subactor_breakpoint( | ||||||
|     for _ in range(5): |     for _ in range(5): | ||||||
|         child.sendline('continue') |         child.sendline('continue') | ||||||
|         child.expect(PROMPT) |         child.expect(PROMPT) | ||||||
|         before = str(child.before.decode()) |         assert in_prompt_msg( | ||||||
|         assert "Attaching pdb to actor: ('breakpoint_forever'" in before |             child, | ||||||
|  |             [_pause_msg, "('breakpoint_forever'"] | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         if ctlc: |         if ctlc: | ||||||
|             do_ctlc(child) |             do_ctlc(child) | ||||||
|  | @ -407,9 +293,12 @@ def test_subactor_breakpoint( | ||||||
|     # child process should exit but parent will capture pdb.BdbQuit |     # child process should exit but parent will capture pdb.BdbQuit | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
| 
 | 
 | ||||||
|     before = str(child.before.decode()) |     assert in_prompt_msg( | ||||||
|     assert "RemoteActorError: ('breakpoint_forever'" in before |         child, | ||||||
|     assert 'bdb.BdbQuit' in before |         ['RemoteActorError:', | ||||||
|  |          "('breakpoint_forever'", | ||||||
|  |          'bdb.BdbQuit',] | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     if ctlc: |     if ctlc: | ||||||
|         do_ctlc(child) |         do_ctlc(child) | ||||||
|  | @ -418,11 +307,14 @@ def test_subactor_breakpoint( | ||||||
|     child.sendline('c') |     child.sendline('c') | ||||||
| 
 | 
 | ||||||
|     # process should exit |     # process should exit | ||||||
|     child.expect(pexpect.EOF) |     child.expect(EOF) | ||||||
| 
 | 
 | ||||||
|     before = str(child.before.decode()) |     assert in_prompt_msg( | ||||||
|     assert "RemoteActorError: ('breakpoint_forever'" in before |         child, | ||||||
|     assert 'bdb.BdbQuit' in before |         ['RemoteActorError:', | ||||||
|  |          "('breakpoint_forever'", | ||||||
|  |          'bdb.BdbQuit',] | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @has_nested_actors | @has_nested_actors | ||||||
|  | @ -441,7 +333,10 @@ def test_multi_subactors( | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
| 
 | 
 | ||||||
|     before = str(child.before.decode()) |     before = str(child.before.decode()) | ||||||
|     assert "Attaching pdb to actor: ('breakpoint_forever'" in before |     assert in_prompt_msg( | ||||||
|  |         child, | ||||||
|  |         [_pause_msg, "('breakpoint_forever'"] | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     if ctlc: |     if ctlc: | ||||||
|         do_ctlc(child) |         do_ctlc(child) | ||||||
|  | @ -460,9 +355,14 @@ def test_multi_subactors( | ||||||
| 
 | 
 | ||||||
|     # first name_error failure |     # first name_error failure | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
|     before = str(child.before.decode()) |     assert in_prompt_msg( | ||||||
|     assert "Attaching to pdb in crashed actor: ('name_error'" in before |         child, | ||||||
|     assert "NameError" in before |         [ | ||||||
|  |             _crash_msg, | ||||||
|  |             "('name_error'", | ||||||
|  |             "NameError", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     if ctlc: |     if ctlc: | ||||||
|         do_ctlc(child) |         do_ctlc(child) | ||||||
|  | @ -486,8 +386,10 @@ def test_multi_subactors( | ||||||
|     # breakpoint loop should re-engage |     # breakpoint loop should re-engage | ||||||
|     child.sendline('c') |     child.sendline('c') | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
|     before = str(child.before.decode()) |     assert in_prompt_msg( | ||||||
|     assert "Attaching pdb to actor: ('breakpoint_forever'" in before |         child, | ||||||
|  |         [_pause_msg, "('breakpoint_forever'"] | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     if ctlc: |     if ctlc: | ||||||
|         do_ctlc(child) |         do_ctlc(child) | ||||||
|  | @ -527,9 +429,12 @@ def test_multi_subactors( | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
|     before = str(child.before.decode()) |     before = str(child.before.decode()) | ||||||
| 
 | 
 | ||||||
|     assert_before(child, [ |     assert_before( | ||||||
|  |         child, [ | ||||||
|             # debugger attaches to root |             # debugger attaches to root | ||||||
|         "Attaching to pdb in crashed actor: ('root'", |             # "Attaching to pdb in crashed actor: ('root'", | ||||||
|  |             _crash_msg, | ||||||
|  |             "('root'", | ||||||
| 
 | 
 | ||||||
|             # expect a multierror with exceptions for each sub-actor |             # expect a multierror with exceptions for each sub-actor | ||||||
|             "RemoteActorError: ('breakpoint_forever'", |             "RemoteActorError: ('breakpoint_forever'", | ||||||
|  | @ -537,14 +442,15 @@ def test_multi_subactors( | ||||||
|             "RemoteActorError: ('spawn_error'", |             "RemoteActorError: ('spawn_error'", | ||||||
|             "RemoteActorError: ('name_error_1'", |             "RemoteActorError: ('name_error_1'", | ||||||
|             'bdb.BdbQuit', |             'bdb.BdbQuit', | ||||||
|     ]) |         ] | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     if ctlc: |     if ctlc: | ||||||
|         do_ctlc(child) |         do_ctlc(child) | ||||||
| 
 | 
 | ||||||
|     # process should exit |     # process should exit | ||||||
|     child.sendline('c') |     child.sendline('c') | ||||||
|     child.expect(pexpect.EOF) |     child.expect(EOF) | ||||||
| 
 | 
 | ||||||
|     # repeat of previous multierror for final output |     # repeat of previous multierror for final output | ||||||
|     assert_before(child, [ |     assert_before(child, [ | ||||||
|  | @ -574,18 +480,28 @@ def test_multi_daemon_subactors( | ||||||
|     # the root's tty lock first so anticipate either crash |     # the root's tty lock first so anticipate either crash | ||||||
|     # message on the first entry. |     # message on the first entry. | ||||||
| 
 | 
 | ||||||
|     bp_forever_msg = "Attaching pdb to actor: ('bp_forever'" |     bp_forev_parts = [ | ||||||
|     name_error_msg = "NameError: name 'doggypants' is not defined" |         _pause_msg, | ||||||
|  |         "('bp_forever'", | ||||||
|  |     ] | ||||||
|  |     bp_forev_in_msg = partial( | ||||||
|  |         in_prompt_msg, | ||||||
|  |         parts=bp_forev_parts, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     name_error_msg: str = "NameError: name 'doggypants' is not defined" | ||||||
|  |     name_error_parts: list[str] = [name_error_msg] | ||||||
| 
 | 
 | ||||||
|     before = str(child.before.decode()) |     before = str(child.before.decode()) | ||||||
|     if bp_forever_msg in before: | 
 | ||||||
|         next_msg = name_error_msg |     if bp_forev_in_msg(child=child): | ||||||
|  |         next_parts = name_error_parts | ||||||
| 
 | 
 | ||||||
|     elif name_error_msg in before: |     elif name_error_msg in before: | ||||||
|         next_msg = bp_forever_msg |         next_parts = bp_forev_parts | ||||||
| 
 | 
 | ||||||
|     else: |     else: | ||||||
|         raise ValueError("Neither log msg was found !?") |         raise ValueError('Neither log msg was found !?') | ||||||
| 
 | 
 | ||||||
|     if ctlc: |     if ctlc: | ||||||
|         do_ctlc(child) |         do_ctlc(child) | ||||||
|  | @ -599,7 +515,10 @@ def test_multi_daemon_subactors( | ||||||
| 
 | 
 | ||||||
|     child.sendline('c') |     child.sendline('c') | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
|     assert_before(child, [next_msg]) |     assert_before( | ||||||
|  |         child, | ||||||
|  |         next_parts, | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     # XXX: hooray the root clobbering the child here was fixed! |     # XXX: hooray the root clobbering the child here was fixed! | ||||||
|     # IMO, this demonstrates the true power of SC system design. |     # IMO, this demonstrates the true power of SC system design. | ||||||
|  | @ -607,7 +526,7 @@ def test_multi_daemon_subactors( | ||||||
|     # now the root actor won't clobber the bp_forever child |     # now the root actor won't clobber the bp_forever child | ||||||
|     # during it's first access to the debug lock, but will instead |     # during it's first access to the debug lock, but will instead | ||||||
|     # wait for the lock to release, by the edge triggered |     # wait for the lock to release, by the edge triggered | ||||||
|     # ``_debug.Lock.no_remote_has_tty`` event before sending cancel messages |     # ``devx._debug.Lock.no_remote_has_tty`` event before sending cancel messages | ||||||
|     # (via portals) to its underlings B) |     # (via portals) to its underlings B) | ||||||
| 
 | 
 | ||||||
|     # at some point here there should have been some warning msg from |     # at some point here there should have been some warning msg from | ||||||
|  | @ -623,9 +542,15 @@ def test_multi_daemon_subactors( | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
| 
 | 
 | ||||||
|     try: |     try: | ||||||
|         assert_before(child, [bp_forever_msg]) |         assert_before( | ||||||
|  |             child, | ||||||
|  |             bp_forev_parts, | ||||||
|  |         ) | ||||||
|     except AssertionError: |     except AssertionError: | ||||||
|         assert_before(child, [name_error_msg]) |         assert_before( | ||||||
|  |             child, | ||||||
|  |             name_error_parts, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     else: |     else: | ||||||
|         if ctlc: |         if ctlc: | ||||||
|  | @ -637,32 +562,36 @@ def test_multi_daemon_subactors( | ||||||
| 
 | 
 | ||||||
|         child.sendline('c') |         child.sendline('c') | ||||||
|         child.expect(PROMPT) |         child.expect(PROMPT) | ||||||
|         assert_before(child, [name_error_msg]) |         assert_before( | ||||||
|  |             child, | ||||||
|  |             name_error_parts, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     # wait for final error in root |     # wait for final error in root | ||||||
|     # where it crashs with boxed error |     # where it crashs with boxed error | ||||||
|     while True: |     while True: | ||||||
|         try: |  | ||||||
|         child.sendline('c') |         child.sendline('c') | ||||||
|         child.expect(PROMPT) |         child.expect(PROMPT) | ||||||
|             assert_before( |         if not in_prompt_msg( | ||||||
|             child, |             child, | ||||||
|                 [bp_forever_msg] |             bp_forev_parts | ||||||
|             ) |         ): | ||||||
|         except AssertionError: |  | ||||||
|             break |             break | ||||||
| 
 | 
 | ||||||
|     assert_before( |     assert_before( | ||||||
|         child, |         child, | ||||||
|         [ |         [ | ||||||
|             # boxed error raised in root task |             # boxed error raised in root task | ||||||
|             "Attaching to pdb in crashed actor: ('root'", |             # "Attaching to pdb in crashed actor: ('root'", | ||||||
|             "_exceptions.RemoteActorError: ('name_error'", |             _crash_msg, | ||||||
|  |             "('root'",  # should attach in root | ||||||
|  |             "_exceptions.RemoteActorError:",  # with an embedded RAE for.. | ||||||
|  |             "('name_error'",  # the src subactor which raised | ||||||
|         ] |         ] | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     child.sendline('c') |     child.sendline('c') | ||||||
|     child.expect(pexpect.EOF) |     child.expect(EOF) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @has_nested_actors | @has_nested_actors | ||||||
|  | @ -738,7 +667,7 @@ def test_multi_subactors_root_errors( | ||||||
|     ]) |     ]) | ||||||
| 
 | 
 | ||||||
|     child.sendline('c') |     child.sendline('c') | ||||||
|     child.expect(pexpect.EOF) |     child.expect(EOF) | ||||||
| 
 | 
 | ||||||
|     assert_before(child, [ |     assert_before(child, [ | ||||||
|         # "Attaching to pdb in crashed actor: ('root'", |         # "Attaching to pdb in crashed actor: ('root'", | ||||||
|  | @ -758,10 +687,11 @@ def test_multi_nested_subactors_error_through_nurseries( | ||||||
|     # https://github.com/goodboy/tractor/issues/320 |     # https://github.com/goodboy/tractor/issues/320 | ||||||
|     # ctlc: bool, |     # ctlc: bool, | ||||||
| ): | ): | ||||||
|     """Verify deeply nested actors that error trigger debugger entries |     ''' | ||||||
|  |     Verify deeply nested actors that error trigger debugger entries | ||||||
|     at each actor nurserly (level) all the way up the tree. |     at each actor nurserly (level) all the way up the tree. | ||||||
| 
 | 
 | ||||||
|     """ |     ''' | ||||||
|     # NOTE: previously, inside this script was a bug where if the |     # NOTE: previously, inside this script was a bug where if the | ||||||
|     # parent errors before a 2-levels-lower actor has released the lock, |     # parent errors before a 2-levels-lower actor has released the lock, | ||||||
|     # the parent tries to cancel it but it's stuck in the debugger? |     # the parent tries to cancel it but it's stuck in the debugger? | ||||||
|  | @ -770,7 +700,7 @@ def test_multi_nested_subactors_error_through_nurseries( | ||||||
| 
 | 
 | ||||||
|     child = spawn('multi_nested_subactors_error_up_through_nurseries') |     child = spawn('multi_nested_subactors_error_up_through_nurseries') | ||||||
| 
 | 
 | ||||||
|     timed_out_early: bool = False |     # timed_out_early: bool = False | ||||||
| 
 | 
 | ||||||
|     for send_char in itertools.cycle(['c', 'q']): |     for send_char in itertools.cycle(['c', 'q']): | ||||||
|         try: |         try: | ||||||
|  | @ -781,22 +711,31 @@ def test_multi_nested_subactors_error_through_nurseries( | ||||||
|         except EOF: |         except EOF: | ||||||
|             break |             break | ||||||
| 
 | 
 | ||||||
|     assert_before(child, [ |     assert_before( | ||||||
| 
 |         child, | ||||||
|         # boxed source errors |         [ # boxed source errors | ||||||
|             "NameError: name 'doggypants' is not defined", |             "NameError: name 'doggypants' is not defined", | ||||||
|         "tractor._exceptions.RemoteActorError: ('name_error'", |             "tractor._exceptions.RemoteActorError:", | ||||||
|  |             "('name_error'", | ||||||
|             "bdb.BdbQuit", |             "bdb.BdbQuit", | ||||||
| 
 | 
 | ||||||
|             # first level subtrees |             # first level subtrees | ||||||
|         "tractor._exceptions.RemoteActorError: ('spawner0'", |             # "tractor._exceptions.RemoteActorError: ('spawner0'", | ||||||
|  |             "src_uid=('spawner0'", | ||||||
|  | 
 | ||||||
|             # "tractor._exceptions.RemoteActorError: ('spawner1'", |             # "tractor._exceptions.RemoteActorError: ('spawner1'", | ||||||
| 
 | 
 | ||||||
|             # propagation of errors up through nested subtrees |             # propagation of errors up through nested subtrees | ||||||
|         "tractor._exceptions.RemoteActorError: ('spawn_until_0'", |             # "tractor._exceptions.RemoteActorError: ('spawn_until_0'", | ||||||
|         "tractor._exceptions.RemoteActorError: ('spawn_until_1'", |             # "tractor._exceptions.RemoteActorError: ('spawn_until_1'", | ||||||
|         "tractor._exceptions.RemoteActorError: ('spawn_until_2'", |             # "tractor._exceptions.RemoteActorError: ('spawn_until_2'", | ||||||
|     ]) |             # ^-NOTE-^ old RAE repr, new one is below with a field | ||||||
|  |             # showing the src actor's uid. | ||||||
|  |             "src_uid=('spawn_until_0'", | ||||||
|  |             "relay_uid=('spawn_until_1'", | ||||||
|  |             "src_uid=('spawn_until_2'", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.timeout(15) | @pytest.mark.timeout(15) | ||||||
|  | @ -817,10 +756,13 @@ def test_root_nursery_cancels_before_child_releases_tty_lock( | ||||||
|     child = spawn('root_cancelled_but_child_is_in_tty_lock') |     child = spawn('root_cancelled_but_child_is_in_tty_lock') | ||||||
| 
 | 
 | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
| 
 |     assert_before( | ||||||
|     before = str(child.before.decode()) |         child, | ||||||
|     assert "NameError: name 'doggypants' is not defined" in before |         [ | ||||||
|     assert "tractor._exceptions.RemoteActorError: ('name_error'" not in before |             "NameError: name 'doggypants' is not defined", | ||||||
|  |             "tractor._exceptions.RemoteActorError: ('name_error'", | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|     time.sleep(0.5) |     time.sleep(0.5) | ||||||
| 
 | 
 | ||||||
|     if ctlc: |     if ctlc: | ||||||
|  | @ -858,7 +800,7 @@ def test_root_nursery_cancels_before_child_releases_tty_lock( | ||||||
| 
 | 
 | ||||||
|     for i in range(3): |     for i in range(3): | ||||||
|         try: |         try: | ||||||
|             child.expect(pexpect.EOF, timeout=0.5) |             child.expect(EOF, timeout=0.5) | ||||||
|             break |             break | ||||||
|         except TIMEOUT: |         except TIMEOUT: | ||||||
|             child.sendline('c') |             child.sendline('c') | ||||||
|  | @ -871,11 +813,14 @@ def test_root_nursery_cancels_before_child_releases_tty_lock( | ||||||
| 
 | 
 | ||||||
|     if not timed_out_early: |     if not timed_out_early: | ||||||
|         before = str(child.before.decode()) |         before = str(child.before.decode()) | ||||||
|         assert_before(child, [ |         assert_before( | ||||||
|  |             child, | ||||||
|  |             [ | ||||||
|                 "tractor._exceptions.RemoteActorError: ('spawner0'", |                 "tractor._exceptions.RemoteActorError: ('spawner0'", | ||||||
|                 "tractor._exceptions.RemoteActorError: ('name_error'", |                 "tractor._exceptions.RemoteActorError: ('name_error'", | ||||||
|                 "NameError: name 'doggypants' is not defined", |                 "NameError: name 'doggypants' is not defined", | ||||||
|         ]) |             ], | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_root_cancels_child_context_during_startup( | def test_root_cancels_child_context_during_startup( | ||||||
|  | @ -897,7 +842,7 @@ def test_root_cancels_child_context_during_startup( | ||||||
|         do_ctlc(child) |         do_ctlc(child) | ||||||
| 
 | 
 | ||||||
|     child.sendline('c') |     child.sendline('c') | ||||||
|     child.expect(pexpect.EOF) |     child.expect(EOF) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_different_debug_mode_per_actor( | def test_different_debug_mode_per_actor( | ||||||
|  | @ -908,26 +853,249 @@ def test_different_debug_mode_per_actor( | ||||||
|     child.expect(PROMPT) |     child.expect(PROMPT) | ||||||
| 
 | 
 | ||||||
|     # only one actor should enter the debugger |     # only one actor should enter the debugger | ||||||
|     before = str(child.before.decode()) |     assert in_prompt_msg( | ||||||
|     assert "Attaching to pdb in crashed actor: ('debugged_boi'" in before |         child, | ||||||
|     assert "RuntimeError" in before |         [_crash_msg, "('debugged_boi'", "RuntimeError"], | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     if ctlc: |     if ctlc: | ||||||
|         do_ctlc(child) |         do_ctlc(child) | ||||||
| 
 | 
 | ||||||
|     child.sendline('c') |     child.sendline('c') | ||||||
|     child.expect(pexpect.EOF) |     child.expect(EOF) | ||||||
| 
 |  | ||||||
|     before = str(child.before.decode()) |  | ||||||
| 
 | 
 | ||||||
|     # NOTE: this debugged actor error currently WON'T show up since the |     # NOTE: this debugged actor error currently WON'T show up since the | ||||||
|     # root will actually cancel and terminate the nursery before the error |     # root will actually cancel and terminate the nursery before the error | ||||||
|     # msg reported back from the debug mode actor is processed. |     # msg reported back from the debug mode actor is processed. | ||||||
|     # assert "tractor._exceptions.RemoteActorError: ('debugged_boi'" in before |     # assert "tractor._exceptions.RemoteActorError: ('debugged_boi'" in before | ||||||
| 
 | 
 | ||||||
|     assert "tractor._exceptions.RemoteActorError: ('crash_boi'" in before |  | ||||||
| 
 |  | ||||||
|     # the crash boi should not have made a debugger request but |     # the crash boi should not have made a debugger request but | ||||||
|     # instead crashed completely |     # instead crashed completely | ||||||
|     assert "tractor._exceptions.RemoteActorError: ('crash_boi'" in before |     assert_before( | ||||||
|     assert "RuntimeError" in before |         child, | ||||||
|  |         [ | ||||||
|  |             "tractor._exceptions.RemoteActorError:", | ||||||
|  |             "src_uid=('crash_boi'", | ||||||
|  |             "RuntimeError", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_post_mortem_api( | ||||||
|  |     spawn, | ||||||
|  |     ctlc: bool, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify the `tractor.post_mortem()` API works in an exception | ||||||
|  |     handler block. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     child = spawn('pm_in_subactor') | ||||||
|  | 
 | ||||||
|  |     # First entry is via manual `.post_mortem()` | ||||||
|  |     child.expect(PROMPT) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             _crash_msg, | ||||||
|  |             "<Task 'name_error'", | ||||||
|  |             "NameError", | ||||||
|  |             "('child'", | ||||||
|  |             "tractor.post_mortem()", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     if ctlc: | ||||||
|  |         do_ctlc(child) | ||||||
|  |     child.sendline('c') | ||||||
|  | 
 | ||||||
|  |     # 2nd is RPC crash handler | ||||||
|  |     child.expect(PROMPT) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             _crash_msg, | ||||||
|  |             "<Task 'name_error'", | ||||||
|  |             "NameError", | ||||||
|  |             "('child'", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     if ctlc: | ||||||
|  |         do_ctlc(child) | ||||||
|  |     child.sendline('c') | ||||||
|  | 
 | ||||||
|  |     # 3rd is via RAE bubbled to root's parent ctx task and | ||||||
|  |     # crash-handled via another manual pm call. | ||||||
|  |     child.expect(PROMPT) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             _crash_msg, | ||||||
|  |             "<Task '__main__.main'", | ||||||
|  |             "('root'", | ||||||
|  |             "NameError", | ||||||
|  |             "tractor.post_mortem()", | ||||||
|  |             "src_uid=('child'", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     if ctlc: | ||||||
|  |         do_ctlc(child) | ||||||
|  |     child.sendline('c') | ||||||
|  | 
 | ||||||
|  |     # 4th and FINAL is via RAE bubbled to root's parent ctx task and | ||||||
|  |     # crash-handled via another manual pm call. | ||||||
|  |     child.expect(PROMPT) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             _crash_msg, | ||||||
|  |             "<Task '__main__.main'", | ||||||
|  |             "('root'", | ||||||
|  |             "NameError", | ||||||
|  |             "src_uid=('child'", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     if ctlc: | ||||||
|  |         do_ctlc(child) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     # TODO: ensure we're stopped and showing the right call stack frame | ||||||
|  |     # -[ ] need a way to strip the terminal color chars in order to | ||||||
|  |     #    pattern match... see TODO around `assert_before()` above! | ||||||
|  |     # child.sendline('w') | ||||||
|  |     # child.expect(PROMPT) | ||||||
|  |     # assert_before( | ||||||
|  |     #     child, | ||||||
|  |     #     [ | ||||||
|  |     #         # error src block annot at ctx open | ||||||
|  |     #         '-> async with p.open_context(name_error) as (ctx, first):', | ||||||
|  |     #     ] | ||||||
|  |     # ) | ||||||
|  | 
 | ||||||
|  |     # # step up a frame to ensure the it's the root's nursery | ||||||
|  |     # child.sendline('u') | ||||||
|  |     # child.expect(PROMPT) | ||||||
|  |     # assert_before( | ||||||
|  |     #     child, | ||||||
|  |     #     [ | ||||||
|  |     #         # handler block annotation | ||||||
|  |     #         '-> async with tractor.open_nursery(', | ||||||
|  |     #     ] | ||||||
|  |     # ) | ||||||
|  | 
 | ||||||
|  |     child.sendline('c') | ||||||
|  |     child.expect(EOF) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_shield_pause( | ||||||
|  |     spawn, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify the `tractor.pause()/.post_mortem()` API works inside an | ||||||
|  |     already cancelled `trio.CancelScope` and that you can step to the | ||||||
|  |     next checkpoint wherein the cancelled will get raised. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     child = spawn('shielded_pause') | ||||||
|  | 
 | ||||||
|  |     # First entry is via manual `.post_mortem()` | ||||||
|  |     child.expect(PROMPT) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             _pause_msg, | ||||||
|  |             "cancellable_pause_loop'", | ||||||
|  |             "('cancelled_before_pause'",  # actor name | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # since 3 tries in ex. shield pause loop | ||||||
|  |     for i in range(3): | ||||||
|  |         child.sendline('c') | ||||||
|  |         child.expect(PROMPT) | ||||||
|  |         assert_before( | ||||||
|  |             child, | ||||||
|  |             [ | ||||||
|  |                 _pause_msg, | ||||||
|  |                 "INSIDE SHIELDED PAUSE", | ||||||
|  |                 "('cancelled_before_pause'",  # actor name | ||||||
|  |             ] | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # back inside parent task that opened nursery | ||||||
|  |     child.sendline('c') | ||||||
|  |     child.expect(PROMPT) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             _crash_msg, | ||||||
|  |             "('cancelled_before_pause'",  # actor name | ||||||
|  |             _repl_fail_msg, | ||||||
|  |             "trio.Cancelled", | ||||||
|  |             "raise Cancelled._create()", | ||||||
|  | 
 | ||||||
|  |             # we should be handling a taskc inside | ||||||
|  |             # the first `.port_mortem()` sin-shield! | ||||||
|  |             'await DebugStatus.req_finished.wait()', | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # same as above but in the root actor's task | ||||||
|  |     child.sendline('c') | ||||||
|  |     child.expect(PROMPT) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             _crash_msg, | ||||||
|  |             "('root'",  # actor name | ||||||
|  |             _repl_fail_msg, | ||||||
|  |             "trio.Cancelled", | ||||||
|  |             "raise Cancelled._create()", | ||||||
|  | 
 | ||||||
|  |             # handling a taskc inside the first unshielded | ||||||
|  |             # `.port_mortem()`. | ||||||
|  |             # BUT in this case in the root-proc path ;) | ||||||
|  |             'wait Lock._debug_lock.acquire()', | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     child.sendline('c') | ||||||
|  |     child.expect(EOF) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: better error for "non-ideal" usage from the root actor. | ||||||
|  | # -[ ] if called from an async scope emit a message that suggests | ||||||
|  | #    using `await tractor.pause()` instead since it's less overhead | ||||||
|  | #    (in terms of `greenback` and/or extra threads) and if it's from | ||||||
|  | #    a sync scope suggest that usage must first call | ||||||
|  | #    `ensure_portal()` in the (eventual parent) async calling scope? | ||||||
|  | def test_sync_pause_from_bg_task_in_root_actor_(): | ||||||
|  |     ''' | ||||||
|  |     When used from the root actor, normally we can only implicitly | ||||||
|  |     support `.pause_from_sync()` from the main-parent-task (that | ||||||
|  |     opens the runtime via `open_root_actor()`) since `greenback` | ||||||
|  |     requires a `.ensure_portal()` call per `trio.Task` where it is | ||||||
|  |     used. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     ... | ||||||
|  | 
 | ||||||
|  | # TODO: needs ANSI code stripping tho, see `assert_before()` # above! | ||||||
|  | def test_correct_frames_below_hidden(): | ||||||
|  |     ''' | ||||||
|  |     Ensure that once a `tractor.pause()` enages, when the user | ||||||
|  |     inputs a "next"/"n" command the actual next line steps | ||||||
|  |     and that using a "step"/"s" into the next LOC, particuarly | ||||||
|  |     `tractor` APIs, you can step down into that code. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     ... | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_cant_pause_from_paused_task(): | ||||||
|  |     ''' | ||||||
|  |     Pausing from with an already paused task should raise an error. | ||||||
|  | 
 | ||||||
|  |     Normally this should only happen in practise while debugging the call stack of `tractor.pause()` itself, likely | ||||||
|  |     by a `.pause()` line somewhere inside our runtime. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     ... | ||||||
|  | @ -0,0 +1,350 @@ | ||||||
|  | ''' | ||||||
|  | That "foreign loop/thread" debug REPL support better ALSO WORK! | ||||||
|  | 
 | ||||||
|  | Same as `test_native_pause.py`. | ||||||
|  | All these tests can be understood (somewhat) by running the | ||||||
|  | equivalent `examples/debugging/` scripts manually. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | # from functools import partial | ||||||
|  | # import itertools | ||||||
|  | import time | ||||||
|  | # from typing import ( | ||||||
|  | #     Iterator, | ||||||
|  | # ) | ||||||
|  | 
 | ||||||
|  | import pytest | ||||||
|  | from pexpect.exceptions import ( | ||||||
|  |     # TIMEOUT, | ||||||
|  |     EOF, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from .conftest import ( | ||||||
|  |     # _ci_env, | ||||||
|  |     do_ctlc, | ||||||
|  |     PROMPT, | ||||||
|  |     # expect, | ||||||
|  |     in_prompt_msg, | ||||||
|  |     assert_before, | ||||||
|  |     _pause_msg, | ||||||
|  |     _crash_msg, | ||||||
|  |     _ctlc_ignore_header, | ||||||
|  |     # _repl_fail_msg, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_pause_from_sync( | ||||||
|  |     spawn, | ||||||
|  |     ctlc: bool, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify we can use the `pdbp` REPL from sync functions AND from | ||||||
|  |     any thread spawned with `trio.to_thread.run_sync()`. | ||||||
|  | 
 | ||||||
|  |     `examples/debugging/sync_bp.py` | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     child = spawn('sync_bp') | ||||||
|  | 
 | ||||||
|  |     # first `sync_pause()` after nurseries open | ||||||
|  |     child.expect(PROMPT) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             # pre-prompt line | ||||||
|  |             _pause_msg, | ||||||
|  |             "<Task '__main__.main'", | ||||||
|  |             "('root'", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     if ctlc: | ||||||
|  |         do_ctlc(child) | ||||||
|  |         # ^NOTE^ subactor not spawned yet; don't need extra delay. | ||||||
|  | 
 | ||||||
|  |     child.sendline('c') | ||||||
|  | 
 | ||||||
|  |     # first `await tractor.pause()` inside `p.open_context()` body | ||||||
|  |     child.expect(PROMPT) | ||||||
|  | 
 | ||||||
|  |     # XXX shouldn't see gb loaded message with PDB loglevel! | ||||||
|  |     assert not in_prompt_msg( | ||||||
|  |         child, | ||||||
|  |         ['`greenback` portal opened!'], | ||||||
|  |     ) | ||||||
|  |     # should be same root task | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             _pause_msg, | ||||||
|  |             "<Task '__main__.main'", | ||||||
|  |             "('root'", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     if ctlc: | ||||||
|  |         do_ctlc( | ||||||
|  |             child, | ||||||
|  |             # NOTE: setting this to 0 (or some other sufficient | ||||||
|  |             # small val) can cause the test to fail since the | ||||||
|  |             # `subactor` suffers a race where the root/parent | ||||||
|  |             # sends an actor-cancel prior to it hitting its pause | ||||||
|  |             # point; by def the value is 0.1 | ||||||
|  |             delay=0.4, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # XXX, fwiw without a brief sleep here the SIGINT might actually | ||||||
|  |     # trigger "subactor" cancellation by its parent  before the | ||||||
|  |     # shield-handler is engaged. | ||||||
|  |     # | ||||||
|  |     # => similar to the `delay` input to `do_ctlc()` below, setting | ||||||
|  |     # this too low can cause the test to fail since the `subactor` | ||||||
|  |     # suffers a race where the root/parent sends an actor-cancel | ||||||
|  |     # prior to the context task hitting its pause point (and thus | ||||||
|  |     # engaging the `sigint_shield()` handler in time); this value | ||||||
|  |     # seems be good enuf? | ||||||
|  |     time.sleep(0.6) | ||||||
|  | 
 | ||||||
|  |     # one of the bg thread or subactor should have | ||||||
|  |     # `Lock.acquire()`-ed | ||||||
|  |     # (NOT both, which will result in REPL clobbering!) | ||||||
|  |     attach_patts: dict[str, list[str]] = { | ||||||
|  |         'subactor': [ | ||||||
|  |             "'start_n_sync_pause'", | ||||||
|  |             "('subactor'", | ||||||
|  |         ], | ||||||
|  |         'inline_root_bg_thread': [ | ||||||
|  |             "<Thread(inline_root_bg_thread", | ||||||
|  |             "('root'", | ||||||
|  |         ], | ||||||
|  |         'start_soon_root_bg_thread': [ | ||||||
|  |             "<Thread(start_soon_root_bg_thread", | ||||||
|  |             "('root'", | ||||||
|  |         ], | ||||||
|  |     } | ||||||
|  |     conts: int = 0  # for debugging below matching logic on failure | ||||||
|  |     while attach_patts: | ||||||
|  |         child.sendline('c') | ||||||
|  |         conts += 1 | ||||||
|  |         child.expect(PROMPT) | ||||||
|  |         before = str(child.before.decode()) | ||||||
|  |         for key in attach_patts: | ||||||
|  |             if key in before: | ||||||
|  |                 attach_key: str = key | ||||||
|  |                 expected_patts: str = attach_patts.pop(key) | ||||||
|  |                 assert_before( | ||||||
|  |                     child, | ||||||
|  |                     [_pause_msg] | ||||||
|  |                     + | ||||||
|  |                     expected_patts | ||||||
|  |                 ) | ||||||
|  |                 break | ||||||
|  |         else: | ||||||
|  |             pytest.fail( | ||||||
|  |                 f'No keys found?\n\n' | ||||||
|  |                 f'{attach_patts.keys()}\n\n' | ||||||
|  |                 f'{before}\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         # ensure no other task/threads engaged a REPL | ||||||
|  |         # at the same time as the one that was detected above. | ||||||
|  |         for key, other_patts in attach_patts.copy().items(): | ||||||
|  |             assert not in_prompt_msg( | ||||||
|  |                 child, | ||||||
|  |                 other_patts, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         if ctlc: | ||||||
|  |             do_ctlc( | ||||||
|  |                 child, | ||||||
|  |                 patt=attach_key, | ||||||
|  |                 # NOTE same as comment above | ||||||
|  |                 delay=0.4, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     child.sendline('c') | ||||||
|  |     child.expect(EOF) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def expect_any_of( | ||||||
|  |     attach_patts: dict[str, list[str]], | ||||||
|  |     child,   # what type? | ||||||
|  |     ctlc: bool = False, | ||||||
|  |     prompt: str = _ctlc_ignore_header, | ||||||
|  |     ctlc_delay: float = .4, | ||||||
|  | 
 | ||||||
|  | ) -> list[str]: | ||||||
|  |     ''' | ||||||
|  |     Receive any of a `list[str]` of patterns provided in | ||||||
|  |     `attach_patts`. | ||||||
|  | 
 | ||||||
|  |     Used to test racing prompts from multiple actors and/or | ||||||
|  |     tasks using a common root process' `pdbp` REPL. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     assert attach_patts | ||||||
|  | 
 | ||||||
|  |     child.expect(PROMPT) | ||||||
|  |     before = str(child.before.decode()) | ||||||
|  | 
 | ||||||
|  |     for attach_key in attach_patts: | ||||||
|  |         if attach_key in before: | ||||||
|  |             expected_patts: str = attach_patts.pop(attach_key) | ||||||
|  |             assert_before( | ||||||
|  |                 child, | ||||||
|  |                 expected_patts | ||||||
|  |             ) | ||||||
|  |             break  # from for | ||||||
|  |     else: | ||||||
|  |         pytest.fail( | ||||||
|  |             f'No keys found?\n\n' | ||||||
|  |             f'{attach_patts.keys()}\n\n' | ||||||
|  |             f'{before}\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # ensure no other task/threads engaged a REPL | ||||||
|  |     # at the same time as the one that was detected above. | ||||||
|  |     for key, other_patts in attach_patts.copy().items(): | ||||||
|  |         assert not in_prompt_msg( | ||||||
|  |             child, | ||||||
|  |             other_patts, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     if ctlc: | ||||||
|  |         do_ctlc( | ||||||
|  |             child, | ||||||
|  |             patt=prompt, | ||||||
|  |             # NOTE same as comment above | ||||||
|  |             delay=ctlc_delay, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     return expected_patts | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_sync_pause_from_aio_task( | ||||||
|  |     spawn, | ||||||
|  |     ctlc: bool | ||||||
|  |     # ^TODO, fix for `asyncio`!! | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify we can use the `pdbp` REPL from an `asyncio.Task` spawned using | ||||||
|  |     APIs in `.to_asyncio`. | ||||||
|  | 
 | ||||||
|  |     `examples/debugging/asycio_bp.py` | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     child = spawn('asyncio_bp') | ||||||
|  | 
 | ||||||
|  |     # RACE on whether trio/asyncio task bps first | ||||||
|  |     attach_patts: dict[str, list[str]] = { | ||||||
|  | 
 | ||||||
|  |         # first pause in guest-mode (aka "infecting") | ||||||
|  |         # `trio.Task`. | ||||||
|  |         'trio-side': [ | ||||||
|  |             _pause_msg, | ||||||
|  |             "<Task 'trio_ctx'", | ||||||
|  |             "('aio_daemon'", | ||||||
|  |         ], | ||||||
|  | 
 | ||||||
|  |         # `breakpoint()` from `asyncio.Task`. | ||||||
|  |         'asyncio-side': [ | ||||||
|  |             _pause_msg, | ||||||
|  |             "<Task pending name='Task-2' coro=<greenback_shim()", | ||||||
|  |             "('aio_daemon'", | ||||||
|  |         ], | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     while attach_patts: | ||||||
|  |         expect_any_of( | ||||||
|  |             attach_patts=attach_patts, | ||||||
|  |             child=child, | ||||||
|  |             ctlc=ctlc, | ||||||
|  |         ) | ||||||
|  |         child.sendline('c') | ||||||
|  | 
 | ||||||
|  |     # NOW in race order, | ||||||
|  |     # - the asyncio-task will error | ||||||
|  |     # - the root-actor parent task will pause | ||||||
|  |     # | ||||||
|  |     attach_patts: dict[str, list[str]] = { | ||||||
|  | 
 | ||||||
|  |         # error raised in `asyncio.Task` | ||||||
|  |         "raise ValueError('asyncio side error!')": [ | ||||||
|  |             _crash_msg, | ||||||
|  |             'return await chan.receive()',  # `.to_asyncio` impl internals in tb | ||||||
|  |             "<Task 'trio_ctx'", | ||||||
|  |             "@ ('aio_daemon'", | ||||||
|  |             "ValueError: asyncio side error!", | ||||||
|  |         ], | ||||||
|  | 
 | ||||||
|  |         # parent-side propagation via actor-nursery/portal | ||||||
|  |         # "tractor._exceptions.RemoteActorError: remote task raised a 'ValueError'": [ | ||||||
|  |         "remote task raised a 'ValueError'": [ | ||||||
|  |             _crash_msg, | ||||||
|  |             "src_uid=('aio_daemon'", | ||||||
|  |             "('aio_daemon'", | ||||||
|  |         ], | ||||||
|  | 
 | ||||||
|  |         # a final pause in root-actor | ||||||
|  |         "<Task '__main__.main'": [ | ||||||
|  |             _pause_msg, | ||||||
|  |             "<Task '__main__.main'", | ||||||
|  |             "('root'", | ||||||
|  |         ], | ||||||
|  |     } | ||||||
|  |     while attach_patts: | ||||||
|  |         expect_any_of( | ||||||
|  |             attach_patts=attach_patts, | ||||||
|  |             child=child, | ||||||
|  |             ctlc=ctlc, | ||||||
|  |         ) | ||||||
|  |         child.sendline('c') | ||||||
|  | 
 | ||||||
|  |     assert not attach_patts | ||||||
|  | 
 | ||||||
|  |     # final boxed error propagates to root | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             _crash_msg, | ||||||
|  |             "<Task '__main__.main'", | ||||||
|  |             "('root'", | ||||||
|  |             "remote task raised a 'ValueError'", | ||||||
|  |             "ValueError: asyncio side error!", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     if ctlc: | ||||||
|  |         do_ctlc( | ||||||
|  |             child, | ||||||
|  |             # NOTE: setting this to 0 (or some other sufficient | ||||||
|  |             # small val) can cause the test to fail since the | ||||||
|  |             # `subactor` suffers a race where the root/parent | ||||||
|  |             # sends an actor-cancel prior to it hitting its pause | ||||||
|  |             # point; by def the value is 0.1 | ||||||
|  |             delay=0.4, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     child.sendline('c') | ||||||
|  |     child.expect(EOF) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_sync_pause_from_non_greenbacked_aio_task(): | ||||||
|  |     ''' | ||||||
|  |     Where the `breakpoint()` caller task is NOT spawned by | ||||||
|  |     `tractor.to_asyncio` and thus never activates | ||||||
|  |     a `greenback.ensure_portal()` beforehand, presumably bc the task | ||||||
|  |     was started by some lib/dep as in often seen in the field. | ||||||
|  | 
 | ||||||
|  |     Ensure sync pausing works when the pause is in, | ||||||
|  | 
 | ||||||
|  |     - the root actor running in infected-mode? | ||||||
|  |       |_ since we don't need any IPC to acquire the debug lock? | ||||||
|  |       |_ is there some way to handle this like the non-main-thread case? | ||||||
|  | 
 | ||||||
|  |     All other cases need to error out appropriately right? | ||||||
|  | 
 | ||||||
|  |     - for any subactor we can't avoid needing the repl lock.. | ||||||
|  |       |_ is there a way to hook into `asyncio.ensure_future(obj)`? | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     pass | ||||||
|  | @ -0,0 +1,120 @@ | ||||||
|  | ''' | ||||||
|  | That "native" runtime-hackin toolset better be dang useful! | ||||||
|  | 
 | ||||||
|  | Verify the funtion of a variety of "developer-experience" tools we | ||||||
|  | offer from the `.devx` sub-pkg: | ||||||
|  | 
 | ||||||
|  | - use of the lovely `stackscope` for dumping actor `trio`-task trees | ||||||
|  |   during operation and hangs. | ||||||
|  | 
 | ||||||
|  | TODO: | ||||||
|  | - demonstration of `CallerInfo` call stack frame filtering such that | ||||||
|  |   for logging and REPL purposes a user sees exactly the layers needed | ||||||
|  |   when debugging a problem inside the stack vs. in their app. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | import os | ||||||
|  | import signal | ||||||
|  | 
 | ||||||
|  | from .conftest import ( | ||||||
|  |     expect, | ||||||
|  |     assert_before, | ||||||
|  |     # in_prompt_msg, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_shield_pause( | ||||||
|  |     spawn, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify the `tractor.pause()/.post_mortem()` API works inside an | ||||||
|  |     already cancelled `trio.CancelScope` and that you can step to the | ||||||
|  |     next checkpoint wherein the cancelled will get raised. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     child = spawn( | ||||||
|  |         'shield_hang_in_sub' | ||||||
|  |     ) | ||||||
|  |     expect( | ||||||
|  |         child, | ||||||
|  |         'Yo my child hanging..?', | ||||||
|  |     ) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             'Entering shield sleep..', | ||||||
|  |             'Enabling trace-trees on `SIGUSR1` since `stackscope` is installed @', | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     print( | ||||||
|  |         'Sending SIGUSR1 to see a tree-trace!', | ||||||
|  |     ) | ||||||
|  |     os.kill( | ||||||
|  |         child.pid, | ||||||
|  |         signal.SIGUSR1, | ||||||
|  |     ) | ||||||
|  |     expect( | ||||||
|  |         child, | ||||||
|  |         # end-of-tree delimiter | ||||||
|  |         "------ \('root', ", | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             'Trying to dump `stackscope` tree..', | ||||||
|  |             'Dumping `stackscope` tree for actor', | ||||||
|  |             "('root'",  # uid line | ||||||
|  | 
 | ||||||
|  |             # parent block point (non-shielded) | ||||||
|  |             'await trio.sleep_forever()  # in root', | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # expect( | ||||||
|  |     #     child, | ||||||
|  |     #     # relay to the sub should be reported | ||||||
|  |     #     'Relaying `SIGUSR1`[10] to sub-actor', | ||||||
|  |     # ) | ||||||
|  | 
 | ||||||
|  |     expect( | ||||||
|  |         child, | ||||||
|  |         # end-of-tree delimiter | ||||||
|  |         "------ \('hanger', ", | ||||||
|  |     ) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             # relay to the sub should be reported | ||||||
|  |             'Relaying `SIGUSR1`[10] to sub-actor', | ||||||
|  | 
 | ||||||
|  |             "('hanger'",  # uid line | ||||||
|  | 
 | ||||||
|  |             # hanger LOC where it's shield-halted | ||||||
|  |             'await trio.sleep_forever()  # in subactor', | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     # breakpoint() | ||||||
|  | 
 | ||||||
|  |     # simulate the user sending a ctl-c to the hanging program. | ||||||
|  |     # this should result in the terminator kicking in since | ||||||
|  |     # the sub is shield blocking and can't respond to SIGINT. | ||||||
|  |     os.kill( | ||||||
|  |         child.pid, | ||||||
|  |         signal.SIGINT, | ||||||
|  |     ) | ||||||
|  |     expect( | ||||||
|  |         child, | ||||||
|  |         'Shutting down actor runtime', | ||||||
|  |         timeout=6, | ||||||
|  |     ) | ||||||
|  |     assert_before( | ||||||
|  |         child, | ||||||
|  |         [ | ||||||
|  |             'raise KeyboardInterrupt', | ||||||
|  |             # 'Shutting down actor runtime', | ||||||
|  |             '#T-800 deployed to collect zombie B0', | ||||||
|  |             "'--uid', \"('hanger',", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | @ -3,22 +3,30 @@ Sketchy network blackoutz, ugly byzantine gens, puedes eschuchar la | ||||||
| cancelacion?.. | cancelacion?.. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
|  | import itertools | ||||||
| from functools import partial | from functools import partial | ||||||
|  | from types import ModuleType | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| from _pytest.pathlib import import_path | from _pytest.pathlib import import_path | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
| 
 | from tractor._testing import ( | ||||||
| from conftest import ( |  | ||||||
|     examples_dir, |     examples_dir, | ||||||
|  |     break_ipc, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'debug_mode', |     'pre_aclose_msgstream', | ||||||
|     [False, True], |     [ | ||||||
|     ids=['no_debug_mode', 'debug_mode'], |         False, | ||||||
|  |         True, | ||||||
|  |     ], | ||||||
|  |     ids=[ | ||||||
|  |         'no_msgstream_aclose', | ||||||
|  |         'pre_aclose_msgstream', | ||||||
|  |     ], | ||||||
| ) | ) | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'ipc_break', |     'ipc_break', | ||||||
|  | @ -63,8 +71,10 @@ from conftest import ( | ||||||
| ) | ) | ||||||
| def test_ipc_channel_break_during_stream( | def test_ipc_channel_break_during_stream( | ||||||
|     debug_mode: bool, |     debug_mode: bool, | ||||||
|  |     loglevel: str, | ||||||
|     spawn_backend: str, |     spawn_backend: str, | ||||||
|     ipc_break: dict | None, |     ipc_break: dict|None, | ||||||
|  |     pre_aclose_msgstream: bool, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     Ensure we can have an IPC channel break its connection during |     Ensure we can have an IPC channel break its connection during | ||||||
|  | @ -81,72 +91,152 @@ def test_ipc_channel_break_during_stream( | ||||||
| 
 | 
 | ||||||
|         # non-`trio` spawners should never hit the hang condition that |         # non-`trio` spawners should never hit the hang condition that | ||||||
|         # requires the user to do ctl-c to cancel the actor tree. |         # requires the user to do ctl-c to cancel the actor tree. | ||||||
|         expect_final_exc = trio.ClosedResourceError |         # expect_final_exc = trio.ClosedResourceError | ||||||
|  |         expect_final_exc = tractor.TransportClosed | ||||||
| 
 | 
 | ||||||
|     mod = import_path( |     mod: ModuleType = import_path( | ||||||
|         examples_dir() / 'advanced_faults' / 'ipc_failure_during_stream.py', |         examples_dir() / 'advanced_faults' | ||||||
|  |         / 'ipc_failure_during_stream.py', | ||||||
|         root=examples_dir(), |         root=examples_dir(), | ||||||
|  |         consider_namespace_packages=False, | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  |     # by def we expect KBI from user after a simulated "hang | ||||||
|  |     # period" wherein the user eventually hits ctl-c to kill the | ||||||
|  |     # root-actor tree. | ||||||
|  |     expect_final_exc: BaseException = KeyboardInterrupt | ||||||
|  |     if ( | ||||||
|  |         # only expect EoC if trans is broken on the child side, | ||||||
|  |         ipc_break['break_child_ipc_after'] is not False | ||||||
|  |         # AND we tell the child to call `MsgStream.aclose()`. | ||||||
|  |         and pre_aclose_msgstream | ||||||
|  |     ): | ||||||
|  |         # expect_final_exc = trio.EndOfChannel | ||||||
|  |         # ^XXX NOPE! XXX^ since now `.open_stream()` absorbs this | ||||||
|  |         # gracefully! | ||||||
|         expect_final_exc = KeyboardInterrupt |         expect_final_exc = KeyboardInterrupt | ||||||
| 
 | 
 | ||||||
|     # when ONLY the child breaks we expect the parent to get a closed |     # NOTE when ONLY the child breaks or it breaks BEFORE the | ||||||
|     # resource error on the next `MsgStream.receive()` and then fail out |     # parent we expect the parent to get a closed resource error | ||||||
|     # and cancel the child from there. |     # on the next `MsgStream.receive()` and then fail out and | ||||||
|  |     # cancel the child from there. | ||||||
|  |     # | ||||||
|  |     # ONLY CHILD breaks | ||||||
|     if ( |     if ( | ||||||
| 
 |  | ||||||
|         # only child breaks |  | ||||||
|         ( |  | ||||||
|         ipc_break['break_child_ipc_after'] |         ipc_break['break_child_ipc_after'] | ||||||
|             and ipc_break['break_parent_ipc_after'] is False |         and | ||||||
|         ) |         ipc_break['break_parent_ipc_after'] is False | ||||||
|  |     ): | ||||||
|  |         # NOTE: we DO NOT expect this any more since | ||||||
|  |         # the child side's channel will be broken silently | ||||||
|  |         # and nothing on the parent side will indicate this! | ||||||
|  |         # expect_final_exc = trio.ClosedResourceError | ||||||
| 
 | 
 | ||||||
|         # both break but, parent breaks first |         # NOTE: child will send a 'stop' msg before it breaks | ||||||
|         or ( |         # the transport channel BUT, that will be absorbed by the | ||||||
|  |         # `ctx.open_stream()` block and thus the `.open_context()` | ||||||
|  |         # should hang, after which the test script simulates | ||||||
|  |         # a user sending ctl-c by raising a KBI. | ||||||
|  |         if pre_aclose_msgstream: | ||||||
|  |             expect_final_exc = KeyboardInterrupt | ||||||
|  | 
 | ||||||
|  |             # XXX OLD XXX | ||||||
|  |             # if child calls `MsgStream.aclose()` then expect EoC. | ||||||
|  |             # ^ XXX not any more ^ since eoc is always absorbed | ||||||
|  |             # gracefully and NOT bubbled to the `.open_context()` | ||||||
|  |             # block! | ||||||
|  |             # expect_final_exc = trio.EndOfChannel | ||||||
|  | 
 | ||||||
|  |     # BOTH but, CHILD breaks FIRST | ||||||
|  |     elif ( | ||||||
|         ipc_break['break_child_ipc_after'] is not False |         ipc_break['break_child_ipc_after'] is not False | ||||||
|         and ( |         and ( | ||||||
|             ipc_break['break_parent_ipc_after'] |             ipc_break['break_parent_ipc_after'] | ||||||
|             > ipc_break['break_child_ipc_after'] |             > ipc_break['break_child_ipc_after'] | ||||||
|         ) |         ) | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|     ): |     ): | ||||||
|         expect_final_exc = trio.ClosedResourceError |         if pre_aclose_msgstream: | ||||||
|  |             expect_final_exc = KeyboardInterrupt | ||||||
| 
 | 
 | ||||||
|     # when the parent IPC side dies (even if the child's does as well |     # NOTE when the parent IPC side dies (even if the child does as well | ||||||
|     # but the child fails BEFORE the parent) we expect the channel to be |     # but the child fails BEFORE the parent) we always expect the | ||||||
|     # sent a stop msg from the child at some point which will signal the |     # IPC layer to raise a closed-resource, NEVER do we expect | ||||||
|     # parent that the stream has been terminated. |     # a stop msg since the parent-side ctx apis will error out | ||||||
|     # NOTE: when the parent breaks "after" the child you get this same |     # IMMEDIATELY before the child ever sends any 'stop' msg. | ||||||
|     # case as well, the child breaks the IPC channel with a stop msg |     # | ||||||
|     # before any closure takes place. |     # ONLY PARENT breaks | ||||||
|     elif ( |     elif ( | ||||||
|         # only parent breaks |  | ||||||
|         ( |  | ||||||
|         ipc_break['break_parent_ipc_after'] |         ipc_break['break_parent_ipc_after'] | ||||||
|             and ipc_break['break_child_ipc_after'] is False |         and | ||||||
|         ) |         ipc_break['break_child_ipc_after'] is False | ||||||
|  |     ): | ||||||
|  |         # expect_final_exc = trio.ClosedResourceError | ||||||
|  |         expect_final_exc = tractor.TransportClosed | ||||||
| 
 | 
 | ||||||
|         # both break but, child breaks first |     # BOTH but, PARENT breaks FIRST | ||||||
|         or ( |     elif ( | ||||||
|         ipc_break['break_parent_ipc_after'] is not False |         ipc_break['break_parent_ipc_after'] is not False | ||||||
|         and ( |         and ( | ||||||
|             ipc_break['break_child_ipc_after'] |             ipc_break['break_child_ipc_after'] | ||||||
|                 > ipc_break['break_parent_ipc_after'] |             > | ||||||
|             ) |             ipc_break['break_parent_ipc_after'] | ||||||
|         ) |         ) | ||||||
|     ): |     ): | ||||||
|         expect_final_exc = trio.EndOfChannel |         # expect_final_exc = trio.ClosedResourceError | ||||||
|  |         expect_final_exc = tractor.TransportClosed | ||||||
| 
 | 
 | ||||||
|     with pytest.raises(expect_final_exc): |     with pytest.raises( | ||||||
|  |         expected_exception=( | ||||||
|  |             expect_final_exc, | ||||||
|  |             ExceptionGroup, | ||||||
|  |         ), | ||||||
|  |     ) as excinfo: | ||||||
|  |         try: | ||||||
|             trio.run( |             trio.run( | ||||||
|                 partial( |                 partial( | ||||||
|                     mod.main, |                     mod.main, | ||||||
|                     debug_mode=debug_mode, |                     debug_mode=debug_mode, | ||||||
|                     start_method=spawn_backend, |                     start_method=spawn_backend, | ||||||
|  |                     loglevel=loglevel, | ||||||
|  |                     pre_close=pre_aclose_msgstream, | ||||||
|                     **ipc_break, |                     **ipc_break, | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|  |         except KeyboardInterrupt as _kbi: | ||||||
|  |             kbi = _kbi | ||||||
|  |             if expect_final_exc is not KeyboardInterrupt: | ||||||
|  |                 pytest.fail( | ||||||
|  |                     'Rxed unexpected KBI !?\n' | ||||||
|  |                     f'{repr(kbi)}' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |             raise | ||||||
|  | 
 | ||||||
|  |         except tractor.TransportClosed as _tc: | ||||||
|  |             tc = _tc | ||||||
|  |             if expect_final_exc is KeyboardInterrupt: | ||||||
|  |                 pytest.fail( | ||||||
|  |                     'Unexpected transport failure !?\n' | ||||||
|  |                     f'{repr(tc)}' | ||||||
|  |                 ) | ||||||
|  |             cause: Exception = tc.__cause__ | ||||||
|  |             assert ( | ||||||
|  |                 type(cause) is trio.ClosedResourceError | ||||||
|  |                 and | ||||||
|  |                 cause.args[0] == 'another task closed this fd' | ||||||
|  |             ) | ||||||
|  |             raise | ||||||
|  | 
 | ||||||
|  |     # get raw instance from pytest wrapper | ||||||
|  |     value = excinfo.value | ||||||
|  |     if isinstance(value, ExceptionGroup): | ||||||
|  |         value = next( | ||||||
|  |             itertools.dropwhile( | ||||||
|  |                 lambda exc: not isinstance(exc, expect_final_exc), | ||||||
|  |                 value.exceptions, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         assert value | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
|  | @ -155,9 +245,15 @@ async def break_ipc_after_started( | ||||||
| ) -> None: | ) -> None: | ||||||
|     await ctx.started() |     await ctx.started() | ||||||
|     async with ctx.open_stream() as stream: |     async with ctx.open_stream() as stream: | ||||||
|         await stream.aclose() | 
 | ||||||
|         await trio.sleep(0.2) |         # TODO: make a test which verifies the error | ||||||
|         await ctx.chan.send(None) |         # for this, i.e. raises a `MsgTypeError` | ||||||
|  |         # await ctx.chan.send(None) | ||||||
|  | 
 | ||||||
|  |         await break_ipc( | ||||||
|  |             stream=stream, | ||||||
|  |             pre_close=True, | ||||||
|  |         ) | ||||||
|         print('child broke IPC and terminating') |         print('child broke IPC and terminating') | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -169,6 +265,7 @@ def test_stream_closed_right_after_ipc_break_and_zombie_lord_engages(): | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     async def main(): |     async def main(): | ||||||
|  |         with trio.fail_after(3): | ||||||
|             async with tractor.open_nursery() as n: |             async with tractor.open_nursery() as n: | ||||||
|                 portal = await n.start_actor( |                 portal = await n.start_actor( | ||||||
|                     'ipc_breaker', |                     'ipc_breaker', | ||||||
|  | @ -186,7 +283,10 @@ def test_stream_closed_right_after_ipc_break_and_zombie_lord_engages(): | ||||||
| 
 | 
 | ||||||
|                         print('parent waiting on context') |                         print('parent waiting on context') | ||||||
| 
 | 
 | ||||||
|             print('parent exited context') |                 print( | ||||||
|  |                     'parent exited context\n' | ||||||
|  |                     'parent raising KBI..\n' | ||||||
|  |                 ) | ||||||
|                 raise KeyboardInterrupt |                 raise KeyboardInterrupt | ||||||
| 
 | 
 | ||||||
|     with pytest.raises(KeyboardInterrupt): |     with pytest.raises(KeyboardInterrupt): | ||||||
|  |  | ||||||
|  | @ -6,6 +6,7 @@ from collections import Counter | ||||||
| import itertools | import itertools | ||||||
| import platform | import platform | ||||||
| 
 | 
 | ||||||
|  | import pytest | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
| 
 | 
 | ||||||
|  | @ -143,8 +144,16 @@ def test_dynamic_pub_sub(): | ||||||
| 
 | 
 | ||||||
|     try: |     try: | ||||||
|         trio.run(main) |         trio.run(main) | ||||||
|     except trio.TooSlowError: |     except ( | ||||||
|         pass |         trio.TooSlowError, | ||||||
|  |         ExceptionGroup, | ||||||
|  |     ) as err: | ||||||
|  |         if isinstance(err, ExceptionGroup): | ||||||
|  |             for suberr in err.exceptions: | ||||||
|  |                 if isinstance(suberr, trio.TooSlowError): | ||||||
|  |                     break | ||||||
|  |             else: | ||||||
|  |                 pytest.fail('Never got a `TooSlowError` ?') | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
|  | @ -298,44 +307,69 @@ async def inf_streamer( | ||||||
| 
 | 
 | ||||||
|     async with ( |     async with ( | ||||||
|         ctx.open_stream() as stream, |         ctx.open_stream() as stream, | ||||||
|         trio.open_nursery() as n, |         trio.open_nursery() as tn, | ||||||
|     ): |     ): | ||||||
|         async def bail_on_sentinel(): |         async def close_stream_on_sentinel(): | ||||||
|             async for msg in stream: |             async for msg in stream: | ||||||
|                 if msg == 'done': |                 if msg == 'done': | ||||||
|  |                     print( | ||||||
|  |                         'streamer RXed "done" sentinel msg!\n' | ||||||
|  |                         'CLOSING `MsgStream`!' | ||||||
|  |                     ) | ||||||
|                     await stream.aclose() |                     await stream.aclose() | ||||||
|                 else: |                 else: | ||||||
|                     print(f'streamer received {msg}') |                     print(f'streamer received {msg}') | ||||||
|  |             else: | ||||||
|  |                 print('streamer exited recv loop') | ||||||
| 
 | 
 | ||||||
|         # start termination detector |         # start termination detector | ||||||
|         n.start_soon(bail_on_sentinel) |         tn.start_soon(close_stream_on_sentinel) | ||||||
| 
 | 
 | ||||||
|         for val in itertools.count(): |         cap: int = 10000  # so that we don't spin forever when bug.. | ||||||
|  |         for val in range(cap): | ||||||
|             try: |             try: | ||||||
|  |                 print(f'streamer sending {val}') | ||||||
|                 await stream.send(val) |                 await stream.send(val) | ||||||
|             except trio.ClosedResourceError: |                 if val > cap: | ||||||
|  |                     raise RuntimeError( | ||||||
|  |                         'Streamer never cancelled by setinel?' | ||||||
|  |                     ) | ||||||
|  |                 await trio.sleep(0.001) | ||||||
|  | 
 | ||||||
|             # close out the stream gracefully |             # close out the stream gracefully | ||||||
|  |             except trio.ClosedResourceError: | ||||||
|  |                 print('transport closed on streamer side!') | ||||||
|  |                 assert stream.closed | ||||||
|                 break |                 break | ||||||
|  |         else: | ||||||
|  |             raise RuntimeError( | ||||||
|  |                 'Streamer not cancelled before finished sending?' | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|     print('terminating streamer') |     print('streamer exited .open_streamer() block') | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_local_task_fanout_from_stream(): | def test_local_task_fanout_from_stream( | ||||||
|  |     debug_mode: bool, | ||||||
|  | ): | ||||||
|     ''' |     ''' | ||||||
|     Single stream with multiple local consumer tasks using the |     Single stream with multiple local consumer tasks using the | ||||||
|     ``MsgStream.subscribe()` api. |     ``MsgStream.subscribe()` api. | ||||||
| 
 | 
 | ||||||
|     Ensure all tasks receive all values after stream completes sending. |     Ensure all tasks receive all values after stream completes | ||||||
|  |     sending. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     consumers = 22 |     consumers: int = 22 | ||||||
| 
 | 
 | ||||||
|     async def main(): |     async def main(): | ||||||
| 
 | 
 | ||||||
|         counts = Counter() |         counts = Counter() | ||||||
| 
 | 
 | ||||||
|         async with tractor.open_nursery() as tn: |         async with tractor.open_nursery( | ||||||
|             p = await tn.start_actor( |             debug_mode=debug_mode, | ||||||
|  |         ) as tn: | ||||||
|  |             p: tractor.Portal = await tn.start_actor( | ||||||
|                 'inf_streamer', |                 'inf_streamer', | ||||||
|                 enable_modules=[__name__], |                 enable_modules=[__name__], | ||||||
|             ) |             ) | ||||||
|  | @ -343,7 +377,6 @@ def test_local_task_fanout_from_stream(): | ||||||
|                 p.open_context(inf_streamer) as (ctx, _), |                 p.open_context(inf_streamer) as (ctx, _), | ||||||
|                 ctx.open_stream() as stream, |                 ctx.open_stream() as stream, | ||||||
|             ): |             ): | ||||||
| 
 |  | ||||||
|                 async def pull_and_count(name: str): |                 async def pull_and_count(name: str): | ||||||
|                     # name = trio.lowlevel.current_task().name |                     # name = trio.lowlevel.current_task().name | ||||||
|                     async with stream.subscribe() as recver: |                     async with stream.subscribe() as recver: | ||||||
|  | @ -352,7 +385,7 @@ def test_local_task_fanout_from_stream(): | ||||||
|                             tractor.trionics.BroadcastReceiver |                             tractor.trionics.BroadcastReceiver | ||||||
|                         ) |                         ) | ||||||
|                         async for val in recver: |                         async for val in recver: | ||||||
|                             # print(f'{name}: {val}') |                             print(f'bx {name} rx: {val}') | ||||||
|                             counts[name] += 1 |                             counts[name] += 1 | ||||||
| 
 | 
 | ||||||
|                         print(f'{name} bcaster ended') |                         print(f'{name} bcaster ended') | ||||||
|  | @ -362,10 +395,14 @@ def test_local_task_fanout_from_stream(): | ||||||
|                 with trio.fail_after(3): |                 with trio.fail_after(3): | ||||||
|                     async with trio.open_nursery() as nurse: |                     async with trio.open_nursery() as nurse: | ||||||
|                         for i in range(consumers): |                         for i in range(consumers): | ||||||
|                             nurse.start_soon(pull_and_count, i) |                             nurse.start_soon( | ||||||
|  |                                 pull_and_count, | ||||||
|  |                                 i, | ||||||
|  |                             ) | ||||||
| 
 | 
 | ||||||
|  |                         # delay to let bcast consumers pull msgs | ||||||
|                         await trio.sleep(0.5) |                         await trio.sleep(0.5) | ||||||
|                         print('\nterminating') |                         print('terminating nursery of bcast rxer consumers!') | ||||||
|                         await stream.send('done') |                         await stream.send('done') | ||||||
| 
 | 
 | ||||||
|             print('closed stream connection') |             print('closed stream connection') | ||||||
|  |  | ||||||
|  | @ -8,15 +8,13 @@ import platform | ||||||
| import time | import time | ||||||
| from itertools import repeat | from itertools import repeat | ||||||
| 
 | 
 | ||||||
| from exceptiongroup import ( |  | ||||||
|     BaseExceptionGroup, |  | ||||||
|     ExceptionGroup, |  | ||||||
| ) |  | ||||||
| import pytest | import pytest | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
| 
 | from tractor._testing import ( | ||||||
| from conftest import tractor_test, no_windows |     tractor_test, | ||||||
|  | ) | ||||||
|  | from .conftest import no_windows | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def is_win(): | def is_win(): | ||||||
|  | @ -47,17 +45,19 @@ async def do_nuthin(): | ||||||
|     ], |     ], | ||||||
|     ids=['no_args', 'unexpected_args'], |     ids=['no_args', 'unexpected_args'], | ||||||
| ) | ) | ||||||
| def test_remote_error(arb_addr, args_err): | def test_remote_error(reg_addr, args_err): | ||||||
|     """Verify an error raised in a subactor that is propagated |     ''' | ||||||
|  |     Verify an error raised in a subactor that is propagated | ||||||
|     to the parent nursery, contains the underlying boxed builtin |     to the parent nursery, contains the underlying boxed builtin | ||||||
|     error type info and causes cancellation and reraising all the |     error type info and causes cancellation and reraising all the | ||||||
|     way up the stack. |     way up the stack. | ||||||
|     """ | 
 | ||||||
|  |     ''' | ||||||
|     args, errtype = args_err |     args, errtype = args_err | ||||||
| 
 | 
 | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             arbiter_addr=arb_addr, |             registry_addrs=[reg_addr], | ||||||
|         ) as nursery: |         ) as nursery: | ||||||
| 
 | 
 | ||||||
|             # on a remote type error caused by bad input args |             # on a remote type error caused by bad input args | ||||||
|  | @ -65,7 +65,9 @@ def test_remote_error(arb_addr, args_err): | ||||||
|             # an exception group outside the nursery since the error |             # an exception group outside the nursery since the error | ||||||
|             # here and the far end task error are one in the same? |             # here and the far end task error are one in the same? | ||||||
|             portal = await nursery.run_in_actor( |             portal = await nursery.run_in_actor( | ||||||
|                 assert_err, name='errorer', **args |                 assert_err, | ||||||
|  |                 name='errorer', | ||||||
|  |                 **args | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|             # get result(s) from main task |             # get result(s) from main task | ||||||
|  | @ -75,7 +77,7 @@ def test_remote_error(arb_addr, args_err): | ||||||
|                 # of this actor nursery. |                 # of this actor nursery. | ||||||
|                 await portal.result() |                 await portal.result() | ||||||
|             except tractor.RemoteActorError as err: |             except tractor.RemoteActorError as err: | ||||||
|                 assert err.type == errtype |                 assert err.boxed_type == errtype | ||||||
|                 print("Look Maa that actor failed hard, hehh") |                 print("Look Maa that actor failed hard, hehh") | ||||||
|                 raise |                 raise | ||||||
| 
 | 
 | ||||||
|  | @ -84,20 +86,33 @@ def test_remote_error(arb_addr, args_err): | ||||||
|         with pytest.raises(tractor.RemoteActorError) as excinfo: |         with pytest.raises(tractor.RemoteActorError) as excinfo: | ||||||
|             trio.run(main) |             trio.run(main) | ||||||
| 
 | 
 | ||||||
|         assert excinfo.value.type == errtype |         assert excinfo.value.boxed_type == errtype | ||||||
| 
 | 
 | ||||||
|     else: |     else: | ||||||
|         # the root task will also error on the `.result()` call |         # the root task will also error on the `Portal.result()` | ||||||
|         # so we expect an error from there AND the child. |         # call so we expect an error from there AND the child. | ||||||
|         with pytest.raises(BaseExceptionGroup) as excinfo: |         # |_ tho seems like on new `trio` this doesn't always | ||||||
|  |         #    happen? | ||||||
|  |         with pytest.raises(( | ||||||
|  |             BaseExceptionGroup, | ||||||
|  |             tractor.RemoteActorError, | ||||||
|  |         )) as excinfo: | ||||||
|             trio.run(main) |             trio.run(main) | ||||||
| 
 | 
 | ||||||
|         # ensure boxed errors |         # ensure boxed errors are `errtype` | ||||||
|         for exc in excinfo.value.exceptions: |         err: BaseException = excinfo.value | ||||||
|             assert exc.type == errtype |         if isinstance(err, BaseExceptionGroup): | ||||||
|  |             suberrs: list[BaseException] = err.exceptions | ||||||
|  |         else: | ||||||
|  |             suberrs: list[BaseException] = [err] | ||||||
|  | 
 | ||||||
|  |         for exc in suberrs: | ||||||
|  |             assert exc.boxed_type == errtype | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_multierror(arb_addr): | def test_multierror( | ||||||
|  |     reg_addr: tuple[str, int], | ||||||
|  | ): | ||||||
|     ''' |     ''' | ||||||
|     Verify we raise a ``BaseExceptionGroup`` out of a nursery where |     Verify we raise a ``BaseExceptionGroup`` out of a nursery where | ||||||
|     more then one actor errors. |     more then one actor errors. | ||||||
|  | @ -105,7 +120,7 @@ def test_multierror(arb_addr): | ||||||
|     ''' |     ''' | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             arbiter_addr=arb_addr, |             registry_addrs=[reg_addr], | ||||||
|         ) as nursery: |         ) as nursery: | ||||||
| 
 | 
 | ||||||
|             await nursery.run_in_actor(assert_err, name='errorer1') |             await nursery.run_in_actor(assert_err, name='errorer1') | ||||||
|  | @ -115,7 +130,7 @@ def test_multierror(arb_addr): | ||||||
|             try: |             try: | ||||||
|                 await portal2.result() |                 await portal2.result() | ||||||
|             except tractor.RemoteActorError as err: |             except tractor.RemoteActorError as err: | ||||||
|                 assert err.type == AssertionError |                 assert err.boxed_type == AssertionError | ||||||
|                 print("Look Maa that first actor failed hard, hehh") |                 print("Look Maa that first actor failed hard, hehh") | ||||||
|                 raise |                 raise | ||||||
| 
 | 
 | ||||||
|  | @ -130,14 +145,14 @@ def test_multierror(arb_addr): | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'num_subactors', range(25, 26), |     'num_subactors', range(25, 26), | ||||||
| ) | ) | ||||||
| def test_multierror_fast_nursery(arb_addr, start_method, num_subactors, delay): | def test_multierror_fast_nursery(reg_addr, start_method, num_subactors, delay): | ||||||
|     """Verify we raise a ``BaseExceptionGroup`` out of a nursery where |     """Verify we raise a ``BaseExceptionGroup`` out of a nursery where | ||||||
|     more then one actor errors and also with a delay before failure |     more then one actor errors and also with a delay before failure | ||||||
|     to test failure during an ongoing spawning. |     to test failure during an ongoing spawning. | ||||||
|     """ |     """ | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             arbiter_addr=arb_addr, |             registry_addrs=[reg_addr], | ||||||
|         ) as nursery: |         ) as nursery: | ||||||
| 
 | 
 | ||||||
|             for i in range(num_subactors): |             for i in range(num_subactors): | ||||||
|  | @ -167,7 +182,7 @@ def test_multierror_fast_nursery(arb_addr, start_method, num_subactors, delay): | ||||||
| 
 | 
 | ||||||
|     for exc in exceptions: |     for exc in exceptions: | ||||||
|         assert isinstance(exc, tractor.RemoteActorError) |         assert isinstance(exc, tractor.RemoteActorError) | ||||||
|         assert exc.type == AssertionError |         assert exc.boxed_type == AssertionError | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def do_nothing(): | async def do_nothing(): | ||||||
|  | @ -175,15 +190,20 @@ async def do_nothing(): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize('mechanism', ['nursery_cancel', KeyboardInterrupt]) | @pytest.mark.parametrize('mechanism', ['nursery_cancel', KeyboardInterrupt]) | ||||||
| def test_cancel_single_subactor(arb_addr, mechanism): | def test_cancel_single_subactor(reg_addr, mechanism): | ||||||
|     """Ensure a ``ActorNursery.start_actor()`` spawned subactor |     ''' | ||||||
|  |     Ensure a ``ActorNursery.start_actor()`` spawned subactor | ||||||
|     cancels when the nursery is cancelled. |     cancels when the nursery is cancelled. | ||||||
|     """ | 
 | ||||||
|  |     ''' | ||||||
|     async def spawn_actor(): |     async def spawn_actor(): | ||||||
|         """Spawn an actor that blocks indefinitely. |         ''' | ||||||
|         """ |         Spawn an actor that blocks indefinitely then cancel via | ||||||
|  |         either `ActorNursery.cancel()` or an exception raise. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             arbiter_addr=arb_addr, |             registry_addrs=[reg_addr], | ||||||
|         ) as nursery: |         ) as nursery: | ||||||
| 
 | 
 | ||||||
|             portal = await nursery.start_actor( |             portal = await nursery.start_actor( | ||||||
|  | @ -303,7 +323,7 @@ async def test_some_cancels_all(num_actors_and_errs, start_method, loglevel): | ||||||
|                         await portal.run(func, **kwargs) |                         await portal.run(func, **kwargs) | ||||||
| 
 | 
 | ||||||
|                     except tractor.RemoteActorError as err: |                     except tractor.RemoteActorError as err: | ||||||
|                         assert err.type == err_type |                         assert err.boxed_type == err_type | ||||||
|                         # we only expect this first error to propogate |                         # we only expect this first error to propogate | ||||||
|                         # (all other daemons are cancelled before they |                         # (all other daemons are cancelled before they | ||||||
|                         # can be scheduled) |                         # can be scheduled) | ||||||
|  | @ -322,11 +342,11 @@ async def test_some_cancels_all(num_actors_and_errs, start_method, loglevel): | ||||||
|             assert len(err.exceptions) == num_actors |             assert len(err.exceptions) == num_actors | ||||||
|             for exc in err.exceptions: |             for exc in err.exceptions: | ||||||
|                 if isinstance(exc, tractor.RemoteActorError): |                 if isinstance(exc, tractor.RemoteActorError): | ||||||
|                     assert exc.type == err_type |                     assert exc.boxed_type == err_type | ||||||
|                 else: |                 else: | ||||||
|                     assert isinstance(exc, trio.Cancelled) |                     assert isinstance(exc, trio.Cancelled) | ||||||
|         elif isinstance(err, tractor.RemoteActorError): |         elif isinstance(err, tractor.RemoteActorError): | ||||||
|             assert err.type == err_type |             assert err.boxed_type == err_type | ||||||
| 
 | 
 | ||||||
|         assert n.cancelled is True |         assert n.cancelled is True | ||||||
|         assert not n._children |         assert not n._children | ||||||
|  | @ -405,7 +425,7 @@ async def test_nested_multierrors(loglevel, start_method): | ||||||
|                     elif isinstance(subexc, tractor.RemoteActorError): |                     elif isinstance(subexc, tractor.RemoteActorError): | ||||||
|                         # on windows it seems we can't exactly be sure wtf |                         # on windows it seems we can't exactly be sure wtf | ||||||
|                         # will happen.. |                         # will happen.. | ||||||
|                         assert subexc.type in ( |                         assert subexc.boxed_type in ( | ||||||
|                             tractor.RemoteActorError, |                             tractor.RemoteActorError, | ||||||
|                             trio.Cancelled, |                             trio.Cancelled, | ||||||
|                             BaseExceptionGroup, |                             BaseExceptionGroup, | ||||||
|  | @ -415,7 +435,7 @@ async def test_nested_multierrors(loglevel, start_method): | ||||||
|                         for subsub in subexc.exceptions: |                         for subsub in subexc.exceptions: | ||||||
| 
 | 
 | ||||||
|                             if subsub in (tractor.RemoteActorError,): |                             if subsub in (tractor.RemoteActorError,): | ||||||
|                                 subsub = subsub.type |                                 subsub = subsub.boxed_type | ||||||
| 
 | 
 | ||||||
|                             assert type(subsub) in ( |                             assert type(subsub) in ( | ||||||
|                                 trio.Cancelled, |                                 trio.Cancelled, | ||||||
|  | @ -430,16 +450,16 @@ async def test_nested_multierrors(loglevel, start_method): | ||||||
|                     # we get back the (sent) cancel signal instead |                     # we get back the (sent) cancel signal instead | ||||||
|                     if is_win(): |                     if is_win(): | ||||||
|                         if isinstance(subexc, tractor.RemoteActorError): |                         if isinstance(subexc, tractor.RemoteActorError): | ||||||
|                             assert subexc.type in ( |                             assert subexc.boxed_type in ( | ||||||
|                                 BaseExceptionGroup, |                                 BaseExceptionGroup, | ||||||
|                                 tractor.RemoteActorError |                                 tractor.RemoteActorError | ||||||
|                             ) |                             ) | ||||||
|                         else: |                         else: | ||||||
|                             assert isinstance(subexc, BaseExceptionGroup) |                             assert isinstance(subexc, BaseExceptionGroup) | ||||||
|                     else: |                     else: | ||||||
|                         assert subexc.type is ExceptionGroup |                         assert subexc.boxed_type is ExceptionGroup | ||||||
|                 else: |                 else: | ||||||
|                     assert subexc.type in ( |                     assert subexc.boxed_type in ( | ||||||
|                         tractor.RemoteActorError, |                         tractor.RemoteActorError, | ||||||
|                         trio.Cancelled |                         trio.Cancelled | ||||||
|                     ) |                     ) | ||||||
|  |  | ||||||
|  | @ -0,0 +1,917 @@ | ||||||
|  | ''' | ||||||
|  | Low-level functional audits for our | ||||||
|  | "capability based messaging"-spec feats. | ||||||
|  | 
 | ||||||
|  | B~) | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | import typing | ||||||
|  | from typing import ( | ||||||
|  |     Any, | ||||||
|  |     Type, | ||||||
|  |     Union, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from msgspec import ( | ||||||
|  |     structs, | ||||||
|  |     msgpack, | ||||||
|  |     Struct, | ||||||
|  |     ValidationError, | ||||||
|  | ) | ||||||
|  | import pytest | ||||||
|  | 
 | ||||||
|  | import tractor | ||||||
|  | from tractor import ( | ||||||
|  |     _state, | ||||||
|  |     MsgTypeError, | ||||||
|  |     Context, | ||||||
|  | ) | ||||||
|  | from tractor.msg import ( | ||||||
|  |     _codec, | ||||||
|  |     _ctxvar_MsgCodec, | ||||||
|  | 
 | ||||||
|  |     NamespacePath, | ||||||
|  |     MsgCodec, | ||||||
|  |     mk_codec, | ||||||
|  |     apply_codec, | ||||||
|  |     current_codec, | ||||||
|  | ) | ||||||
|  | from tractor.msg.types import ( | ||||||
|  |     _payload_msgs, | ||||||
|  |     log, | ||||||
|  |     PayloadMsg, | ||||||
|  |     Started, | ||||||
|  |     mk_msg_spec, | ||||||
|  | ) | ||||||
|  | import trio | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def mk_custom_codec( | ||||||
|  |     pld_spec: Union[Type]|Any, | ||||||
|  |     add_hooks: bool, | ||||||
|  | 
 | ||||||
|  | ) -> MsgCodec: | ||||||
|  |     ''' | ||||||
|  |     Create custom `msgpack` enc/dec-hooks and set a `Decoder` | ||||||
|  |     which only loads `pld_spec` (like `NamespacePath`) types. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     uid: tuple[str, str] = tractor.current_actor().uid | ||||||
|  | 
 | ||||||
|  |     # XXX NOTE XXX: despite defining `NamespacePath` as a type | ||||||
|  |     # field on our `PayloadMsg.pld`, we still need a enc/dec_hook() pair | ||||||
|  |     # to cast to/from that type on the wire. See the docs: | ||||||
|  |     # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types | ||||||
|  | 
 | ||||||
|  |     def enc_nsp(obj: Any) -> Any: | ||||||
|  |         print(f'{uid} ENC HOOK') | ||||||
|  |         match obj: | ||||||
|  |             case NamespacePath(): | ||||||
|  |                 print( | ||||||
|  |                     f'{uid}: `NamespacePath`-Only ENCODE?\n' | ||||||
|  |                     f'obj-> `{obj}`: {type(obj)}\n' | ||||||
|  |                 ) | ||||||
|  |                 # if type(obj) != NamespacePath: | ||||||
|  |                 #     breakpoint() | ||||||
|  |                 return str(obj) | ||||||
|  | 
 | ||||||
|  |         print( | ||||||
|  |             f'{uid}\n' | ||||||
|  |             'CUSTOM ENCODE\n' | ||||||
|  |             f'obj-arg-> `{obj}`: {type(obj)}\n' | ||||||
|  |         ) | ||||||
|  |         logmsg: str = ( | ||||||
|  |             f'{uid}\n' | ||||||
|  |             'FAILED ENCODE\n' | ||||||
|  |             f'obj-> `{obj}: {type(obj)}`\n' | ||||||
|  |         ) | ||||||
|  |         raise NotImplementedError(logmsg) | ||||||
|  | 
 | ||||||
|  |     def dec_nsp( | ||||||
|  |         obj_type: Type, | ||||||
|  |         obj: Any, | ||||||
|  | 
 | ||||||
|  |     ) -> Any: | ||||||
|  |         print( | ||||||
|  |             f'{uid}\n' | ||||||
|  |             'CUSTOM DECODE\n' | ||||||
|  |             f'type-arg-> {obj_type}\n' | ||||||
|  |             f'obj-arg-> `{obj}`: {type(obj)}\n' | ||||||
|  |         ) | ||||||
|  |         nsp = None | ||||||
|  | 
 | ||||||
|  |         if ( | ||||||
|  |             obj_type is NamespacePath | ||||||
|  |             and isinstance(obj, str) | ||||||
|  |             and ':' in obj | ||||||
|  |         ): | ||||||
|  |             nsp = NamespacePath(obj) | ||||||
|  |             # TODO: we could built a generic handler using | ||||||
|  |             # JUST matching the obj_type part? | ||||||
|  |             # nsp = obj_type(obj) | ||||||
|  | 
 | ||||||
|  |         if nsp: | ||||||
|  |             print(f'Returning NSP instance: {nsp}') | ||||||
|  |             return nsp | ||||||
|  | 
 | ||||||
|  |         logmsg: str = ( | ||||||
|  |             f'{uid}\n' | ||||||
|  |             'FAILED DECODE\n' | ||||||
|  |             f'type-> {obj_type}\n' | ||||||
|  |             f'obj-arg-> `{obj}`: {type(obj)}\n\n' | ||||||
|  |             f'current codec:\n' | ||||||
|  |             f'{current_codec()}\n' | ||||||
|  |         ) | ||||||
|  |         # TODO: figure out the ignore subsys for this! | ||||||
|  |         # -[ ] option whether to defense-relay backc the msg | ||||||
|  |         #   inside an `Invalid`/`Ignore` | ||||||
|  |         # -[ ] how to make this handling pluggable such that a | ||||||
|  |         #   `Channel`/`MsgTransport` can intercept and process | ||||||
|  |         #   back msgs either via exception handling or some other | ||||||
|  |         #   signal? | ||||||
|  |         log.warning(logmsg) | ||||||
|  |         # NOTE: this delivers the invalid | ||||||
|  |         # value up to `msgspec`'s decoding | ||||||
|  |         # machinery for error raising. | ||||||
|  |         return obj | ||||||
|  |         # raise NotImplementedError(logmsg) | ||||||
|  | 
 | ||||||
|  |     nsp_codec: MsgCodec = mk_codec( | ||||||
|  |         ipc_pld_spec=pld_spec, | ||||||
|  | 
 | ||||||
|  |         # NOTE XXX: the encode hook MUST be used no matter what since | ||||||
|  |         # our `NamespacePath` is not any of a `Any` native type nor | ||||||
|  |         # a `msgspec.Struct` subtype - so `msgspec` has no way to know | ||||||
|  |         # how to encode it unless we provide the custom hook. | ||||||
|  |         # | ||||||
|  |         # AGAIN that is, regardless of whether we spec an | ||||||
|  |         # `Any`-decoded-pld the enc has no knowledge (by default) | ||||||
|  |         # how to enc `NamespacePath` (nsp), so we add a custom | ||||||
|  |         # hook to do that ALWAYS. | ||||||
|  |         enc_hook=enc_nsp if add_hooks else None, | ||||||
|  | 
 | ||||||
|  |         # XXX NOTE: pretty sure this is mutex with the `type=` to | ||||||
|  |         # `Decoder`? so it won't work in tandem with the | ||||||
|  |         # `ipc_pld_spec` passed above? | ||||||
|  |         dec_hook=dec_nsp if add_hooks else None, | ||||||
|  |     ) | ||||||
|  |     return nsp_codec | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def chk_codec_applied( | ||||||
|  |     expect_codec: MsgCodec, | ||||||
|  |     enter_value: MsgCodec|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> MsgCodec: | ||||||
|  |     ''' | ||||||
|  |     buncha sanity checks ensuring that the IPC channel's | ||||||
|  |     context-vars are set to the expected codec and that are | ||||||
|  |     ctx-var wrapper APIs match the same. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # TODO: play with tricyle again, bc this is supposed to work | ||||||
|  |     # the way we want? | ||||||
|  |     # | ||||||
|  |     # TreeVar | ||||||
|  |     # task: trio.Task = trio.lowlevel.current_task() | ||||||
|  |     # curr_codec = _ctxvar_MsgCodec.get_in(task) | ||||||
|  | 
 | ||||||
|  |     # ContextVar | ||||||
|  |     # task_ctx: Context = task.context | ||||||
|  |     # assert _ctxvar_MsgCodec in task_ctx | ||||||
|  |     # curr_codec: MsgCodec = task.context[_ctxvar_MsgCodec] | ||||||
|  | 
 | ||||||
|  |     # NOTE: currently we use this! | ||||||
|  |     # RunVar | ||||||
|  |     curr_codec: MsgCodec = current_codec() | ||||||
|  |     last_read_codec = _ctxvar_MsgCodec.get() | ||||||
|  |     # assert curr_codec is last_read_codec | ||||||
|  | 
 | ||||||
|  |     assert ( | ||||||
|  |         (same_codec := expect_codec) is | ||||||
|  |         # returned from `mk_codec()` | ||||||
|  | 
 | ||||||
|  |         # yielded value from `apply_codec()` | ||||||
|  | 
 | ||||||
|  |         # read from current task's `contextvars.Context` | ||||||
|  |         curr_codec is | ||||||
|  |         last_read_codec | ||||||
|  | 
 | ||||||
|  |         # the default `msgspec` settings | ||||||
|  |         is not _codec._def_msgspec_codec | ||||||
|  |         is not _codec._def_tractor_codec | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     if enter_value: | ||||||
|  |         enter_value is same_codec | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def iter_maybe_sends( | ||||||
|  |     send_items: dict[Union[Type], Any] | list[tuple], | ||||||
|  |     ipc_pld_spec: Union[Type] | Any, | ||||||
|  |     add_codec_hooks: bool, | ||||||
|  | 
 | ||||||
|  |     codec: MsgCodec|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> tuple[Any, bool]: | ||||||
|  | 
 | ||||||
|  |     if isinstance(send_items, dict): | ||||||
|  |         send_items = send_items.items() | ||||||
|  | 
 | ||||||
|  |     for ( | ||||||
|  |         send_type_spec, | ||||||
|  |         send_value, | ||||||
|  |     ) in send_items: | ||||||
|  | 
 | ||||||
|  |         expect_roundtrip: bool = False | ||||||
|  | 
 | ||||||
|  |         # values-to-typespec santiy | ||||||
|  |         send_type = type(send_value) | ||||||
|  |         assert send_type == send_type_spec or ( | ||||||
|  |             (subtypes := getattr(send_type_spec, '__args__', None)) | ||||||
|  |             and send_type in subtypes | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         spec_subtypes: set[Union[Type]] = ( | ||||||
|  |              getattr( | ||||||
|  |                  ipc_pld_spec, | ||||||
|  |                  '__args__', | ||||||
|  |                  {ipc_pld_spec,}, | ||||||
|  |              ) | ||||||
|  |         ) | ||||||
|  |         send_in_spec: bool = ( | ||||||
|  |             send_type == ipc_pld_spec | ||||||
|  |             or ( | ||||||
|  |                 ipc_pld_spec != Any | ||||||
|  |                 and  # presume `Union` of types | ||||||
|  |                 send_type in spec_subtypes | ||||||
|  |             ) | ||||||
|  |             or ( | ||||||
|  |                 ipc_pld_spec == Any | ||||||
|  |                 and | ||||||
|  |                 send_type != NamespacePath | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         expect_roundtrip = ( | ||||||
|  |             send_in_spec | ||||||
|  |             # any spec should support all other | ||||||
|  |             # builtin py values that we send | ||||||
|  |             # except our custom nsp type which | ||||||
|  |             # we should be able to send as long | ||||||
|  |             # as we provide the custom codec hooks. | ||||||
|  |             or ( | ||||||
|  |                 ipc_pld_spec == Any | ||||||
|  |                 and | ||||||
|  |                 send_type == NamespacePath | ||||||
|  |                 and | ||||||
|  |                 add_codec_hooks | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         if codec is not None: | ||||||
|  |             # XXX FIRST XXX ensure roundtripping works | ||||||
|  |             # before touching any IPC primitives/APIs. | ||||||
|  |             wire_bytes: bytes = codec.encode( | ||||||
|  |                 Started( | ||||||
|  |                     cid='blahblah', | ||||||
|  |                     pld=send_value, | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |             # NOTE: demonstrates the decoder loading | ||||||
|  |             # to via our native SCIPP msg-spec | ||||||
|  |             # (structurred-conc-inter-proc-protocol) | ||||||
|  |             # implemented as per, | ||||||
|  |             try: | ||||||
|  |                 msg: Started = codec.decode(wire_bytes) | ||||||
|  |                 if not expect_roundtrip: | ||||||
|  |                     pytest.fail( | ||||||
|  |                         f'NOT-EXPECTED able to roundtrip value given spec:\n' | ||||||
|  |                         f'ipc_pld_spec -> {ipc_pld_spec}\n' | ||||||
|  |                         f'value -> {send_value}: {send_type}\n' | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                 pld = msg.pld | ||||||
|  |                 assert pld == send_value | ||||||
|  | 
 | ||||||
|  |             except ValidationError: | ||||||
|  |                 if expect_roundtrip: | ||||||
|  |                     pytest.fail( | ||||||
|  |                         f'EXPECTED to roundtrip value given spec:\n' | ||||||
|  |                         f'ipc_pld_spec -> {ipc_pld_spec}\n' | ||||||
|  |                         f'value -> {send_value}: {send_type}\n' | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |         yield ( | ||||||
|  |             str(send_type), | ||||||
|  |             send_value, | ||||||
|  |             expect_roundtrip, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def dec_type_union( | ||||||
|  |     type_names: list[str], | ||||||
|  | ) -> Type: | ||||||
|  |     ''' | ||||||
|  |     Look up types by name, compile into a list and then create and | ||||||
|  |     return a `typing.Union` from the full set. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     import importlib | ||||||
|  |     types: list[Type] = [] | ||||||
|  |     for type_name in type_names: | ||||||
|  |         for mod in [ | ||||||
|  |             typing, | ||||||
|  |             importlib.import_module(__name__), | ||||||
|  |         ]: | ||||||
|  |             if type_ref := getattr( | ||||||
|  |                 mod, | ||||||
|  |                 type_name, | ||||||
|  |                 False, | ||||||
|  |             ): | ||||||
|  |                 types.append(type_ref) | ||||||
|  | 
 | ||||||
|  |     # special case handling only.. | ||||||
|  |     # ipc_pld_spec: Union[Type] = eval( | ||||||
|  |     #     pld_spec_str, | ||||||
|  |     #     {},  # globals | ||||||
|  |     #     {'typing': typing},  # locals | ||||||
|  |     # ) | ||||||
|  | 
 | ||||||
|  |     return Union[*types] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def enc_type_union( | ||||||
|  |     union_or_type: Union[Type]|Type, | ||||||
|  | ) -> list[str]: | ||||||
|  |     ''' | ||||||
|  |     Encode a type-union or single type to a list of type-name-strings | ||||||
|  |     ready for IPC interchange. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     type_strs: list[str] = [] | ||||||
|  |     for typ in getattr( | ||||||
|  |         union_or_type, | ||||||
|  |         '__args__', | ||||||
|  |         {union_or_type,}, | ||||||
|  |     ): | ||||||
|  |         type_strs.append(typ.__qualname__) | ||||||
|  | 
 | ||||||
|  |     return type_strs | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def send_back_values( | ||||||
|  |     ctx: Context, | ||||||
|  |     expect_debug: bool, | ||||||
|  |     pld_spec_type_strs: list[str], | ||||||
|  |     add_hooks: bool, | ||||||
|  |     started_msg_bytes: bytes, | ||||||
|  |     expect_ipc_send: dict[str, tuple[Any, bool]], | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     Setup up a custom codec to load instances of `NamespacePath` | ||||||
|  |     and ensure we can round trip a func ref with our parent. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     uid: tuple = tractor.current_actor().uid | ||||||
|  | 
 | ||||||
|  |     # debug mode sanity check (prolly superfluous but, meh) | ||||||
|  |     assert expect_debug == _state.debug_mode() | ||||||
|  | 
 | ||||||
|  |     # init state in sub-actor should be default | ||||||
|  |     chk_codec_applied( | ||||||
|  |         expect_codec=_codec._def_tractor_codec, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # load pld spec from input str | ||||||
|  |     ipc_pld_spec = dec_type_union( | ||||||
|  |         pld_spec_type_strs, | ||||||
|  |     ) | ||||||
|  |     pld_spec_str = str(ipc_pld_spec) | ||||||
|  | 
 | ||||||
|  |     # same as on parent side config. | ||||||
|  |     nsp_codec: MsgCodec = mk_custom_codec( | ||||||
|  |         pld_spec=ipc_pld_spec, | ||||||
|  |         add_hooks=add_hooks, | ||||||
|  |     ) | ||||||
|  |     with ( | ||||||
|  |         apply_codec(nsp_codec) as codec, | ||||||
|  |     ): | ||||||
|  |         chk_codec_applied( | ||||||
|  |             expect_codec=nsp_codec, | ||||||
|  |             enter_value=codec, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         print( | ||||||
|  |             f'{uid}: attempting `Started`-bytes DECODE..\n' | ||||||
|  |         ) | ||||||
|  |         try: | ||||||
|  |             msg: Started = nsp_codec.decode(started_msg_bytes) | ||||||
|  |             expected_pld_spec_str: str = msg.pld | ||||||
|  |             assert pld_spec_str == expected_pld_spec_str | ||||||
|  | 
 | ||||||
|  |         # TODO: maybe we should add our own wrapper error so as to | ||||||
|  |         # be interchange-lib agnostic? | ||||||
|  |         # -[ ] the error type is wtv is raised from the hook so we | ||||||
|  |         #   could also require a type-class of errors for | ||||||
|  |         #   indicating whether the hook-failure can be handled by | ||||||
|  |         #   a nasty-dialog-unprot sub-sys? | ||||||
|  |         except ValidationError: | ||||||
|  | 
 | ||||||
|  |             # NOTE: only in the `Any` spec case do we expect this to | ||||||
|  |             # work since otherwise no spec covers a plain-ol' | ||||||
|  |             # `.pld: str` | ||||||
|  |             if pld_spec_str == 'Any': | ||||||
|  |                 raise | ||||||
|  |             else: | ||||||
|  |                 print( | ||||||
|  |                     f'{uid}: (correctly) unable to DECODE `Started`-bytes\n' | ||||||
|  |                     f'{started_msg_bytes}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         iter_send_val_items = iter(expect_ipc_send.values()) | ||||||
|  |         sent: list[Any] = [] | ||||||
|  |         for send_value, expect_send in iter_send_val_items: | ||||||
|  |             try: | ||||||
|  |                 print( | ||||||
|  |                     f'{uid}: attempting to `.started({send_value})`\n' | ||||||
|  |                     f'=> expect_send: {expect_send}\n' | ||||||
|  |                     f'SINCE, ipc_pld_spec: {ipc_pld_spec}\n' | ||||||
|  |                     f'AND, codec: {codec}\n' | ||||||
|  |                 ) | ||||||
|  |                 await ctx.started(send_value) | ||||||
|  |                 sent.append(send_value) | ||||||
|  |                 if not expect_send: | ||||||
|  | 
 | ||||||
|  |                     # XXX NOTE XXX THIS WON'T WORK WITHOUT SPECIAL | ||||||
|  |                     # `str` handling! or special debug mode IPC | ||||||
|  |                     # msgs! | ||||||
|  |                     await tractor.pause() | ||||||
|  | 
 | ||||||
|  |                     raise RuntimeError( | ||||||
|  |                         f'NOT-EXPECTED able to roundtrip value given spec:\n' | ||||||
|  |                         f'ipc_pld_spec -> {ipc_pld_spec}\n' | ||||||
|  |                         f'value -> {send_value}: {type(send_value)}\n' | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                 break  # move on to streaming block.. | ||||||
|  | 
 | ||||||
|  |             except tractor.MsgTypeError: | ||||||
|  |                 await tractor.pause() | ||||||
|  | 
 | ||||||
|  |                 if expect_send: | ||||||
|  |                     raise RuntimeError( | ||||||
|  |                         f'EXPECTED to `.started()` value given spec:\n' | ||||||
|  |                         f'ipc_pld_spec -> {ipc_pld_spec}\n' | ||||||
|  |                         f'value -> {send_value}: {type(send_value)}\n' | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |         async with ctx.open_stream() as ipc: | ||||||
|  |             print( | ||||||
|  |                 f'{uid}: Entering streaming block to send remaining values..' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             for send_value, expect_send in iter_send_val_items: | ||||||
|  |                 send_type: Type = type(send_value) | ||||||
|  |                 print( | ||||||
|  |                     '------ - ------\n' | ||||||
|  |                     f'{uid}: SENDING NEXT VALUE\n' | ||||||
|  |                     f'ipc_pld_spec: {ipc_pld_spec}\n' | ||||||
|  |                     f'expect_send: {expect_send}\n' | ||||||
|  |                     f'val: {send_value}\n' | ||||||
|  |                     '------ - ------\n' | ||||||
|  |                 ) | ||||||
|  |                 try: | ||||||
|  |                     await ipc.send(send_value) | ||||||
|  |                     print(f'***\n{uid}-CHILD sent {send_value!r}\n***\n') | ||||||
|  |                     sent.append(send_value) | ||||||
|  | 
 | ||||||
|  |                     # NOTE: should only raise above on | ||||||
|  |                     # `.started()` or a `Return` | ||||||
|  |                     # if not expect_send: | ||||||
|  |                     #     raise RuntimeError( | ||||||
|  |                     #         f'NOT-EXPECTED able to roundtrip value given spec:\n' | ||||||
|  |                     #         f'ipc_pld_spec -> {ipc_pld_spec}\n' | ||||||
|  |                     #         f'value -> {send_value}: {send_type}\n' | ||||||
|  |                     #     ) | ||||||
|  | 
 | ||||||
|  |                 except ValidationError: | ||||||
|  |                     print(f'{uid} FAILED TO SEND {send_value}!') | ||||||
|  | 
 | ||||||
|  |                     # await tractor.pause() | ||||||
|  |                     if expect_send: | ||||||
|  |                         raise RuntimeError( | ||||||
|  |                             f'EXPECTED to roundtrip value given spec:\n' | ||||||
|  |                             f'ipc_pld_spec -> {ipc_pld_spec}\n' | ||||||
|  |                             f'value -> {send_value}: {send_type}\n' | ||||||
|  |                         ) | ||||||
|  |                     # continue | ||||||
|  | 
 | ||||||
|  |             else: | ||||||
|  |                 print( | ||||||
|  |                     f'{uid}: finished sending all values\n' | ||||||
|  |                     'Should be exiting stream block!\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         print(f'{uid}: exited streaming block!') | ||||||
|  | 
 | ||||||
|  |         # TODO: this won't be true bc in streaming phase we DO NOT | ||||||
|  |         # msgspec check outbound msgs! | ||||||
|  |         # -[ ] once we implement the receiver side `InvalidMsg` | ||||||
|  |         #   then we can expect it here? | ||||||
|  |         # assert ( | ||||||
|  |         #     len(sent) | ||||||
|  |         #     == | ||||||
|  |         #     len([val | ||||||
|  |         #          for val, expect in | ||||||
|  |         #          expect_ipc_send.values() | ||||||
|  |         #          if expect is True]) | ||||||
|  |         # ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def ex_func(*args): | ||||||
|  |     print(f'ex_func({args})') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'ipc_pld_spec', | ||||||
|  |     [ | ||||||
|  |         Any, | ||||||
|  |         NamespacePath, | ||||||
|  |         NamespacePath|None,  # the "maybe" spec Bo | ||||||
|  |     ], | ||||||
|  |     ids=[ | ||||||
|  |         'any_type', | ||||||
|  |         'nsp_type', | ||||||
|  |         'maybe_nsp_type', | ||||||
|  |     ] | ||||||
|  | ) | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'add_codec_hooks', | ||||||
|  |     [ | ||||||
|  |         True, | ||||||
|  |         False, | ||||||
|  |     ], | ||||||
|  |     ids=['use_codec_hooks', 'no_codec_hooks'], | ||||||
|  | ) | ||||||
|  | def test_codec_hooks_mod( | ||||||
|  |     debug_mode: bool, | ||||||
|  |     ipc_pld_spec: Union[Type]|Any, | ||||||
|  |     # send_value: None|str|NamespacePath, | ||||||
|  |     add_codec_hooks: bool, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Audit the `.msg.MsgCodec` override apis details given our impl | ||||||
|  |     uses `contextvars` to accomplish per `trio` task codec | ||||||
|  |     application around an inter-proc-task-comms context. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     async def main(): | ||||||
|  |         nsp = NamespacePath.from_ref(ex_func) | ||||||
|  |         send_items: dict[Union, Any] = { | ||||||
|  |             Union[None]: None, | ||||||
|  |             Union[NamespacePath]: nsp, | ||||||
|  |             Union[str]: str(nsp), | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         # init default state for actor | ||||||
|  |         chk_codec_applied( | ||||||
|  |             expect_codec=_codec._def_tractor_codec, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         async with tractor.open_nursery( | ||||||
|  |             debug_mode=debug_mode, | ||||||
|  |         ) as an: | ||||||
|  |             p: tractor.Portal = await an.start_actor( | ||||||
|  |                 'sub', | ||||||
|  |                 enable_modules=[__name__], | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             # TODO: 2 cases: | ||||||
|  |             # - codec not modified -> decode nsp as `str` | ||||||
|  |             # - codec modified with hooks -> decode nsp as | ||||||
|  |             #   `NamespacePath` | ||||||
|  |             nsp_codec: MsgCodec = mk_custom_codec( | ||||||
|  |                 pld_spec=ipc_pld_spec, | ||||||
|  |                 add_hooks=add_codec_hooks, | ||||||
|  |             ) | ||||||
|  |             with apply_codec(nsp_codec) as codec: | ||||||
|  |                 chk_codec_applied( | ||||||
|  |                     expect_codec=nsp_codec, | ||||||
|  |                     enter_value=codec, | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |                 expect_ipc_send: dict[str, tuple[Any, bool]] = {} | ||||||
|  | 
 | ||||||
|  |                 report: str = ( | ||||||
|  |                     'Parent report on send values with\n' | ||||||
|  |                     f'ipc_pld_spec: {ipc_pld_spec}\n' | ||||||
|  |                     '       ------ - ------\n' | ||||||
|  |                 ) | ||||||
|  |                 for val_type_str, val, expect_send in iter_maybe_sends( | ||||||
|  |                     send_items, | ||||||
|  |                     ipc_pld_spec, | ||||||
|  |                     add_codec_hooks=add_codec_hooks, | ||||||
|  |                 ): | ||||||
|  |                     report += ( | ||||||
|  |                         f'send_value: {val}: {type(val)} ' | ||||||
|  |                         f'=> expect_send: {expect_send}\n' | ||||||
|  |                     ) | ||||||
|  |                     expect_ipc_send[val_type_str] = (val, expect_send) | ||||||
|  | 
 | ||||||
|  |                 print( | ||||||
|  |                     report + | ||||||
|  |                     '       ------ - ------\n' | ||||||
|  |                 ) | ||||||
|  |                 assert len(expect_ipc_send) == len(send_items) | ||||||
|  |                 # now try over real IPC with a the subactor | ||||||
|  |                 # expect_ipc_rountrip: bool = True | ||||||
|  |                 expected_started = Started( | ||||||
|  |                     cid='cid', | ||||||
|  |                     pld=str(ipc_pld_spec), | ||||||
|  |                 ) | ||||||
|  |                 # build list of values we expect to receive from | ||||||
|  |                 # the subactor. | ||||||
|  |                 expect_to_send: list[Any] = [ | ||||||
|  |                     val | ||||||
|  |                     for val, expect_send in expect_ipc_send.values() | ||||||
|  |                     if expect_send | ||||||
|  |                 ] | ||||||
|  | 
 | ||||||
|  |                 pld_spec_type_strs: list[str] = enc_type_union(ipc_pld_spec) | ||||||
|  | 
 | ||||||
|  |                 # XXX should raise an mte (`MsgTypeError`) | ||||||
|  |                 # when `add_codec_hooks == False` bc the input | ||||||
|  |                 # `expect_ipc_send` kwarg has a nsp which can't be | ||||||
|  |                 # serialized! | ||||||
|  |                 # | ||||||
|  |                 # TODO:can we ensure this happens from the | ||||||
|  |                 # `Return`-side (aka the sub) as well? | ||||||
|  |                 if not add_codec_hooks: | ||||||
|  |                     try: | ||||||
|  |                         async with p.open_context( | ||||||
|  |                             send_back_values, | ||||||
|  |                             expect_debug=debug_mode, | ||||||
|  |                             pld_spec_type_strs=pld_spec_type_strs, | ||||||
|  |                             add_hooks=add_codec_hooks, | ||||||
|  |                             started_msg_bytes=nsp_codec.encode(expected_started), | ||||||
|  | 
 | ||||||
|  |                             # XXX NOTE bc we send a `NamespacePath` in this kwarg | ||||||
|  |                             expect_ipc_send=expect_ipc_send, | ||||||
|  | 
 | ||||||
|  |                         ) as (ctx, first): | ||||||
|  |                             pytest.fail('ctx should fail to open without custom enc_hook!?') | ||||||
|  | 
 | ||||||
|  |                     # this test passes bc we can go no further! | ||||||
|  |                     except MsgTypeError: | ||||||
|  |                         # teardown nursery | ||||||
|  |                         await p.cancel_actor() | ||||||
|  |                         return | ||||||
|  | 
 | ||||||
|  |                 # TODO: send the original nsp here and | ||||||
|  |                 # test with `limit_msg_spec()` above? | ||||||
|  |                 # await tractor.pause() | ||||||
|  |                 print('PARENT opening IPC ctx!\n') | ||||||
|  |                 async with ( | ||||||
|  | 
 | ||||||
|  |                     # XXX should raise an mte (`MsgTypeError`) | ||||||
|  |                     # when `add_codec_hooks == False`.. | ||||||
|  |                     p.open_context( | ||||||
|  |                         send_back_values, | ||||||
|  |                         expect_debug=debug_mode, | ||||||
|  |                         pld_spec_type_strs=pld_spec_type_strs, | ||||||
|  |                         add_hooks=add_codec_hooks, | ||||||
|  |                         started_msg_bytes=nsp_codec.encode(expected_started), | ||||||
|  |                         expect_ipc_send=expect_ipc_send, | ||||||
|  |                     ) as (ctx, first), | ||||||
|  | 
 | ||||||
|  |                     ctx.open_stream() as ipc, | ||||||
|  |                 ): | ||||||
|  |                     # ensure codec is still applied across | ||||||
|  |                     # `tractor.Context` + its embedded nursery. | ||||||
|  |                     chk_codec_applied( | ||||||
|  |                         expect_codec=nsp_codec, | ||||||
|  |                         enter_value=codec, | ||||||
|  |                     ) | ||||||
|  |                     print( | ||||||
|  |                         'root: ENTERING CONTEXT BLOCK\n' | ||||||
|  |                         f'type(first): {type(first)}\n' | ||||||
|  |                         f'first: {first}\n' | ||||||
|  |                     ) | ||||||
|  |                     expect_to_send.remove(first) | ||||||
|  | 
 | ||||||
|  |                     # TODO: explicit values we expect depending on | ||||||
|  |                     # codec config! | ||||||
|  |                     # assert first == first_val | ||||||
|  |                     # assert first == f'{__name__}:ex_func' | ||||||
|  | 
 | ||||||
|  |                     async for next_sent in ipc: | ||||||
|  |                         print( | ||||||
|  |                             'Parent: child sent next value\n' | ||||||
|  |                             f'{next_sent}: {type(next_sent)}\n' | ||||||
|  |                         ) | ||||||
|  |                         if expect_to_send: | ||||||
|  |                             expect_to_send.remove(next_sent) | ||||||
|  |                         else: | ||||||
|  |                             print('PARENT should terminate stream loop + block!') | ||||||
|  | 
 | ||||||
|  |                     # all sent values should have arrived! | ||||||
|  |                     assert not expect_to_send | ||||||
|  | 
 | ||||||
|  |             await p.cancel_actor() | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def chk_pld_type( | ||||||
|  |     payload_spec: Type[Struct]|Any, | ||||||
|  |     pld: Any, | ||||||
|  | 
 | ||||||
|  |     expect_roundtrip: bool|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> bool: | ||||||
|  | 
 | ||||||
|  |     pld_val_type: Type = type(pld) | ||||||
|  | 
 | ||||||
|  |     # TODO: verify that the overridden subtypes | ||||||
|  |     # DO NOT have modified type-annots from original! | ||||||
|  |     # 'Start',  .pld: FuncSpec | ||||||
|  |     # 'StartAck',  .pld: IpcCtxSpec | ||||||
|  |     # 'Stop',  .pld: UNSEt | ||||||
|  |     # 'Error',  .pld: ErrorData | ||||||
|  | 
 | ||||||
|  |     codec: MsgCodec = mk_codec( | ||||||
|  |         # NOTE: this ONLY accepts `PayloadMsg.pld` fields of a specified | ||||||
|  |         # type union. | ||||||
|  |         ipc_pld_spec=payload_spec, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # make a one-off dec to compare with our `MsgCodec` instance | ||||||
|  |     # which does the below `mk_msg_spec()` call internally | ||||||
|  |     ipc_msg_spec: Union[Type[Struct]] | ||||||
|  |     msg_types: list[PayloadMsg[payload_spec]] | ||||||
|  |     ( | ||||||
|  |         ipc_msg_spec, | ||||||
|  |         msg_types, | ||||||
|  |     ) = mk_msg_spec( | ||||||
|  |         payload_type_union=payload_spec, | ||||||
|  |     ) | ||||||
|  |     _enc = msgpack.Encoder() | ||||||
|  |     _dec = msgpack.Decoder( | ||||||
|  |         type=ipc_msg_spec or Any,  # like `PayloadMsg[Any]` | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     assert ( | ||||||
|  |         payload_spec | ||||||
|  |         == | ||||||
|  |         codec.pld_spec | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # assert codec.dec == dec | ||||||
|  |     # | ||||||
|  |     # ^-XXX-^ not sure why these aren't "equal" but when cast | ||||||
|  |     # to `str` they seem to match ?? .. kk | ||||||
|  | 
 | ||||||
|  |     assert ( | ||||||
|  |         str(ipc_msg_spec) | ||||||
|  |         == | ||||||
|  |         str(codec.msg_spec) | ||||||
|  |         == | ||||||
|  |         str(_dec.type) | ||||||
|  |         == | ||||||
|  |         str(codec.dec.type) | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # verify the boxed-type for all variable payload-type msgs. | ||||||
|  |     if not msg_types: | ||||||
|  |         breakpoint() | ||||||
|  | 
 | ||||||
|  |     roundtrip: bool|None = None | ||||||
|  |     pld_spec_msg_names: list[str] = [ | ||||||
|  |         td.__name__ for td in _payload_msgs | ||||||
|  |     ] | ||||||
|  |     for typedef in msg_types: | ||||||
|  | 
 | ||||||
|  |         skip_runtime_msg: bool = typedef.__name__ not in pld_spec_msg_names | ||||||
|  |         if skip_runtime_msg: | ||||||
|  |             continue | ||||||
|  | 
 | ||||||
|  |         pld_field = structs.fields(typedef)[1] | ||||||
|  |         assert pld_field.type is payload_spec # TODO-^ does this need to work to get all subtypes to adhere? | ||||||
|  | 
 | ||||||
|  |         kwargs: dict[str, Any] = { | ||||||
|  |             'cid': '666', | ||||||
|  |             'pld': pld, | ||||||
|  |         } | ||||||
|  |         enc_msg: PayloadMsg = typedef(**kwargs) | ||||||
|  | 
 | ||||||
|  |         _wire_bytes: bytes = _enc.encode(enc_msg) | ||||||
|  |         wire_bytes: bytes = codec.enc.encode(enc_msg) | ||||||
|  |         assert _wire_bytes == wire_bytes | ||||||
|  | 
 | ||||||
|  |         ve: ValidationError|None = None | ||||||
|  |         try: | ||||||
|  |             dec_msg = codec.dec.decode(wire_bytes) | ||||||
|  |             _dec_msg = _dec.decode(wire_bytes) | ||||||
|  | 
 | ||||||
|  |             # decoded msg and thus payload should be exactly same! | ||||||
|  |             assert (roundtrip := ( | ||||||
|  |                 _dec_msg | ||||||
|  |                 == | ||||||
|  |                 dec_msg | ||||||
|  |                 == | ||||||
|  |                 enc_msg | ||||||
|  |             )) | ||||||
|  | 
 | ||||||
|  |             if ( | ||||||
|  |                 expect_roundtrip is not None | ||||||
|  |                 and expect_roundtrip != roundtrip | ||||||
|  |             ): | ||||||
|  |                 breakpoint() | ||||||
|  | 
 | ||||||
|  |             assert ( | ||||||
|  |                 pld | ||||||
|  |                 == | ||||||
|  |                 dec_msg.pld | ||||||
|  |                 == | ||||||
|  |                 enc_msg.pld | ||||||
|  |             ) | ||||||
|  |             # assert (roundtrip := (_dec_msg == enc_msg)) | ||||||
|  | 
 | ||||||
|  |         except ValidationError as _ve: | ||||||
|  |             ve = _ve | ||||||
|  |             roundtrip: bool = False | ||||||
|  |             if pld_val_type is payload_spec: | ||||||
|  |                 raise ValueError( | ||||||
|  |                    'Got `ValidationError` despite type-var match!?\n' | ||||||
|  |                     f'pld_val_type: {pld_val_type}\n' | ||||||
|  |                     f'payload_type: {payload_spec}\n' | ||||||
|  |                 ) from ve | ||||||
|  | 
 | ||||||
|  |             else: | ||||||
|  |                 # ow we good cuz the pld spec mismatched. | ||||||
|  |                 print( | ||||||
|  |                     'Got expected `ValidationError` since,\n' | ||||||
|  |                     f'{pld_val_type} is not {payload_spec}\n' | ||||||
|  |                 ) | ||||||
|  |         else: | ||||||
|  |             if ( | ||||||
|  |                 payload_spec is not Any | ||||||
|  |                 and | ||||||
|  |                 pld_val_type is not payload_spec | ||||||
|  |             ): | ||||||
|  |                 raise ValueError( | ||||||
|  |                    'DID NOT `ValidationError` despite expected type match!?\n' | ||||||
|  |                     f'pld_val_type: {pld_val_type}\n' | ||||||
|  |                     f'payload_type: {payload_spec}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |     # full code decode should always be attempted! | ||||||
|  |     if roundtrip is None: | ||||||
|  |         breakpoint() | ||||||
|  | 
 | ||||||
|  |     return roundtrip | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_limit_msgspec(): | ||||||
|  | 
 | ||||||
|  |     async def main(): | ||||||
|  |         async with tractor.open_root_actor( | ||||||
|  |             debug_mode=True | ||||||
|  |         ): | ||||||
|  | 
 | ||||||
|  |             # ensure we can round-trip a boxing `PayloadMsg` | ||||||
|  |             assert chk_pld_type( | ||||||
|  |                 payload_spec=Any, | ||||||
|  |                 pld=None, | ||||||
|  |                 expect_roundtrip=True, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             # verify that a mis-typed payload value won't decode | ||||||
|  |             assert not chk_pld_type( | ||||||
|  |                 payload_spec=int, | ||||||
|  |                 pld='doggy', | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             # parametrize the boxed `.pld` type as a custom-struct | ||||||
|  |             # and ensure that parametrization propagates | ||||||
|  |             # to all payload-msg-spec-able subtypes! | ||||||
|  |             class CustomPayload(Struct): | ||||||
|  |                 name: str | ||||||
|  |                 value: Any | ||||||
|  | 
 | ||||||
|  |             assert not chk_pld_type( | ||||||
|  |                 payload_spec=CustomPayload, | ||||||
|  |                 pld='doggy', | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             assert chk_pld_type( | ||||||
|  |                 payload_spec=CustomPayload, | ||||||
|  |                 pld=CustomPayload(name='doggy', value='urmom') | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             # yah, we can `.pause_from_sync()` now! | ||||||
|  |             # breakpoint() | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | @ -6,14 +6,15 @@ sub-sub-actor daemons. | ||||||
| ''' | ''' | ||||||
| from typing import Optional | from typing import Optional | ||||||
| import asyncio | import asyncio | ||||||
| from contextlib import asynccontextmanager as acm | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  |     aclosing, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| import trio | import trio | ||||||
| from trio_typing import TaskStatus |  | ||||||
| import tractor | import tractor | ||||||
| from tractor import RemoteActorError | from tractor import RemoteActorError | ||||||
| from async_generator import aclosing |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def aio_streamer( | async def aio_streamer( | ||||||
|  | @ -141,7 +142,7 @@ async def open_actor_local_nursery( | ||||||
| ) | ) | ||||||
| def test_actor_managed_trio_nursery_task_error_cancels_aio( | def test_actor_managed_trio_nursery_task_error_cancels_aio( | ||||||
|     asyncio_mode: bool, |     asyncio_mode: bool, | ||||||
|     arb_addr |     reg_addr: tuple, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     Verify that a ``trio`` nursery created managed in a child actor |     Verify that a ``trio`` nursery created managed in a child actor | ||||||
|  | @ -170,4 +171,4 @@ def test_actor_managed_trio_nursery_task_error_cancels_aio( | ||||||
| 
 | 
 | ||||||
|     # verify boxed error |     # verify boxed error | ||||||
|     err = excinfo.value |     err = excinfo.value | ||||||
|     assert isinstance(err.type(), NameError) |     assert err.boxed_type is NameError | ||||||
|  |  | ||||||
|  | @ -5,9 +5,7 @@ import trio | ||||||
| import tractor | import tractor | ||||||
| from tractor import open_actor_cluster | from tractor import open_actor_cluster | ||||||
| from tractor.trionics import gather_contexts | from tractor.trionics import gather_contexts | ||||||
| 
 | from tractor._testing import tractor_test | ||||||
| from conftest import tractor_test |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| MESSAGE = 'tractoring at full speed' | MESSAGE = 'tractoring at full speed' | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,24 +1,36 @@ | ||||||
| ''' | ''' | ||||||
| ``async with ():`` inlined context-stream cancellation testing. | ``async with ():`` inlined context-stream cancellation testing. | ||||||
| 
 | 
 | ||||||
| Verify the we raise errors when streams are opened prior to sync-opening | Verify the we raise errors when streams are opened prior to | ||||||
| a ``tractor.Context`` beforehand. | sync-opening a ``tractor.Context`` beforehand. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| from contextlib import asynccontextmanager as acm |  | ||||||
| from itertools import count | from itertools import count | ||||||
|  | import math | ||||||
| import platform | import platform | ||||||
| from typing import Optional | from pprint import pformat | ||||||
|  | from typing import ( | ||||||
|  |     Callable, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
|  | from tractor import ( | ||||||
|  |     Actor, | ||||||
|  |     Context, | ||||||
|  |     current_actor, | ||||||
|  | ) | ||||||
| from tractor._exceptions import ( | from tractor._exceptions import ( | ||||||
|     StreamOverrun, |     StreamOverrun, | ||||||
|     ContextCancelled, |     ContextCancelled, | ||||||
| ) | ) | ||||||
|  | from tractor._state import current_ipc_ctx | ||||||
| 
 | 
 | ||||||
| from conftest import tractor_test | from tractor._testing import ( | ||||||
|  |     tractor_test, | ||||||
|  |     expect_ctxc, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| # ``Context`` semantics are as follows, | # ``Context`` semantics are as follows, | ||||||
| #  ------------------------------------ | #  ------------------------------------ | ||||||
|  | @ -64,7 +76,7 @@ _state: bool = False | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def too_many_starteds( | async def too_many_starteds( | ||||||
|     ctx: tractor.Context, |     ctx: Context, | ||||||
| ) -> None: | ) -> None: | ||||||
|     ''' |     ''' | ||||||
|     Call ``Context.started()`` more then once (an error). |     Call ``Context.started()`` more then once (an error). | ||||||
|  | @ -79,7 +91,7 @@ async def too_many_starteds( | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def not_started_but_stream_opened( | async def not_started_but_stream_opened( | ||||||
|     ctx: tractor.Context, |     ctx: Context, | ||||||
| ) -> None: | ) -> None: | ||||||
|     ''' |     ''' | ||||||
|     Enter ``Context.open_stream()`` without calling ``.started()``. |     Enter ``Context.open_stream()`` without calling ``.started()``. | ||||||
|  | @ -100,11 +112,15 @@ async def not_started_but_stream_opened( | ||||||
|     ], |     ], | ||||||
|     ids='misuse_type={}'.format, |     ids='misuse_type={}'.format, | ||||||
| ) | ) | ||||||
| def test_started_misuse(target): | def test_started_misuse( | ||||||
| 
 |     target: Callable, | ||||||
|  |     debug_mode: bool, | ||||||
|  | ): | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery() as n: |         async with tractor.open_nursery( | ||||||
|             portal = await n.start_actor( |             debug_mode=debug_mode, | ||||||
|  |         ) as an: | ||||||
|  |             portal = await an.start_actor( | ||||||
|                 target.__name__, |                 target.__name__, | ||||||
|                 enable_modules=[__name__], |                 enable_modules=[__name__], | ||||||
|             ) |             ) | ||||||
|  | @ -119,7 +135,7 @@ def test_started_misuse(target): | ||||||
| @tractor.context | @tractor.context | ||||||
| async def simple_setup_teardown( | async def simple_setup_teardown( | ||||||
| 
 | 
 | ||||||
|     ctx: tractor.Context, |     ctx: Context, | ||||||
|     data: int, |     data: int, | ||||||
|     block_forever: bool = False, |     block_forever: bool = False, | ||||||
| 
 | 
 | ||||||
|  | @ -129,6 +145,8 @@ async def simple_setup_teardown( | ||||||
|     global _state |     global _state | ||||||
|     _state = True |     _state = True | ||||||
| 
 | 
 | ||||||
|  |     assert current_ipc_ctx() is ctx | ||||||
|  | 
 | ||||||
|     # signal to parent that we're up |     # signal to parent that we're up | ||||||
|     await ctx.started(data + 1) |     await ctx.started(data + 1) | ||||||
| 
 | 
 | ||||||
|  | @ -165,6 +183,7 @@ def test_simple_context( | ||||||
|     error_parent, |     error_parent, | ||||||
|     callee_blocks_forever, |     callee_blocks_forever, | ||||||
|     pointlessly_open_stream, |     pointlessly_open_stream, | ||||||
|  |     debug_mode: bool, | ||||||
| ): | ): | ||||||
| 
 | 
 | ||||||
|     timeout = 1.5 if not platform.system() == 'Windows' else 4 |     timeout = 1.5 if not platform.system() == 'Windows' else 4 | ||||||
|  | @ -172,20 +191,23 @@ def test_simple_context( | ||||||
|     async def main(): |     async def main(): | ||||||
| 
 | 
 | ||||||
|         with trio.fail_after(timeout): |         with trio.fail_after(timeout): | ||||||
|             async with tractor.open_nursery() as nursery: |             async with tractor.open_nursery( | ||||||
| 
 |                 debug_mode=debug_mode, | ||||||
|                 portal = await nursery.start_actor( |             ) as an: | ||||||
|  |                 portal = await an.start_actor( | ||||||
|                     'simple_context', |                     'simple_context', | ||||||
|                     enable_modules=[__name__], |                     enable_modules=[__name__], | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|                 try: |                 try: | ||||||
|                     async with portal.open_context( |                     async with ( | ||||||
|  |                         portal.open_context( | ||||||
|                             simple_setup_teardown, |                             simple_setup_teardown, | ||||||
|                             data=10, |                             data=10, | ||||||
|                             block_forever=callee_blocks_forever, |                             block_forever=callee_blocks_forever, | ||||||
|                     ) as (ctx, sent): |                         ) as (ctx, sent), | ||||||
| 
 |                     ): | ||||||
|  |                         assert current_ipc_ctx() is ctx | ||||||
|                         assert sent == 11 |                         assert sent == 11 | ||||||
| 
 | 
 | ||||||
|                         if callee_blocks_forever: |                         if callee_blocks_forever: | ||||||
|  | @ -193,9 +215,6 @@ def test_simple_context( | ||||||
|                         else: |                         else: | ||||||
|                             assert await ctx.result() == 'yo' |                             assert await ctx.result() == 'yo' | ||||||
| 
 | 
 | ||||||
|                         if not error_parent: |  | ||||||
|                             await ctx.cancel() |  | ||||||
| 
 |  | ||||||
|                         if pointlessly_open_stream: |                         if pointlessly_open_stream: | ||||||
|                             async with ctx.open_stream(): |                             async with ctx.open_stream(): | ||||||
|                                 if error_parent: |                                 if error_parent: | ||||||
|  | @ -208,10 +227,15 @@ def test_simple_context( | ||||||
|                                     # 'stop' msg to the far end which needs |                                     # 'stop' msg to the far end which needs | ||||||
|                                     # to be ignored |                                     # to be ignored | ||||||
|                                     pass |                                     pass | ||||||
|  | 
 | ||||||
|                         else: |                         else: | ||||||
|                             if error_parent: |                             if error_parent: | ||||||
|                                 raise error_parent |                                 raise error_parent | ||||||
| 
 | 
 | ||||||
|  |                             # cancel AFTER we open a stream | ||||||
|  |                             # to avoid a cancel raised inside | ||||||
|  |                             # `.open_stream()` | ||||||
|  |                             await ctx.cancel() | ||||||
|                 finally: |                 finally: | ||||||
| 
 | 
 | ||||||
|                     # after cancellation |                     # after cancellation | ||||||
|  | @ -226,10 +250,10 @@ def test_simple_context( | ||||||
|             trio.run(main) |             trio.run(main) | ||||||
|         except error_parent: |         except error_parent: | ||||||
|             pass |             pass | ||||||
|         except trio.MultiError as me: |         except BaseExceptionGroup as beg: | ||||||
|             # XXX: on windows it seems we may have to expect the group error |             # XXX: on windows it seems we may have to expect the group error | ||||||
|             from tractor._exceptions import is_multi_cancelled |             from tractor._exceptions import is_multi_cancelled | ||||||
|             assert is_multi_cancelled(me) |             assert is_multi_cancelled(beg) | ||||||
|     else: |     else: | ||||||
|         trio.run(main) |         trio.run(main) | ||||||
| 
 | 
 | ||||||
|  | @ -253,6 +277,7 @@ def test_caller_cancels( | ||||||
|     cancel_method: str, |     cancel_method: str, | ||||||
|     chk_ctx_result_before_exit: bool, |     chk_ctx_result_before_exit: bool, | ||||||
|     callee_returns_early: bool, |     callee_returns_early: bool, | ||||||
|  |     debug_mode: bool, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     Verify that when the opening side of a context (aka the caller) |     Verify that when the opening side of a context (aka the caller) | ||||||
|  | @ -261,37 +286,100 @@ def test_caller_cancels( | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     async def check_canceller( |     async def check_canceller( | ||||||
|         ctx: tractor.Context, |         ctx: Context, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         # should not raise yet return the remote |         actor: Actor = current_actor() | ||||||
|         # context cancelled error. |         uid: tuple = actor.uid | ||||||
|         res = await ctx.result() |         _ctxc: ContextCancelled|None = None | ||||||
| 
 | 
 | ||||||
|  |         if ( | ||||||
|  |             cancel_method == 'portal' | ||||||
|  |             and not callee_returns_early | ||||||
|  |         ): | ||||||
|  |             try: | ||||||
|  |                 res = await ctx.result() | ||||||
|  |                 assert 0, 'Portal cancel should raise!' | ||||||
|  | 
 | ||||||
|  |             except ContextCancelled as ctxc: | ||||||
|  |                 # with trio.CancelScope(shield=True): | ||||||
|  |                 #     await tractor.pause() | ||||||
|  |                 _ctxc = ctxc | ||||||
|  |                 assert ctx.chan._cancel_called | ||||||
|  |                 assert ctxc.canceller == uid | ||||||
|  |                 assert ctxc is ctx.maybe_error | ||||||
|  | 
 | ||||||
|  |         # NOTE: should not ever raise even in the `ctx` | ||||||
|  |         # case since self-cancellation should swallow the ctxc | ||||||
|  |         # silently! | ||||||
|  |         else: | ||||||
|  |             try: | ||||||
|  |                 res = await ctx.result() | ||||||
|  |             except ContextCancelled as ctxc: | ||||||
|  |                 pytest.fail(f'should not have raised ctxc\n{ctxc}') | ||||||
|  | 
 | ||||||
|  |         # we actually get a result | ||||||
|         if callee_returns_early: |         if callee_returns_early: | ||||||
|             assert res == 'yo' |             assert res == 'yo' | ||||||
|  |             assert ctx.outcome is res | ||||||
|  |             assert ctx.maybe_error is None | ||||||
| 
 | 
 | ||||||
|         else: |         else: | ||||||
|             err = res |             err: Exception = ctx.outcome | ||||||
|             assert isinstance(err, ContextCancelled) |             assert isinstance(err, ContextCancelled) | ||||||
|             assert ( |             assert ( | ||||||
|                 tuple(err.canceller) |                 tuple(err.canceller) | ||||||
|                 == |                 == | ||||||
|                 tractor.current_actor().uid |                 uid | ||||||
|             ) |             ) | ||||||
|  |             assert ( | ||||||
|  |                 err | ||||||
|  |                 is ctx.maybe_error | ||||||
|  |                 is ctx._remote_error | ||||||
|  |             ) | ||||||
|  |             if le := ctx._local_error: | ||||||
|  |                 assert err is le | ||||||
|  | 
 | ||||||
|  |             # else: | ||||||
|  |                 # TODO: what should this be then? | ||||||
|  |                 # not defined until block closes right? | ||||||
|  |                 # | ||||||
|  |                 # await tractor.pause() | ||||||
|  |                 # assert ctx._local_error is None | ||||||
|  | 
 | ||||||
|  |         # TODO: don't need this right? | ||||||
|  |         # if _ctxc: | ||||||
|  |         #     raise _ctxc | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery() as nursery: | 
 | ||||||
|             portal = await nursery.start_actor( |         async with tractor.open_nursery( | ||||||
|  |             debug_mode=debug_mode, | ||||||
|  |         ) as an: | ||||||
|  |             portal = await an.start_actor( | ||||||
|                 'simple_context', |                 'simple_context', | ||||||
|                 enable_modules=[__name__], |                 enable_modules=[__name__], | ||||||
|             ) |             ) | ||||||
|             timeout = 0.5 if not callee_returns_early else 2 |             timeout: float = ( | ||||||
|  |                 0.5 | ||||||
|  |                 if not callee_returns_early | ||||||
|  |                 else 2 | ||||||
|  |             ) | ||||||
|             with trio.fail_after(timeout): |             with trio.fail_after(timeout): | ||||||
|                 async with portal.open_context( |                 async with ( | ||||||
|  |                     expect_ctxc( | ||||||
|  |                         yay=( | ||||||
|  |                             not callee_returns_early | ||||||
|  |                             and cancel_method == 'portal' | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  | 
 | ||||||
|  |                     portal.open_context( | ||||||
|                         simple_setup_teardown, |                         simple_setup_teardown, | ||||||
|                         data=10, |                         data=10, | ||||||
|                         block_forever=not callee_returns_early, |                         block_forever=not callee_returns_early, | ||||||
|                 ) as (ctx, sent): |                     ) as (ctx, sent), | ||||||
|  |                 ): | ||||||
| 
 | 
 | ||||||
|                     if callee_returns_early: |                     if callee_returns_early: | ||||||
|                         # ensure we block long enough before sending |                         # ensure we block long enough before sending | ||||||
|  | @ -300,10 +388,18 @@ def test_caller_cancels( | ||||||
|                         await trio.sleep(0.5) |                         await trio.sleep(0.5) | ||||||
| 
 | 
 | ||||||
|                     if cancel_method == 'ctx': |                     if cancel_method == 'ctx': | ||||||
|  |                         print('cancelling with `Context.cancel()`') | ||||||
|                         await ctx.cancel() |                         await ctx.cancel() | ||||||
|                     else: | 
 | ||||||
|  |                     elif cancel_method == 'portal': | ||||||
|  |                         print('cancelling with `Portal.cancel_actor()`') | ||||||
|                         await portal.cancel_actor() |                         await portal.cancel_actor() | ||||||
| 
 | 
 | ||||||
|  |                     else: | ||||||
|  |                         pytest.fail( | ||||||
|  |                             f'Unknown `cancel_method={cancel_method} ?' | ||||||
|  |                         ) | ||||||
|  | 
 | ||||||
|                     if chk_ctx_result_before_exit: |                     if chk_ctx_result_before_exit: | ||||||
|                         await check_canceller(ctx) |                         await check_canceller(ctx) | ||||||
| 
 | 
 | ||||||
|  | @ -313,6 +409,23 @@ def test_caller_cancels( | ||||||
|             if cancel_method != 'portal': |             if cancel_method != 'portal': | ||||||
|                 await portal.cancel_actor() |                 await portal.cancel_actor() | ||||||
| 
 | 
 | ||||||
|  |             # XXX NOTE XXX: non-normal yet purposeful | ||||||
|  |             # test-specific ctxc suppression is implemented! | ||||||
|  |             # | ||||||
|  |             # WHY: the `.cancel_actor()` case (cancel_method='portal') | ||||||
|  |             # will cause both: | ||||||
|  |             #  * the `ctx.result()` inside `.open_context().__aexit__()` | ||||||
|  |             #  * AND the `ctx.result()` inside `check_canceller()` | ||||||
|  |             # to raise ctxc. | ||||||
|  |             # | ||||||
|  |             #   which should in turn cause `ctx._scope` to | ||||||
|  |             # catch any cancellation? | ||||||
|  |             if ( | ||||||
|  |                 not callee_returns_early | ||||||
|  |                 and cancel_method != 'portal' | ||||||
|  |             ): | ||||||
|  |                 assert not ctx._scope.cancelled_caught | ||||||
|  | 
 | ||||||
|     trio.run(main) |     trio.run(main) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -331,7 +444,7 @@ def test_caller_cancels( | ||||||
| @tractor.context | @tractor.context | ||||||
| async def close_ctx_immediately( | async def close_ctx_immediately( | ||||||
| 
 | 
 | ||||||
|     ctx: tractor.Context, |     ctx: Context, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
| 
 | 
 | ||||||
|  | @ -343,17 +456,33 @@ async def close_ctx_immediately( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_callee_closes_ctx_after_stream_open(): | async def test_callee_closes_ctx_after_stream_open( | ||||||
|     'callee context closes without using stream' |     debug_mode: bool, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     callee context closes without using stream. | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_nursery() as n: |     This should result in a msg sequence | ||||||
|  |     |_<root>_ | ||||||
|  |              |_<fast_stream_closer> | ||||||
| 
 | 
 | ||||||
|         portal = await n.start_actor( |              <= {'started': <Any>, 'cid': <str>} | ||||||
|  |              <= {'stop': True, 'cid': <str>} | ||||||
|  |              <= {'result': Any, ..} | ||||||
|  | 
 | ||||||
|  |      (ignored by child) | ||||||
|  |     => {'stop': True, 'cid': <str>} | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     async with tractor.open_nursery( | ||||||
|  |         debug_mode=debug_mode, | ||||||
|  |     ) as an: | ||||||
|  |         portal = await an.start_actor( | ||||||
|             'fast_stream_closer', |             'fast_stream_closer', | ||||||
|             enable_modules=[__name__], |             enable_modules=[__name__], | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         with trio.fail_after(2): |         with trio.fail_after(0.5): | ||||||
|             async with portal.open_context( |             async with portal.open_context( | ||||||
|                 close_ctx_immediately, |                 close_ctx_immediately, | ||||||
| 
 | 
 | ||||||
|  | @ -361,10 +490,9 @@ async def test_callee_closes_ctx_after_stream_open(): | ||||||
|                 # cancel_on_exit=True, |                 # cancel_on_exit=True, | ||||||
| 
 | 
 | ||||||
|             ) as (ctx, sent): |             ) as (ctx, sent): | ||||||
| 
 |  | ||||||
|                 assert sent is None |                 assert sent is None | ||||||
| 
 | 
 | ||||||
|                 with trio.fail_after(0.5): |                 with trio.fail_after(0.4): | ||||||
|                     async with ctx.open_stream() as stream: |                     async with ctx.open_stream() as stream: | ||||||
| 
 | 
 | ||||||
|                         # should fall through since ``StopAsyncIteration`` |                         # should fall through since ``StopAsyncIteration`` | ||||||
|  | @ -372,11 +500,14 @@ async def test_callee_closes_ctx_after_stream_open(): | ||||||
|                         # a ``trio.EndOfChannel`` by |                         # a ``trio.EndOfChannel`` by | ||||||
|                         # ``trio.abc.ReceiveChannel.__anext__()`` |                         # ``trio.abc.ReceiveChannel.__anext__()`` | ||||||
|                         async for _ in stream: |                         async for _ in stream: | ||||||
|  |                             # trigger failure if we DO NOT | ||||||
|  |                             # get an EOC! | ||||||
|                             assert 0 |                             assert 0 | ||||||
|                         else: |                         else: | ||||||
| 
 | 
 | ||||||
|                             # verify stream is now closed |                             # verify stream is now closed | ||||||
|                             try: |                             try: | ||||||
|  |                                 with trio.fail_after(0.3): | ||||||
|                                     await stream.receive() |                                     await stream.receive() | ||||||
|                             except trio.EndOfChannel: |                             except trio.EndOfChannel: | ||||||
|                                 pass |                                 pass | ||||||
|  | @ -397,8 +528,7 @@ async def test_callee_closes_ctx_after_stream_open(): | ||||||
| 
 | 
 | ||||||
| @tractor.context | @tractor.context | ||||||
| async def expect_cancelled( | async def expect_cancelled( | ||||||
| 
 |     ctx: Context, | ||||||
|     ctx: tractor.Context, |  | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     global _state |     global _state | ||||||
|  | @ -412,12 +542,29 @@ async def expect_cancelled( | ||||||
|                 await stream.send(msg)  # echo server |                 await stream.send(msg)  # echo server | ||||||
| 
 | 
 | ||||||
|     except trio.Cancelled: |     except trio.Cancelled: | ||||||
|  | 
 | ||||||
|  |         # on ctx.cancel() the internal RPC scope is cancelled but | ||||||
|  |         # never caught until the func exits. | ||||||
|  |         assert ctx._scope.cancel_called | ||||||
|  |         assert not ctx._scope.cancelled_caught | ||||||
|  | 
 | ||||||
|  |         # should be the RPC cmd request for `._cancel_task()` | ||||||
|  |         assert ctx._cancel_msg | ||||||
|  |         # which, has not yet resolved to an error outcome | ||||||
|  |         # since this rpc func has not yet exited. | ||||||
|  |         assert not ctx.maybe_error | ||||||
|  |         assert not ctx._final_result_is_set() | ||||||
|  | 
 | ||||||
|  |         # debug REPL if needed | ||||||
|  |         # with trio.CancelScope(shield=True): | ||||||
|  |         #     await tractor.pause() | ||||||
|  | 
 | ||||||
|         # expected case |         # expected case | ||||||
|         _state = False |         _state = False | ||||||
|         raise |         raise | ||||||
| 
 | 
 | ||||||
|     else: |     else: | ||||||
|         assert 0, "Wasn't cancelled!?" |         assert 0, "callee wasn't cancelled !?" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|  | @ -427,12 +574,18 @@ async def expect_cancelled( | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_caller_closes_ctx_after_callee_opens_stream( | async def test_caller_closes_ctx_after_callee_opens_stream( | ||||||
|     use_ctx_cancel_method: bool, |     use_ctx_cancel_method: bool, | ||||||
|  |     debug_mode: bool, | ||||||
| ): | ): | ||||||
|     'caller context closes without using stream' |     ''' | ||||||
|  |     caller context closes without using/opening stream | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_nursery() as n: |     ''' | ||||||
|  |     async with tractor.open_nursery( | ||||||
|  |         debug_mode=debug_mode, | ||||||
|  |     ) as an: | ||||||
| 
 | 
 | ||||||
|         portal = await n.start_actor( |         root: Actor = current_actor() | ||||||
|  |         portal = await an.start_actor( | ||||||
|             'ctx_cancelled', |             'ctx_cancelled', | ||||||
|             enable_modules=[__name__], |             enable_modules=[__name__], | ||||||
|         ) |         ) | ||||||
|  | @ -440,22 +593,37 @@ async def test_caller_closes_ctx_after_callee_opens_stream( | ||||||
|         async with portal.open_context( |         async with portal.open_context( | ||||||
|             expect_cancelled, |             expect_cancelled, | ||||||
|         ) as (ctx, sent): |         ) as (ctx, sent): | ||||||
|             await portal.run(assert_state, value=True) |  | ||||||
| 
 |  | ||||||
|             assert sent is None |             assert sent is None | ||||||
| 
 | 
 | ||||||
|             # call cancel explicitly |             await portal.run(assert_state, value=True) | ||||||
|             if use_ctx_cancel_method: |  | ||||||
| 
 | 
 | ||||||
|  |             # call `ctx.cancel()` explicitly | ||||||
|  |             if use_ctx_cancel_method: | ||||||
|                 await ctx.cancel() |                 await ctx.cancel() | ||||||
| 
 | 
 | ||||||
|  |                 # NOTE: means the local side `ctx._scope` will | ||||||
|  |                 # have been cancelled by an ctxc ack and thus | ||||||
|  |                 # `._scope.cancelled_caught` should be set. | ||||||
|                 try: |                 try: | ||||||
|                     async with ctx.open_stream() as stream: |                     async with ctx.open_stream() as stream: | ||||||
|                         async for msg in stream: |                         async for msg in stream: | ||||||
|                             pass |                             pass | ||||||
| 
 | 
 | ||||||
|                 except tractor.ContextCancelled: |                 except tractor.ContextCancelled as ctxc: | ||||||
|                     raise  # XXX: must be propagated to __aexit__ |                     # XXX: the cause is US since we call | ||||||
|  |                     # `Context.cancel()` just above! | ||||||
|  |                     assert ( | ||||||
|  |                         ctxc.canceller | ||||||
|  |                         == | ||||||
|  |                         current_actor().uid | ||||||
|  |                         == | ||||||
|  |                         root.uid | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                     # XXX: must be propagated to __aexit__ | ||||||
|  |                     # and should be silently absorbed there | ||||||
|  |                     # since we called `.cancel()` just above ;) | ||||||
|  |                     raise | ||||||
| 
 | 
 | ||||||
|                 else: |                 else: | ||||||
|                     assert 0, "Should have context cancelled?" |                     assert 0, "Should have context cancelled?" | ||||||
|  | @ -464,7 +632,10 @@ async def test_caller_closes_ctx_after_callee_opens_stream( | ||||||
|                 assert portal.channel.connected() |                 assert portal.channel.connected() | ||||||
| 
 | 
 | ||||||
|                 # ctx is closed here |                 # ctx is closed here | ||||||
|                 await portal.run(assert_state, value=False) |                 await portal.run( | ||||||
|  |                     assert_state, | ||||||
|  |                     value=False, | ||||||
|  |                 ) | ||||||
| 
 | 
 | ||||||
|             else: |             else: | ||||||
|                 try: |                 try: | ||||||
|  | @ -472,7 +643,25 @@ async def test_caller_closes_ctx_after_callee_opens_stream( | ||||||
|                         await ctx.result() |                         await ctx.result() | ||||||
|                         assert 0, "Callee should have blocked!?" |                         assert 0, "Callee should have blocked!?" | ||||||
|                 except trio.TooSlowError: |                 except trio.TooSlowError: | ||||||
|  |                     # NO-OP -> since already called above | ||||||
|                     await ctx.cancel() |                     await ctx.cancel() | ||||||
|  | 
 | ||||||
|  |         # NOTE: local scope should have absorbed the cancellation since | ||||||
|  |         # in this case we call `ctx.cancel()` and the local | ||||||
|  |         # `._scope` does not get `.cancel_called` and thus | ||||||
|  |         # `.cancelled_caught` neither will ever bet set. | ||||||
|  |         if use_ctx_cancel_method: | ||||||
|  |             assert not ctx._scope.cancelled_caught | ||||||
|  | 
 | ||||||
|  |         # rxed ctxc response from far end | ||||||
|  |         assert ctx.cancel_acked | ||||||
|  |         assert ( | ||||||
|  |             ctx._remote_error | ||||||
|  |             is ctx._local_error | ||||||
|  |             is ctx.maybe_error | ||||||
|  |             is ctx.outcome | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|         try: |         try: | ||||||
|             async with ctx.open_stream() as stream: |             async with ctx.open_stream() as stream: | ||||||
|                 async for msg in stream: |                 async for msg in stream: | ||||||
|  | @ -494,11 +683,13 @@ async def test_caller_closes_ctx_after_callee_opens_stream( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_multitask_caller_cancels_from_nonroot_task(): | async def test_multitask_caller_cancels_from_nonroot_task( | ||||||
| 
 |     debug_mode: bool, | ||||||
|     async with tractor.open_nursery() as n: | ): | ||||||
| 
 |     async with tractor.open_nursery( | ||||||
|         portal = await n.start_actor( |         debug_mode=debug_mode, | ||||||
|  |     ) as an: | ||||||
|  |         portal = await an.start_actor( | ||||||
|             'ctx_cancelled', |             'ctx_cancelled', | ||||||
|             enable_modules=[__name__], |             enable_modules=[__name__], | ||||||
|         ) |         ) | ||||||
|  | @ -545,25 +736,31 @@ async def test_multitask_caller_cancels_from_nonroot_task(): | ||||||
| @tractor.context | @tractor.context | ||||||
| async def cancel_self( | async def cancel_self( | ||||||
| 
 | 
 | ||||||
|     ctx: tractor.Context, |     ctx: Context, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     global _state |     global _state | ||||||
|     _state = True |     _state = True | ||||||
| 
 | 
 | ||||||
|  |     # since we call this the below `.open_stream()` should always | ||||||
|  |     # error! | ||||||
|     await ctx.cancel() |     await ctx.cancel() | ||||||
| 
 | 
 | ||||||
|     # should inline raise immediately |     # should inline raise immediately | ||||||
|     try: |     try: | ||||||
|         async with ctx.open_stream(): |         async with ctx.open_stream(): | ||||||
|             pass |             pass | ||||||
|     except tractor.ContextCancelled: |     # except tractor.ContextCancelled: | ||||||
|  |     except RuntimeError: | ||||||
|         # suppress for now so we can do checkpoint tests below |         # suppress for now so we can do checkpoint tests below | ||||||
|         pass |         print('Got expected runtime error for stream-after-cancel') | ||||||
|  | 
 | ||||||
|     else: |     else: | ||||||
|         raise RuntimeError('Context didnt cancel itself?!') |         raise RuntimeError('Context didnt cancel itself?!') | ||||||
| 
 | 
 | ||||||
|     # check a real ``trio.Cancelled`` is raised on a checkpoint |     # check that``trio.Cancelled`` is now raised on any further | ||||||
|  |     # checkpoints since the self cancel above will have cancelled | ||||||
|  |     # the `Context._scope.cancel_scope: trio.CancelScope` | ||||||
|     try: |     try: | ||||||
|         with trio.fail_after(0.1): |         with trio.fail_after(0.1): | ||||||
|             await trio.sleep_forever() |             await trio.sleep_forever() | ||||||
|  | @ -574,17 +771,22 @@ async def cancel_self( | ||||||
|         # should never get here |         # should never get here | ||||||
|         assert 0 |         assert 0 | ||||||
| 
 | 
 | ||||||
|  |     raise RuntimeError('Context didnt cancel itself?!') | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_callee_cancels_before_started(): | async def test_callee_cancels_before_started( | ||||||
|  |     debug_mode: bool, | ||||||
|  | ): | ||||||
|     ''' |     ''' | ||||||
|     Callee calls `Context.cancel()` while streaming and caller |     Callee calls `Context.cancel()` while streaming and caller | ||||||
|     sees stream terminated in `ContextCancelled`. |     sees stream terminated in `ContextCancelled`. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     async with tractor.open_nursery() as n: |     async with tractor.open_nursery( | ||||||
| 
 |         debug_mode=debug_mode, | ||||||
|         portal = await n.start_actor( |     ) as an: | ||||||
|  |         portal = await an.start_actor( | ||||||
|             'cancels_self', |             'cancels_self', | ||||||
|             enable_modules=[__name__], |             enable_modules=[__name__], | ||||||
|         ) |         ) | ||||||
|  | @ -598,10 +800,12 @@ async def test_callee_cancels_before_started(): | ||||||
| 
 | 
 | ||||||
|         # raises a special cancel signal |         # raises a special cancel signal | ||||||
|         except tractor.ContextCancelled as ce: |         except tractor.ContextCancelled as ce: | ||||||
|             ce.type == trio.Cancelled |             _ce = ce  # for debug on crash | ||||||
|  |             ce.boxed_type == trio.Cancelled | ||||||
| 
 | 
 | ||||||
|             # the traceback should be informative |             # the traceback should be informative | ||||||
|             assert 'cancelled itself' in ce.msgdata['tb_str'] |             assert 'itself' in ce.tb_str | ||||||
|  |             assert ce.tb_str == ce.msgdata['tb_str'] | ||||||
| 
 | 
 | ||||||
|         # teardown the actor |         # teardown the actor | ||||||
|         await portal.cancel_actor() |         await portal.cancel_actor() | ||||||
|  | @ -610,7 +814,7 @@ async def test_callee_cancels_before_started(): | ||||||
| @tractor.context | @tractor.context | ||||||
| async def never_open_stream( | async def never_open_stream( | ||||||
| 
 | 
 | ||||||
|     ctx:  tractor.Context, |     ctx:  Context, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     ''' |     ''' | ||||||
|  | @ -624,8 +828,8 @@ async def never_open_stream( | ||||||
| @tractor.context | @tractor.context | ||||||
| async def keep_sending_from_callee( | async def keep_sending_from_callee( | ||||||
| 
 | 
 | ||||||
|     ctx:  tractor.Context, |     ctx:  Context, | ||||||
|     msg_buffer_size: Optional[int] = None, |     msg_buffer_size: int|None = None, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     ''' |     ''' | ||||||
|  | @ -648,9 +852,15 @@ async def keep_sending_from_callee( | ||||||
|         ('caller', 1, never_open_stream), |         ('caller', 1, never_open_stream), | ||||||
|         ('callee', 0, keep_sending_from_callee), |         ('callee', 0, keep_sending_from_callee), | ||||||
|     ], |     ], | ||||||
|     ids='overrun_condition={}'.format, |     ids=[ | ||||||
|  |          ('caller_1buf_never_open_stream'), | ||||||
|  |          ('callee_0buf_keep_sending_from_callee'), | ||||||
|  |     ] | ||||||
| ) | ) | ||||||
| def test_one_end_stream_not_opened(overrun_by): | def test_one_end_stream_not_opened( | ||||||
|  |     overrun_by: tuple[str, int, Callable], | ||||||
|  |     debug_mode: bool, | ||||||
|  | ): | ||||||
|     ''' |     ''' | ||||||
|     This should exemplify the bug from: |     This should exemplify the bug from: | ||||||
|     https://github.com/goodboy/tractor/issues/265 |     https://github.com/goodboy/tractor/issues/265 | ||||||
|  | @ -661,12 +871,15 @@ def test_one_end_stream_not_opened(overrun_by): | ||||||
|     buf_size = buf_size_increase + Actor.msg_buffer_size |     buf_size = buf_size_increase + Actor.msg_buffer_size | ||||||
| 
 | 
 | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery() as n: |         async with tractor.open_nursery( | ||||||
|             portal = await n.start_actor( |             debug_mode=debug_mode, | ||||||
|  |         ) as an: | ||||||
|  |             portal = await an.start_actor( | ||||||
|                 entrypoint.__name__, |                 entrypoint.__name__, | ||||||
|                 enable_modules=[__name__], |                 enable_modules=[__name__], | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|  |             with trio.fail_after(1): | ||||||
|                 async with portal.open_context( |                 async with portal.open_context( | ||||||
|                     entrypoint, |                     entrypoint, | ||||||
|                 ) as (ctx, sent): |                 ) as (ctx, sent): | ||||||
|  | @ -701,7 +914,7 @@ def test_one_end_stream_not_opened(overrun_by): | ||||||
|         with pytest.raises(tractor.RemoteActorError) as excinfo: |         with pytest.raises(tractor.RemoteActorError) as excinfo: | ||||||
|             trio.run(main) |             trio.run(main) | ||||||
| 
 | 
 | ||||||
|         assert excinfo.value.type == StreamOverrun |         assert excinfo.value.boxed_type == StreamOverrun | ||||||
| 
 | 
 | ||||||
|     elif overrunner == 'callee': |     elif overrunner == 'callee': | ||||||
|         with pytest.raises(tractor.RemoteActorError) as excinfo: |         with pytest.raises(tractor.RemoteActorError) as excinfo: | ||||||
|  | @ -710,7 +923,7 @@ def test_one_end_stream_not_opened(overrun_by): | ||||||
|         # TODO: embedded remote errors so that we can verify the source |         # TODO: embedded remote errors so that we can verify the source | ||||||
|         # error? the callee delivers an error which is an overrun |         # error? the callee delivers an error which is an overrun | ||||||
|         # wrapped in a remote actor error. |         # wrapped in a remote actor error. | ||||||
|         assert excinfo.value.type == tractor.RemoteActorError |         assert excinfo.value.boxed_type == tractor.RemoteActorError | ||||||
| 
 | 
 | ||||||
|     else: |     else: | ||||||
|         trio.run(main) |         trio.run(main) | ||||||
|  | @ -719,7 +932,7 @@ def test_one_end_stream_not_opened(overrun_by): | ||||||
| @tractor.context | @tractor.context | ||||||
| async def echo_back_sequence( | async def echo_back_sequence( | ||||||
| 
 | 
 | ||||||
|     ctx:  tractor.Context, |     ctx:  Context, | ||||||
|     seq: list[int], |     seq: list[int], | ||||||
|     wait_for_cancel: bool, |     wait_for_cancel: bool, | ||||||
|     allow_overruns_side: str, |     allow_overruns_side: str, | ||||||
|  | @ -736,10 +949,13 @@ async def echo_back_sequence( | ||||||
|     # NOTE: ensure that if the caller is expecting to cancel this task |     # NOTE: ensure that if the caller is expecting to cancel this task | ||||||
|     # that we stay echoing much longer then they are so we don't |     # that we stay echoing much longer then they are so we don't | ||||||
|     # return early instead of receive the cancel msg. |     # return early instead of receive the cancel msg. | ||||||
|     total_batches: int = 1000 if wait_for_cancel else 6 |     total_batches: int = ( | ||||||
|  |         1000 if wait_for_cancel | ||||||
|  |         else 6 | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     await ctx.started() |     await ctx.started() | ||||||
|     # await tractor.breakpoint() |     # await tractor.pause() | ||||||
|     async with ctx.open_stream( |     async with ctx.open_stream( | ||||||
|         msg_buffer_size=msg_buffer_size, |         msg_buffer_size=msg_buffer_size, | ||||||
| 
 | 
 | ||||||
|  | @ -755,8 +971,23 @@ async def echo_back_sequence( | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         seq = list(seq)  # bleh, msgpack sometimes ain't decoded right |         seq = list(seq)  # bleh, msgpack sometimes ain't decoded right | ||||||
|         for _ in range(total_batches): |         for i in range(total_batches): | ||||||
|  |             print(f'starting new stream batch {i} iter in child') | ||||||
|             batch = [] |             batch = [] | ||||||
|  | 
 | ||||||
|  |             # EoC case, delay a little instead of hot | ||||||
|  |             # iter-stopping (since apparently py3.11+ can do that | ||||||
|  |             # faster then a ctxc can be sent) on the async for | ||||||
|  |             # loop when child was requested to ctxc. | ||||||
|  |             if ( | ||||||
|  |                 stream.closed | ||||||
|  |                 or | ||||||
|  |                 ctx.cancel_called | ||||||
|  |             ): | ||||||
|  |                 print('child stream already closed!?!') | ||||||
|  |                 await trio.sleep(0.05) | ||||||
|  |                 continue | ||||||
|  | 
 | ||||||
|             async for msg in stream: |             async for msg in stream: | ||||||
|                 batch.append(msg) |                 batch.append(msg) | ||||||
|                 if batch == seq: |                 if batch == seq: | ||||||
|  | @ -767,15 +998,18 @@ async def echo_back_sequence( | ||||||
| 
 | 
 | ||||||
|                 print('callee waiting on next') |                 print('callee waiting on next') | ||||||
| 
 | 
 | ||||||
|  |             print(f'callee echoing back latest batch\n{batch}') | ||||||
|             for msg in batch: |             for msg in batch: | ||||||
|                 print(f'callee sending {msg}') |                 print(f'callee sending msg\n{msg}') | ||||||
|                 await stream.send(msg) |                 await stream.send(msg) | ||||||
| 
 | 
 | ||||||
|     print( |     try: | ||||||
|         'EXITING CALLEEE:\n' |  | ||||||
|         f'{ctx.cancel_called_remote}' |  | ||||||
|     ) |  | ||||||
|         return 'yo' |         return 'yo' | ||||||
|  |     finally: | ||||||
|  |         print( | ||||||
|  |             'exiting callee with context:\n' | ||||||
|  |             f'{pformat(ctx)}\n' | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|  | @ -801,7 +1035,10 @@ def test_maybe_allow_overruns_stream( | ||||||
|     cancel_ctx: bool, |     cancel_ctx: bool, | ||||||
|     slow_side: str, |     slow_side: str, | ||||||
|     allow_overruns_side: str, |     allow_overruns_side: str, | ||||||
|  | 
 | ||||||
|  |     # conftest wide | ||||||
|     loglevel: str, |     loglevel: str, | ||||||
|  |     debug_mode: bool, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     Demonstrate small overruns of each task back and forth |     Demonstrate small overruns of each task back and forth | ||||||
|  | @ -820,23 +1057,34 @@ def test_maybe_allow_overruns_stream( | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery() as n: |         async with tractor.open_nursery( | ||||||
|             portal = await n.start_actor( |             debug_mode=debug_mode, | ||||||
|  |         ) as an: | ||||||
|  |             portal = await an.start_actor( | ||||||
|                 'callee_sends_forever', |                 'callee_sends_forever', | ||||||
|                 enable_modules=[__name__], |                 enable_modules=[__name__], | ||||||
|                 loglevel=loglevel, |                 loglevel=loglevel, | ||||||
| 
 |                 debug_mode=debug_mode, | ||||||
|                 # debug_mode=True, |  | ||||||
|             ) |             ) | ||||||
|             seq = list(range(10)) | 
 | ||||||
|  |             # stream-sequence batch info with send delay to determine | ||||||
|  |             # approx timeout determining whether test has hung. | ||||||
|  |             total_batches: int = 2 | ||||||
|  |             num_items: int = 10 | ||||||
|  |             seq = list(range(num_items)) | ||||||
|  |             parent_send_delay: float = 0.16 | ||||||
|  |             timeout: float = math.ceil( | ||||||
|  |                 total_batches * num_items * parent_send_delay | ||||||
|  |             ) | ||||||
|  |             with trio.fail_after(timeout): | ||||||
|                 async with portal.open_context( |                 async with portal.open_context( | ||||||
|                     echo_back_sequence, |                     echo_back_sequence, | ||||||
|                     seq=seq, |                     seq=seq, | ||||||
|                     wait_for_cancel=cancel_ctx, |                     wait_for_cancel=cancel_ctx, | ||||||
|                     be_slow=(slow_side == 'child'), |                     be_slow=(slow_side == 'child'), | ||||||
|                     allow_overruns_side=allow_overruns_side, |                     allow_overruns_side=allow_overruns_side, | ||||||
|             ) as (ctx, sent): |  | ||||||
| 
 | 
 | ||||||
|  |                 ) as (ctx, sent): | ||||||
|                     assert sent is None |                     assert sent is None | ||||||
| 
 | 
 | ||||||
|                     async with ctx.open_stream( |                     async with ctx.open_stream( | ||||||
|  | @ -844,7 +1092,6 @@ def test_maybe_allow_overruns_stream( | ||||||
|                         allow_overruns=(allow_overruns_side in {'parent', 'both'}), |                         allow_overruns=(allow_overruns_side in {'parent', 'both'}), | ||||||
|                     ) as stream: |                     ) as stream: | ||||||
| 
 | 
 | ||||||
|                     total_batches: int = 2 |  | ||||||
|                         for _ in range(total_batches): |                         for _ in range(total_batches): | ||||||
|                             for msg in seq: |                             for msg in seq: | ||||||
|                                 # print(f'root tx {msg}') |                                 # print(f'root tx {msg}') | ||||||
|  | @ -853,7 +1100,7 @@ def test_maybe_allow_overruns_stream( | ||||||
|                                     # NOTE: we make the parent slightly |                                     # NOTE: we make the parent slightly | ||||||
|                                     # slower, when it is slow, to make sure |                                     # slower, when it is slow, to make sure | ||||||
|                                     # that in the overruns everywhere case |                                     # that in the overruns everywhere case | ||||||
|                                 await trio.sleep(0.16) |                                     await trio.sleep(parent_send_delay) | ||||||
| 
 | 
 | ||||||
|                             batch = [] |                             batch = [] | ||||||
|                             async for msg in stream: |                             async for msg in stream: | ||||||
|  | @ -864,14 +1111,14 @@ def test_maybe_allow_overruns_stream( | ||||||
| 
 | 
 | ||||||
|                     if cancel_ctx: |                     if cancel_ctx: | ||||||
|                         # cancel the remote task |                         # cancel the remote task | ||||||
|                     print('sending root side cancel') |                         print('Requesting `ctx.cancel()` in parent!') | ||||||
|                         await ctx.cancel() |                         await ctx.cancel() | ||||||
| 
 | 
 | ||||||
|             res = await ctx.result() |                 res: str|ContextCancelled = await ctx.result() | ||||||
| 
 | 
 | ||||||
|                 if cancel_ctx: |                 if cancel_ctx: | ||||||
|                     assert isinstance(res, ContextCancelled) |                     assert isinstance(res, ContextCancelled) | ||||||
|                 assert tuple(res.canceller) == tractor.current_actor().uid |                     assert tuple(res.canceller) == current_actor().uid | ||||||
| 
 | 
 | ||||||
|                 else: |                 else: | ||||||
|                     print(f'RX ROOT SIDE RESULT {res}') |                     print(f'RX ROOT SIDE RESULT {res}') | ||||||
|  | @ -904,7 +1151,7 @@ def test_maybe_allow_overruns_stream( | ||||||
|             # NOTE: i tried to isolate to a deterministic case here |             # NOTE: i tried to isolate to a deterministic case here | ||||||
|             # based on timeing, but i was kinda wasted, and i don't |             # based on timeing, but i was kinda wasted, and i don't | ||||||
|             # think it's sane to catch them.. |             # think it's sane to catch them.. | ||||||
|             assert err.type in ( |             assert err.boxed_type in ( | ||||||
|                 tractor.RemoteActorError, |                 tractor.RemoteActorError, | ||||||
|                 StreamOverrun, |                 StreamOverrun, | ||||||
|             ) |             ) | ||||||
|  | @ -912,11 +1159,12 @@ def test_maybe_allow_overruns_stream( | ||||||
|         elif ( |         elif ( | ||||||
|             slow_side == 'child' |             slow_side == 'child' | ||||||
|         ): |         ): | ||||||
|             assert err.type == StreamOverrun |             assert err.boxed_type == StreamOverrun | ||||||
| 
 | 
 | ||||||
|         elif slow_side == 'parent': |         elif slow_side == 'parent': | ||||||
|             assert err.type == tractor.RemoteActorError |             assert err.boxed_type == tractor.RemoteActorError | ||||||
|             assert 'StreamOverrun' in err.msgdata['tb_str'] |             assert 'StreamOverrun' in err.tb_str | ||||||
|  |             assert err.tb_str == err.msgdata['tb_str'] | ||||||
| 
 | 
 | ||||||
|     else: |     else: | ||||||
|         # if this hits the logic blocks from above are not |         # if this hits the logic blocks from above are not | ||||||
|  | @ -924,91 +1172,50 @@ def test_maybe_allow_overruns_stream( | ||||||
|         pytest.fail('PARAMETRIZED CASE GEN PROBLEM YO') |         pytest.fail('PARAMETRIZED CASE GEN PROBLEM YO') | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor.context | def test_ctx_with_self_actor( | ||||||
| async def sleep_forever( |     loglevel: str, | ||||||
|     ctx: tractor.Context, |     debug_mode: bool, | ||||||
| ) -> None: | ): | ||||||
|     await ctx.started() |  | ||||||
|     async with ctx.open_stream(): |  | ||||||
|         await trio.sleep_forever() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @acm |  | ||||||
| async def attach_to_sleep_forever(): |  | ||||||
|     ''' |     ''' | ||||||
|     Cancel a context **before** any underlying error is raised in order |     NOTE: for now this is an INVALID OP! | ||||||
|     to trigger a local reception of a ``ContextCancelled`` which **should not** |  | ||||||
|     be re-raised in the local surrounding ``Context`` *iff* the cancel was |  | ||||||
|     requested by **this** side of the context. |  | ||||||
| 
 | 
 | ||||||
|     ''' |     BUT, eventually presuming we add a "side" key to `Actor.get_context()`, | ||||||
|     async with tractor.wait_for_actor('sleeper') as p2: |     we might be able to get this working symmetrically, but should we?? | ||||||
|         async with ( |  | ||||||
|             p2.open_context(sleep_forever) as (peer_ctx, first), |  | ||||||
|             peer_ctx.open_stream(), |  | ||||||
|         ): |  | ||||||
|             try: |  | ||||||
|                 yield |  | ||||||
|             finally: |  | ||||||
|                 # XXX: previously this would trigger local |  | ||||||
|                 # ``ContextCancelled`` to be received and raised in the |  | ||||||
|                 # local context overriding any local error due to logic |  | ||||||
|                 # inside ``_invoke()`` which checked for an error set on |  | ||||||
|                 # ``Context._error`` and raised it in a cancellation |  | ||||||
|                 # scenario. |  | ||||||
|                 # ------ |  | ||||||
|                 # The problem is you can have a remote cancellation that |  | ||||||
|                 # is part of a local error and we shouldn't raise |  | ||||||
|                 # ``ContextCancelled`` **iff** we **were not** the side |  | ||||||
|                 # of the context to initiate it, i.e. |  | ||||||
|                 # ``Context._cancel_called`` should **NOT** have been |  | ||||||
|                 # set. The special logic to handle this case is now |  | ||||||
|                 # inside ``Context._maybe_raise_from_remote_msg()`` XD |  | ||||||
|                 await peer_ctx.cancel() |  | ||||||
| 
 | 
 | ||||||
| 
 |     Open a context back to the same actor and ensure all cancellation | ||||||
| @tractor.context |     and error semantics hold the same. | ||||||
| async def error_before_started( |  | ||||||
|     ctx: tractor.Context, |  | ||||||
| ) -> None: |  | ||||||
|     ''' |  | ||||||
|     This simulates exactly an original bug discovered in: |  | ||||||
|     https://github.com/pikers/piker/issues/244 |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     async with attach_to_sleep_forever(): |  | ||||||
|         # send an unserializable type which should raise a type error |  | ||||||
|         # here and **NOT BE SWALLOWED** by the surrounding acm!!?! |  | ||||||
|         await ctx.started(object()) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_do_not_swallow_error_before_started_by_remote_contextcancelled(): |  | ||||||
|     ''' |  | ||||||
|     Verify that an error raised in a remote context which itself opens |  | ||||||
|     another remote context, which it cancels, does not ovverride the |  | ||||||
|     original error that caused the cancellation of the secondardy |  | ||||||
|     context. |  | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery() as n: |         async with tractor.open_nursery( | ||||||
|             portal = await n.start_actor( |             debug_mode=debug_mode, | ||||||
|                 'errorer', |  | ||||||
|             enable_modules=[__name__], |             enable_modules=[__name__], | ||||||
|             ) |         ) as an: | ||||||
|             await n.start_actor( |             assert an | ||||||
|                 'sleeper', |  | ||||||
|                 enable_modules=[__name__], |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|             async with ( |             async with ( | ||||||
|  |                 tractor.find_actor('root') as portal, | ||||||
|                 portal.open_context( |                 portal.open_context( | ||||||
|                     error_before_started |                     expect_cancelled, | ||||||
|                 ) as (ctx, sent), |                     # echo_back_sequence, | ||||||
|             ): |                     # seq=seq, | ||||||
|                 await trio.sleep_forever() |                     # wait_for_cancel=cancel_ctx, | ||||||
|  |                     # be_slow=(slow_side == 'child'), | ||||||
|  |                     # allow_overruns_side=allow_overruns_side, | ||||||
| 
 | 
 | ||||||
|     with pytest.raises(tractor.RemoteActorError) as excinfo: |                 ) as (ctx, sent), | ||||||
|  |                 ctx.open_stream() as ipc, | ||||||
|  |             ): | ||||||
|  |                 assert sent is None | ||||||
|  | 
 | ||||||
|  |                 seq = list(range(10)) | ||||||
|  |                 for i in seq: | ||||||
|  |                     await ipc.send(i) | ||||||
|  |                     rx: int = await ipc.receive() | ||||||
|  |                     assert rx == i | ||||||
|  | 
 | ||||||
|  |                 await ctx.cancel() | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(RuntimeError) as excinfo: | ||||||
|         trio.run(main) |         trio.run(main) | ||||||
| 
 | 
 | ||||||
|     assert excinfo.value.type == TypeError |     assert 'Invalid Operation' in repr(excinfo.value) | ||||||
|  |  | ||||||
|  | @ -9,25 +9,24 @@ import itertools | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| import tractor | import tractor | ||||||
|  | from tractor._testing import tractor_test | ||||||
| import trio | import trio | ||||||
| 
 | 
 | ||||||
| from conftest import tractor_test |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_reg_then_unreg(arb_addr): | async def test_reg_then_unreg(reg_addr): | ||||||
|     actor = tractor.current_actor() |     actor = tractor.current_actor() | ||||||
|     assert actor.is_arbiter |     assert actor.is_arbiter | ||||||
|     assert len(actor._registry) == 1  # only self is registered |     assert len(actor._registry) == 1  # only self is registered | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         arbiter_addr=arb_addr, |         registry_addrs=[reg_addr], | ||||||
|     ) as n: |     ) as n: | ||||||
| 
 | 
 | ||||||
|         portal = await n.start_actor('actor', enable_modules=[__name__]) |         portal = await n.start_actor('actor', enable_modules=[__name__]) | ||||||
|         uid = portal.channel.uid |         uid = portal.channel.uid | ||||||
| 
 | 
 | ||||||
|         async with tractor.get_arbiter(*arb_addr) as aportal: |         async with tractor.get_registry(*reg_addr) as aportal: | ||||||
|             # this local actor should be the arbiter |             # this local actor should be the arbiter | ||||||
|             assert actor is aportal.actor |             assert actor is aportal.actor | ||||||
| 
 | 
 | ||||||
|  | @ -53,15 +52,27 @@ async def hi(): | ||||||
|     return the_line.format(tractor.current_actor().name) |     return the_line.format(tractor.current_actor().name) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def say_hello(other_actor): | async def say_hello( | ||||||
|  |     other_actor: str, | ||||||
|  |     reg_addr: tuple[str, int], | ||||||
|  | ): | ||||||
|     await trio.sleep(1)  # wait for other actor to spawn |     await trio.sleep(1)  # wait for other actor to spawn | ||||||
|     async with tractor.find_actor(other_actor) as portal: |     async with tractor.find_actor( | ||||||
|  |         other_actor, | ||||||
|  |         registry_addrs=[reg_addr], | ||||||
|  |     ) as portal: | ||||||
|         assert portal is not None |         assert portal is not None | ||||||
|         return await portal.run(__name__, 'hi') |         return await portal.run(__name__, 'hi') | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def say_hello_use_wait(other_actor): | async def say_hello_use_wait( | ||||||
|     async with tractor.wait_for_actor(other_actor) as portal: |     other_actor: str, | ||||||
|  |     reg_addr: tuple[str, int], | ||||||
|  | ): | ||||||
|  |     async with tractor.wait_for_actor( | ||||||
|  |         other_actor, | ||||||
|  |         registry_addr=reg_addr, | ||||||
|  |     ) as portal: | ||||||
|         assert portal is not None |         assert portal is not None | ||||||
|         result = await portal.run(__name__, 'hi') |         result = await portal.run(__name__, 'hi') | ||||||
|         return result |         return result | ||||||
|  | @ -69,21 +80,29 @@ async def say_hello_use_wait(other_actor): | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| @pytest.mark.parametrize('func', [say_hello, say_hello_use_wait]) | @pytest.mark.parametrize('func', [say_hello, say_hello_use_wait]) | ||||||
| async def test_trynamic_trio(func, start_method, arb_addr): | async def test_trynamic_trio( | ||||||
|     """Main tractor entry point, the "master" process (for now |     func, | ||||||
|     acts as the "director"). |     start_method, | ||||||
|     """ |     reg_addr, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Root actor acting as the "director" and running one-shot-task-actors | ||||||
|  |     for the directed subs. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|     async with tractor.open_nursery() as n: |     async with tractor.open_nursery() as n: | ||||||
|         print("Alright... Action!") |         print("Alright... Action!") | ||||||
| 
 | 
 | ||||||
|         donny = await n.run_in_actor( |         donny = await n.run_in_actor( | ||||||
|             func, |             func, | ||||||
|             other_actor='gretchen', |             other_actor='gretchen', | ||||||
|  |             reg_addr=reg_addr, | ||||||
|             name='donny', |             name='donny', | ||||||
|         ) |         ) | ||||||
|         gretchen = await n.run_in_actor( |         gretchen = await n.run_in_actor( | ||||||
|             func, |             func, | ||||||
|             other_actor='donny', |             other_actor='donny', | ||||||
|  |             reg_addr=reg_addr, | ||||||
|             name='gretchen', |             name='gretchen', | ||||||
|         ) |         ) | ||||||
|         print(await gretchen.result()) |         print(await gretchen.result()) | ||||||
|  | @ -131,7 +150,7 @@ async def unpack_reg(actor_or_portal): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def spawn_and_check_registry( | async def spawn_and_check_registry( | ||||||
|     arb_addr: tuple, |     reg_addr: tuple, | ||||||
|     use_signal: bool, |     use_signal: bool, | ||||||
|     remote_arbiter: bool = False, |     remote_arbiter: bool = False, | ||||||
|     with_streaming: bool = False, |     with_streaming: bool = False, | ||||||
|  | @ -139,9 +158,9 @@ async def spawn_and_check_registry( | ||||||
| ) -> None: | ) -> None: | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_root_actor( |     async with tractor.open_root_actor( | ||||||
|         arbiter_addr=arb_addr, |         registry_addrs=[reg_addr], | ||||||
|     ): |     ): | ||||||
|         async with tractor.get_arbiter(*arb_addr) as portal: |         async with tractor.get_registry(*reg_addr) as portal: | ||||||
|             # runtime needs to be up to call this |             # runtime needs to be up to call this | ||||||
|             actor = tractor.current_actor() |             actor = tractor.current_actor() | ||||||
| 
 | 
 | ||||||
|  | @ -213,17 +232,19 @@ async def spawn_and_check_registry( | ||||||
| def test_subactors_unregister_on_cancel( | def test_subactors_unregister_on_cancel( | ||||||
|     start_method, |     start_method, | ||||||
|     use_signal, |     use_signal, | ||||||
|     arb_addr, |     reg_addr, | ||||||
|     with_streaming, |     with_streaming, | ||||||
| ): | ): | ||||||
|     """Verify that cancelling a nursery results in all subactors |     ''' | ||||||
|  |     Verify that cancelling a nursery results in all subactors | ||||||
|     deregistering themselves with the arbiter. |     deregistering themselves with the arbiter. | ||||||
|     """ | 
 | ||||||
|  |     ''' | ||||||
|     with pytest.raises(KeyboardInterrupt): |     with pytest.raises(KeyboardInterrupt): | ||||||
|         trio.run( |         trio.run( | ||||||
|             partial( |             partial( | ||||||
|                 spawn_and_check_registry, |                 spawn_and_check_registry, | ||||||
|                 arb_addr, |                 reg_addr, | ||||||
|                 use_signal, |                 use_signal, | ||||||
|                 remote_arbiter=False, |                 remote_arbiter=False, | ||||||
|                 with_streaming=with_streaming, |                 with_streaming=with_streaming, | ||||||
|  | @ -237,7 +258,7 @@ def test_subactors_unregister_on_cancel_remote_daemon( | ||||||
|     daemon, |     daemon, | ||||||
|     start_method, |     start_method, | ||||||
|     use_signal, |     use_signal, | ||||||
|     arb_addr, |     reg_addr, | ||||||
|     with_streaming, |     with_streaming, | ||||||
| ): | ): | ||||||
|     """Verify that cancelling a nursery results in all subactors |     """Verify that cancelling a nursery results in all subactors | ||||||
|  | @ -248,7 +269,7 @@ def test_subactors_unregister_on_cancel_remote_daemon( | ||||||
|         trio.run( |         trio.run( | ||||||
|             partial( |             partial( | ||||||
|                 spawn_and_check_registry, |                 spawn_and_check_registry, | ||||||
|                 arb_addr, |                 reg_addr, | ||||||
|                 use_signal, |                 use_signal, | ||||||
|                 remote_arbiter=True, |                 remote_arbiter=True, | ||||||
|                 with_streaming=with_streaming, |                 with_streaming=with_streaming, | ||||||
|  | @ -262,7 +283,7 @@ async def streamer(agen): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def close_chans_before_nursery( | async def close_chans_before_nursery( | ||||||
|     arb_addr: tuple, |     reg_addr: tuple, | ||||||
|     use_signal: bool, |     use_signal: bool, | ||||||
|     remote_arbiter: bool = False, |     remote_arbiter: bool = False, | ||||||
| ) -> None: | ) -> None: | ||||||
|  | @ -275,9 +296,9 @@ async def close_chans_before_nursery( | ||||||
|         entries_at_end = 1 |         entries_at_end = 1 | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_root_actor( |     async with tractor.open_root_actor( | ||||||
|         arbiter_addr=arb_addr, |         registry_addrs=[reg_addr], | ||||||
|     ): |     ): | ||||||
|         async with tractor.get_arbiter(*arb_addr) as aportal: |         async with tractor.get_registry(*reg_addr) as aportal: | ||||||
|             try: |             try: | ||||||
|                 get_reg = partial(unpack_reg, aportal) |                 get_reg = partial(unpack_reg, aportal) | ||||||
| 
 | 
 | ||||||
|  | @ -329,7 +350,7 @@ async def close_chans_before_nursery( | ||||||
| def test_close_channel_explicit( | def test_close_channel_explicit( | ||||||
|     start_method, |     start_method, | ||||||
|     use_signal, |     use_signal, | ||||||
|     arb_addr, |     reg_addr, | ||||||
| ): | ): | ||||||
|     """Verify that closing a stream explicitly and killing the actor's |     """Verify that closing a stream explicitly and killing the actor's | ||||||
|     "root nursery" **before** the containing nursery tears down also |     "root nursery" **before** the containing nursery tears down also | ||||||
|  | @ -339,7 +360,7 @@ def test_close_channel_explicit( | ||||||
|         trio.run( |         trio.run( | ||||||
|             partial( |             partial( | ||||||
|                 close_chans_before_nursery, |                 close_chans_before_nursery, | ||||||
|                 arb_addr, |                 reg_addr, | ||||||
|                 use_signal, |                 use_signal, | ||||||
|                 remote_arbiter=False, |                 remote_arbiter=False, | ||||||
|             ), |             ), | ||||||
|  | @ -351,7 +372,7 @@ def test_close_channel_explicit_remote_arbiter( | ||||||
|     daemon, |     daemon, | ||||||
|     start_method, |     start_method, | ||||||
|     use_signal, |     use_signal, | ||||||
|     arb_addr, |     reg_addr, | ||||||
| ): | ): | ||||||
|     """Verify that closing a stream explicitly and killing the actor's |     """Verify that closing a stream explicitly and killing the actor's | ||||||
|     "root nursery" **before** the containing nursery tears down also |     "root nursery" **before** the containing nursery tears down also | ||||||
|  | @ -361,7 +382,7 @@ def test_close_channel_explicit_remote_arbiter( | ||||||
|         trio.run( |         trio.run( | ||||||
|             partial( |             partial( | ||||||
|                 close_chans_before_nursery, |                 close_chans_before_nursery, | ||||||
|                 arb_addr, |                 reg_addr, | ||||||
|                 use_signal, |                 use_signal, | ||||||
|                 remote_arbiter=True, |                 remote_arbiter=True, | ||||||
|             ), |             ), | ||||||
|  |  | ||||||
|  | @ -11,8 +11,7 @@ import platform | ||||||
| import shutil | import shutil | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| 
 | from tractor._testing import ( | ||||||
| from conftest import ( |  | ||||||
|     examples_dir, |     examples_dir, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -20,8 +19,8 @@ from conftest import ( | ||||||
| @pytest.fixture | @pytest.fixture | ||||||
| def run_example_in_subproc( | def run_example_in_subproc( | ||||||
|     loglevel: str, |     loglevel: str, | ||||||
|     testdir, |     testdir: pytest.Testdir, | ||||||
|     arb_addr: tuple[str, int], |     reg_addr: tuple[str, int], | ||||||
| ): | ): | ||||||
| 
 | 
 | ||||||
|     @contextmanager |     @contextmanager | ||||||
|  | @ -89,6 +88,7 @@ def run_example_in_subproc( | ||||||
|         and 'debugging' not in p[0] |         and 'debugging' not in p[0] | ||||||
|         and 'integration' not in p[0] |         and 'integration' not in p[0] | ||||||
|         and 'advanced_faults' not in p[0] |         and 'advanced_faults' not in p[0] | ||||||
|  |         and 'multihost' not in p[0] | ||||||
|     ], |     ], | ||||||
| 
 | 
 | ||||||
|     ids=lambda t: t[1], |     ids=lambda t: t[1], | ||||||
|  |  | ||||||
|  | @ -2,22 +2,34 @@ | ||||||
| The hipster way to force SC onto the stdlib's "async": 'infection mode'. | The hipster way to force SC onto the stdlib's "async": 'infection mode'. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| from typing import Optional, Iterable, Union |  | ||||||
| import asyncio | import asyncio | ||||||
| import builtins | import builtins | ||||||
|  | from contextlib import ExitStack | ||||||
|  | # from functools import partial | ||||||
| import itertools | import itertools | ||||||
| import importlib | import importlib | ||||||
|  | import os | ||||||
|  | from pathlib import Path | ||||||
|  | import signal | ||||||
|  | from typing import ( | ||||||
|  |     Callable, | ||||||
|  |     Iterable, | ||||||
|  |     Union, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| from exceptiongroup import BaseExceptionGroup |  | ||||||
| import pytest | import pytest | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
| from tractor import ( | from tractor import ( | ||||||
|  |     current_actor, | ||||||
|  |     Actor, | ||||||
|     to_asyncio, |     to_asyncio, | ||||||
|     RemoteActorError, |     RemoteActorError, | ||||||
|     ContextCancelled, |     ContextCancelled, | ||||||
|  |     _state, | ||||||
| ) | ) | ||||||
| from tractor.trionics import BroadcastReceiver | from tractor.trionics import BroadcastReceiver | ||||||
|  | from tractor._testing import expect_ctxc | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def sleep_and_err( | async def sleep_and_err( | ||||||
|  | @ -25,8 +37,8 @@ async def sleep_and_err( | ||||||
| 
 | 
 | ||||||
|     # just signature placeholders for compat with |     # just signature placeholders for compat with | ||||||
|     # ``to_asyncio.open_channel_from()`` |     # ``to_asyncio.open_channel_from()`` | ||||||
|     to_trio: Optional[trio.MemorySendChannel] = None, |     to_trio: trio.MemorySendChannel|None = None, | ||||||
|     from_trio: Optional[asyncio.Queue] = None, |     from_trio: asyncio.Queue|None = None, | ||||||
| 
 | 
 | ||||||
| ): | ): | ||||||
|     if to_trio: |     if to_trio: | ||||||
|  | @ -36,7 +48,7 @@ async def sleep_and_err( | ||||||
|     assert 0 |     assert 0 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def sleep_forever(): | async def aio_sleep_forever(): | ||||||
|     await asyncio.sleep(float('inf')) |     await asyncio.sleep(float('inf')) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -44,10 +56,10 @@ async def trio_cancels_single_aio_task(): | ||||||
| 
 | 
 | ||||||
|     # spawn an ``asyncio`` task to run a func and return result |     # spawn an ``asyncio`` task to run a func and return result | ||||||
|     with trio.move_on_after(.2): |     with trio.move_on_after(.2): | ||||||
|         await tractor.to_asyncio.run_task(sleep_forever) |         await tractor.to_asyncio.run_task(aio_sleep_forever) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_trio_cancels_aio_on_actor_side(arb_addr): | def test_trio_cancels_aio_on_actor_side(reg_addr): | ||||||
|     ''' |     ''' | ||||||
|     Spawn an infected actor that is cancelled by the ``trio`` side |     Spawn an infected actor that is cancelled by the ``trio`` side | ||||||
|     task using std cancel scope apis. |     task using std cancel scope apis. | ||||||
|  | @ -55,7 +67,7 @@ def test_trio_cancels_aio_on_actor_side(arb_addr): | ||||||
|     ''' |     ''' | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             arbiter_addr=arb_addr |             registry_addrs=[reg_addr] | ||||||
|         ) as n: |         ) as n: | ||||||
|             await n.run_in_actor( |             await n.run_in_actor( | ||||||
|                 trio_cancels_single_aio_task, |                 trio_cancels_single_aio_task, | ||||||
|  | @ -66,14 +78,22 @@ def test_trio_cancels_aio_on_actor_side(arb_addr): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def asyncio_actor( | async def asyncio_actor( | ||||||
| 
 |  | ||||||
|     target: str, |     target: str, | ||||||
|     expect_err: Optional[Exception] = None |     expect_err: Exception|None = None | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
| 
 | 
 | ||||||
|     assert tractor.current_actor().is_infected_aio() |     # ensure internal runtime state is consistent | ||||||
|     target = globals()[target] |     actor: Actor = tractor.current_actor() | ||||||
|  |     assert ( | ||||||
|  |         actor.is_infected_aio() | ||||||
|  |         and | ||||||
|  |         actor._infected_aio | ||||||
|  |         and | ||||||
|  |         _state._runtime_vars['_is_infected_aio'] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     target: Callable = globals()[target] | ||||||
| 
 | 
 | ||||||
|     if '.' in expect_err: |     if '.' in expect_err: | ||||||
|         modpath, _, name = expect_err.rpartition('.') |         modpath, _, name = expect_err.rpartition('.') | ||||||
|  | @ -89,12 +109,14 @@ async def asyncio_actor( | ||||||
| 
 | 
 | ||||||
|     except BaseException as err: |     except BaseException as err: | ||||||
|         if expect_err: |         if expect_err: | ||||||
|             assert isinstance(err, error_type) |             assert isinstance(err, error_type), ( | ||||||
|  |                 f'{type(err)} is not {error_type}?' | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|         raise |         raise | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_aio_simple_error(arb_addr): | def test_aio_simple_error(reg_addr): | ||||||
|     ''' |     ''' | ||||||
|     Verify a simple remote asyncio error propagates back through trio |     Verify a simple remote asyncio error propagates back through trio | ||||||
|     to the parent actor. |     to the parent actor. | ||||||
|  | @ -103,7 +125,7 @@ def test_aio_simple_error(arb_addr): | ||||||
|     ''' |     ''' | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             arbiter_addr=arb_addr |             registry_addrs=[reg_addr] | ||||||
|         ) as n: |         ) as n: | ||||||
|             await n.run_in_actor( |             await n.run_in_actor( | ||||||
|                 asyncio_actor, |                 asyncio_actor, | ||||||
|  | @ -112,15 +134,26 @@ def test_aio_simple_error(arb_addr): | ||||||
|                 infect_asyncio=True, |                 infect_asyncio=True, | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|     with pytest.raises(RemoteActorError) as excinfo: |     with pytest.raises( | ||||||
|  |         expected_exception=(RemoteActorError, ExceptionGroup), | ||||||
|  |     ) as excinfo: | ||||||
|         trio.run(main) |         trio.run(main) | ||||||
| 
 | 
 | ||||||
|     err = excinfo.value |     err = excinfo.value | ||||||
|  | 
 | ||||||
|  |     # might get multiple `trio.Cancelled`s as well inside an inception | ||||||
|  |     if isinstance(err, ExceptionGroup): | ||||||
|  |         err = next(itertools.dropwhile( | ||||||
|  |             lambda exc: not isinstance(exc, tractor.RemoteActorError), | ||||||
|  |             err.exceptions | ||||||
|  |         )) | ||||||
|  |         assert err | ||||||
|  | 
 | ||||||
|     assert isinstance(err, RemoteActorError) |     assert isinstance(err, RemoteActorError) | ||||||
|     assert err.type == AssertionError |     assert err.boxed_type is AssertionError | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_tractor_cancels_aio(arb_addr): | def test_tractor_cancels_aio(reg_addr): | ||||||
|     ''' |     ''' | ||||||
|     Verify we can cancel a spawned asyncio task gracefully. |     Verify we can cancel a spawned asyncio task gracefully. | ||||||
| 
 | 
 | ||||||
|  | @ -129,7 +162,7 @@ def test_tractor_cancels_aio(arb_addr): | ||||||
|         async with tractor.open_nursery() as n: |         async with tractor.open_nursery() as n: | ||||||
|             portal = await n.run_in_actor( |             portal = await n.run_in_actor( | ||||||
|                 asyncio_actor, |                 asyncio_actor, | ||||||
|                 target='sleep_forever', |                 target='aio_sleep_forever', | ||||||
|                 expect_err='trio.Cancelled', |                 expect_err='trio.Cancelled', | ||||||
|                 infect_asyncio=True, |                 infect_asyncio=True, | ||||||
|             ) |             ) | ||||||
|  | @ -139,7 +172,7 @@ def test_tractor_cancels_aio(arb_addr): | ||||||
|     trio.run(main) |     trio.run(main) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_trio_cancels_aio(arb_addr): | def test_trio_cancels_aio(reg_addr): | ||||||
|     ''' |     ''' | ||||||
|     Much like the above test with ``tractor.Portal.cancel_actor()`` |     Much like the above test with ``tractor.Portal.cancel_actor()`` | ||||||
|     except we just use a standard ``trio`` cancellation api. |     except we just use a standard ``trio`` cancellation api. | ||||||
|  | @ -150,10 +183,10 @@ def test_trio_cancels_aio(arb_addr): | ||||||
|         with trio.move_on_after(1): |         with trio.move_on_after(1): | ||||||
|             # cancel the nursery shortly after boot |             # cancel the nursery shortly after boot | ||||||
| 
 | 
 | ||||||
|             async with tractor.open_nursery() as n: |             async with tractor.open_nursery() as tn: | ||||||
|                 await n.run_in_actor( |                 await tn.run_in_actor( | ||||||
|                     asyncio_actor, |                     asyncio_actor, | ||||||
|                     target='sleep_forever', |                     target='aio_sleep_forever', | ||||||
|                     expect_err='trio.Cancelled', |                     expect_err='trio.Cancelled', | ||||||
|                     infect_asyncio=True, |                     infect_asyncio=True, | ||||||
|                 ) |                 ) | ||||||
|  | @ -171,9 +204,12 @@ async def trio_ctx( | ||||||
|     # this will block until the ``asyncio`` task sends a "first" |     # this will block until the ``asyncio`` task sends a "first" | ||||||
|     # message. |     # message. | ||||||
|     with trio.fail_after(2): |     with trio.fail_after(2): | ||||||
|  |         try: | ||||||
|             async with ( |             async with ( | ||||||
|             trio.open_nursery() as n, |                 trio.open_nursery( | ||||||
| 
 |                     # TODO, for new `trio` / py3.13 | ||||||
|  |                     # strict_exception_groups=False, | ||||||
|  |                 ) as tn, | ||||||
|                 tractor.to_asyncio.open_channel_from( |                 tractor.to_asyncio.open_channel_from( | ||||||
|                     sleep_and_err, |                     sleep_and_err, | ||||||
|                 ) as (first, chan), |                 ) as (first, chan), | ||||||
|  | @ -182,19 +218,28 @@ async def trio_ctx( | ||||||
|                 assert first == 'start' |                 assert first == 'start' | ||||||
| 
 | 
 | ||||||
|                 # spawn another asyncio task for the cuck of it. |                 # spawn another asyncio task for the cuck of it. | ||||||
|             n.start_soon( |                 tn.start_soon( | ||||||
|                     tractor.to_asyncio.run_task, |                     tractor.to_asyncio.run_task, | ||||||
|                 sleep_forever, |                     aio_sleep_forever, | ||||||
|                 ) |                 ) | ||||||
|                 await trio.sleep_forever() |                 await trio.sleep_forever() | ||||||
| 
 | 
 | ||||||
|  |         # TODO, factor this into a `trionics.collapse()`? | ||||||
|  |         except* BaseException as beg: | ||||||
|  |             # await tractor.pause(shield=True) | ||||||
|  |             if len(excs := beg.exceptions) == 1: | ||||||
|  |                 raise excs[0] | ||||||
|  |             else: | ||||||
|  |                 raise | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'parent_cancels', [False, True], |     'parent_cancels', | ||||||
|  |     ['context', 'actor', False], | ||||||
|     ids='parent_actor_cancels_child={}'.format |     ids='parent_actor_cancels_child={}'.format | ||||||
| ) | ) | ||||||
| def test_context_spawns_aio_task_that_errors( | def test_context_spawns_aio_task_that_errors( | ||||||
|     arb_addr, |     reg_addr, | ||||||
|     parent_cancels: bool, |     parent_cancels: bool, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|  | @ -204,7 +249,6 @@ def test_context_spawns_aio_task_that_errors( | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     async def main(): |     async def main(): | ||||||
| 
 |  | ||||||
|         with trio.fail_after(2): |         with trio.fail_after(2): | ||||||
|             async with tractor.open_nursery() as n: |             async with tractor.open_nursery() as n: | ||||||
|                 p = await n.start_actor( |                 p = await n.start_actor( | ||||||
|  | @ -214,18 +258,36 @@ def test_context_spawns_aio_task_that_errors( | ||||||
|                     # debug_mode=True, |                     # debug_mode=True, | ||||||
|                     loglevel='cancel', |                     loglevel='cancel', | ||||||
|                 ) |                 ) | ||||||
|                 async with p.open_context( |                 async with ( | ||||||
|  |                     expect_ctxc( | ||||||
|  |                         yay=parent_cancels == 'actor', | ||||||
|  |                     ), | ||||||
|  |                     p.open_context( | ||||||
|                         trio_ctx, |                         trio_ctx, | ||||||
|                 ) as (ctx, first): |                     ) as (ctx, first), | ||||||
|  |                 ): | ||||||
| 
 | 
 | ||||||
|                     assert first == 'start' |                     assert first == 'start' | ||||||
| 
 | 
 | ||||||
|                     if parent_cancels: |                     if parent_cancels == 'actor': | ||||||
|                         await p.cancel_actor() |                         await p.cancel_actor() | ||||||
| 
 | 
 | ||||||
|  |                     elif parent_cancels == 'context': | ||||||
|  |                         await ctx.cancel() | ||||||
|  | 
 | ||||||
|  |                     else: | ||||||
|                         await trio.sleep_forever() |                         await trio.sleep_forever() | ||||||
| 
 | 
 | ||||||
|         return await ctx.result() |                 async with expect_ctxc( | ||||||
|  |                     yay=parent_cancels == 'actor', | ||||||
|  |                 ): | ||||||
|  |                     await ctx.result() | ||||||
|  | 
 | ||||||
|  |                 if parent_cancels == 'context': | ||||||
|  |                     # to tear down sub-acor | ||||||
|  |                     await p.cancel_actor() | ||||||
|  | 
 | ||||||
|  |         return ctx.outcome | ||||||
| 
 | 
 | ||||||
|     if parent_cancels: |     if parent_cancels: | ||||||
|         # bc the parent made the cancel request, |         # bc the parent made the cancel request, | ||||||
|  | @ -242,7 +304,7 @@ def test_context_spawns_aio_task_that_errors( | ||||||
| 
 | 
 | ||||||
|         err = excinfo.value |         err = excinfo.value | ||||||
|         assert isinstance(err, expect) |         assert isinstance(err, expect) | ||||||
|         assert err.type == AssertionError |         assert err.boxed_type is AssertionError | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def aio_cancel(): | async def aio_cancel(): | ||||||
|  | @ -251,29 +313,55 @@ async def aio_cancel(): | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     await asyncio.sleep(0.5) |     await asyncio.sleep(0.5) | ||||||
|     task = asyncio.current_task() |  | ||||||
| 
 | 
 | ||||||
|     # cancel and enter sleep |     # cancel and enter sleep | ||||||
|  |     task = asyncio.current_task() | ||||||
|     task.cancel() |     task.cancel() | ||||||
|     await sleep_forever() |     await aio_sleep_forever() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_aio_cancelled_from_aio_causes_trio_cancelled(arb_addr): | def test_aio_cancelled_from_aio_causes_trio_cancelled( | ||||||
|  |     reg_addr: tuple, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     When the `asyncio.Task` cancels itself the `trio` side cshould | ||||||
|  |     also cancel and teardown and relay the cancellation cross-process | ||||||
|  |     to the caller (parent). | ||||||
| 
 | 
 | ||||||
|  |     ''' | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery() as n: | 
 | ||||||
|             await n.run_in_actor( |         an: tractor.ActorNursery | ||||||
|  |         async with tractor.open_nursery() as an: | ||||||
|  |             p: tractor.Portal = await an.run_in_actor( | ||||||
|                 asyncio_actor, |                 asyncio_actor, | ||||||
|                 target='aio_cancel', |                 target='aio_cancel', | ||||||
|                 expect_err='tractor.to_asyncio.AsyncioCancelled', |                 expect_err='tractor.to_asyncio.AsyncioCancelled', | ||||||
|                 infect_asyncio=True, |                 infect_asyncio=True, | ||||||
|             ) |             ) | ||||||
|  |             # NOTE: normally the `an.__aexit__()` waits on the | ||||||
|  |             # portal's result but we do it explicitly here | ||||||
|  |             # to avoid indent levels. | ||||||
|  |             with trio.fail_after(1): | ||||||
|  |                 await p.wait_for_result() | ||||||
| 
 | 
 | ||||||
|     with pytest.raises(RemoteActorError) as excinfo: |     with pytest.raises( | ||||||
|  |         expected_exception=(RemoteActorError, ExceptionGroup), | ||||||
|  |     ) as excinfo: | ||||||
|         trio.run(main) |         trio.run(main) | ||||||
| 
 | 
 | ||||||
|     # ensure boxed error is correct |     # might get multiple `trio.Cancelled`s as well inside an inception | ||||||
|     assert excinfo.value.type == to_asyncio.AsyncioCancelled |     err: RemoteActorError|ExceptionGroup = excinfo.value | ||||||
|  |     if isinstance(err, ExceptionGroup): | ||||||
|  |         err = next(itertools.dropwhile( | ||||||
|  |             lambda exc: not isinstance(exc, tractor.RemoteActorError), | ||||||
|  |             err.exceptions | ||||||
|  |         )) | ||||||
|  |         assert err | ||||||
|  | 
 | ||||||
|  |     # relayed boxed error should be our `trio`-task's | ||||||
|  |     # cancel-signal-proxy-equivalent of `asyncio.CancelledError`. | ||||||
|  |     assert err.boxed_type == to_asyncio.AsyncioCancelled | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # TODO: verify open_channel_from will fail on this.. | # TODO: verify open_channel_from will fail on this.. | ||||||
|  | @ -314,7 +402,6 @@ async def push_from_aio_task( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def stream_from_aio( | async def stream_from_aio( | ||||||
| 
 |  | ||||||
|     exit_early: bool = False, |     exit_early: bool = False, | ||||||
|     raise_err: bool = False, |     raise_err: bool = False, | ||||||
|     aio_raise_err: bool = False, |     aio_raise_err: bool = False, | ||||||
|  | @ -332,6 +419,7 @@ async def stream_from_aio( | ||||||
|             sequence=seq, |             sequence=seq, | ||||||
|             expect_cancel=raise_err or exit_early, |             expect_cancel=raise_err or exit_early, | ||||||
|             fail_early=aio_raise_err, |             fail_early=aio_raise_err, | ||||||
|  | 
 | ||||||
|         ) as (first, chan): |         ) as (first, chan): | ||||||
| 
 | 
 | ||||||
|             assert first is True |             assert first is True | ||||||
|  | @ -350,10 +438,15 @@ async def stream_from_aio( | ||||||
|                         if raise_err: |                         if raise_err: | ||||||
|                             raise Exception |                             raise Exception | ||||||
|                         elif exit_early: |                         elif exit_early: | ||||||
|  |                             print('`consume()` breaking early!\n') | ||||||
|                             break |                             break | ||||||
| 
 | 
 | ||||||
|  |                 print('returning from `consume()`..\n') | ||||||
|  | 
 | ||||||
|  |             # run 2 tasks each pulling from | ||||||
|  |             # the inter-task-channel with the 2nd | ||||||
|  |             # using a fan-out `BroadcastReceiver`. | ||||||
|             if fan_out: |             if fan_out: | ||||||
|                 # start second task that get's the same stream value set. |  | ||||||
|                 async with ( |                 async with ( | ||||||
| 
 | 
 | ||||||
|                     # NOTE: this has to come first to avoid |                     # NOTE: this has to come first to avoid | ||||||
|  | @ -363,11 +456,19 @@ async def stream_from_aio( | ||||||
| 
 | 
 | ||||||
|                     trio.open_nursery() as n, |                     trio.open_nursery() as n, | ||||||
|                 ): |                 ): | ||||||
|  |                     # start 2nd task that get's broadcast the same | ||||||
|  |                     # value set. | ||||||
|                     n.start_soon(consume, br) |                     n.start_soon(consume, br) | ||||||
|                     await consume(chan) |                     await consume(chan) | ||||||
| 
 | 
 | ||||||
|             else: |             else: | ||||||
|                 await consume(chan) |                 await consume(chan) | ||||||
|  |     except BaseException as err: | ||||||
|  |         import logging | ||||||
|  |         log = logging.getLogger() | ||||||
|  |         log.exception('aio-subactor errored!\n') | ||||||
|  |         raise err | ||||||
|  | 
 | ||||||
|     finally: |     finally: | ||||||
| 
 | 
 | ||||||
|         if ( |         if ( | ||||||
|  | @ -388,14 +489,15 @@ async def stream_from_aio( | ||||||
|             assert not fan_out |             assert not fan_out | ||||||
|             assert pulled == expect[:51] |             assert pulled == expect[:51] | ||||||
| 
 | 
 | ||||||
|         print('trio guest mode task completed!') |         print('trio guest-mode task completed!') | ||||||
|  |         assert chan._aio_task.done() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'fan_out', [False, True], |     'fan_out', [False, True], | ||||||
|     ids='fan_out_w_chan_subscribe={}'.format |     ids='fan_out_w_chan_subscribe={}'.format | ||||||
| ) | ) | ||||||
| def test_basic_interloop_channel_stream(arb_addr, fan_out): | def test_basic_interloop_channel_stream(reg_addr, fan_out): | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery() as n: |         async with tractor.open_nursery() as n: | ||||||
|             portal = await n.run_in_actor( |             portal = await n.run_in_actor( | ||||||
|  | @ -403,13 +505,14 @@ def test_basic_interloop_channel_stream(arb_addr, fan_out): | ||||||
|                 infect_asyncio=True, |                 infect_asyncio=True, | ||||||
|                 fan_out=fan_out, |                 fan_out=fan_out, | ||||||
|             ) |             ) | ||||||
|  |             # should raise RAE diectly | ||||||
|             await portal.result() |             await portal.result() | ||||||
| 
 | 
 | ||||||
|     trio.run(main) |     trio.run(main) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # TODO: parametrize the above test and avoid the duplication here? | # TODO: parametrize the above test and avoid the duplication here? | ||||||
| def test_trio_error_cancels_intertask_chan(arb_addr): | def test_trio_error_cancels_intertask_chan(reg_addr): | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery() as n: |         async with tractor.open_nursery() as n: | ||||||
|             portal = await n.run_in_actor( |             portal = await n.run_in_actor( | ||||||
|  | @ -420,30 +523,47 @@ def test_trio_error_cancels_intertask_chan(arb_addr): | ||||||
|             # should trigger remote actor error |             # should trigger remote actor error | ||||||
|             await portal.result() |             await portal.result() | ||||||
| 
 | 
 | ||||||
|     with pytest.raises(BaseExceptionGroup) as excinfo: |     with pytest.raises(RemoteActorError) as excinfo: | ||||||
|         trio.run(main) |         trio.run(main) | ||||||
| 
 | 
 | ||||||
|     # ensure boxed errors |     # ensure boxed error type | ||||||
|     for exc in excinfo.value.exceptions: |     excinfo.value.boxed_type is Exception | ||||||
|         assert exc.type == Exception |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_trio_closes_early_and_channel_exits(arb_addr): | def test_trio_closes_early_and_channel_exits( | ||||||
|  |     reg_addr: tuple[str, int], | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Check that if the `trio`-task "exits early" on `async for`ing the | ||||||
|  |     inter-task-channel (via a `break`) we exit silently from the | ||||||
|  |     `open_channel_from()` block and get a final `Return[None]` msg. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery() as n: |         with trio.fail_after(2): | ||||||
|  |             async with tractor.open_nursery( | ||||||
|  |                 # debug_mode=True, | ||||||
|  |                 # enable_stack_on_sig=True, | ||||||
|  |             ) as n: | ||||||
|                 portal = await n.run_in_actor( |                 portal = await n.run_in_actor( | ||||||
|                     stream_from_aio, |                     stream_from_aio, | ||||||
|                     exit_early=True, |                     exit_early=True, | ||||||
|                     infect_asyncio=True, |                     infect_asyncio=True, | ||||||
|                 ) |                 ) | ||||||
|             # should trigger remote actor error |                 # should raise RAE diectly | ||||||
|             await portal.result() |                 print('waiting on final infected subactor result..') | ||||||
|  |                 res: None = await portal.wait_for_result() | ||||||
|  |                 assert res is None | ||||||
|  |                 print('infected subactor returned result: {res!r}\n') | ||||||
| 
 | 
 | ||||||
|     # should be a quiet exit on a simple channel exit |     # should be a quiet exit on a simple channel exit | ||||||
|     trio.run(main) |     trio.run( | ||||||
|  |         main, | ||||||
|  |         # strict_exception_groups=False, | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_aio_errors_and_channel_propagates_and_closes(arb_addr): | def test_aio_errors_and_channel_propagates_and_closes(reg_addr): | ||||||
|     async def main(): |     async def main(): | ||||||
|         async with tractor.open_nursery() as n: |         async with tractor.open_nursery() as n: | ||||||
|             portal = await n.run_in_actor( |             portal = await n.run_in_actor( | ||||||
|  | @ -451,26 +571,23 @@ def test_aio_errors_and_channel_propagates_and_closes(arb_addr): | ||||||
|                 aio_raise_err=True, |                 aio_raise_err=True, | ||||||
|                 infect_asyncio=True, |                 infect_asyncio=True, | ||||||
|             ) |             ) | ||||||
|             # should trigger remote actor error |             # should trigger RAE directly, not an eg. | ||||||
|             await portal.result() |             await portal.result() | ||||||
| 
 | 
 | ||||||
|     with pytest.raises(BaseExceptionGroup) as excinfo: |     with pytest.raises( | ||||||
|  |         # NOTE: bc we directly wait on `Portal.result()` instead | ||||||
|  |         # of capturing it inside the `ActorNursery` machinery. | ||||||
|  |         expected_exception=RemoteActorError, | ||||||
|  |     ) as excinfo: | ||||||
|         trio.run(main) |         trio.run(main) | ||||||
| 
 | 
 | ||||||
|     # ensure boxed errors |     excinfo.value.boxed_type is Exception | ||||||
|     for exc in excinfo.value.exceptions: |  | ||||||
|         assert exc.type == Exception |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor.context | async def aio_echo_server( | ||||||
| async def trio_to_aio_echo_server( |  | ||||||
|     ctx: tractor.Context, |  | ||||||
| ): |  | ||||||
| 
 |  | ||||||
|     async def aio_echo_server( |  | ||||||
|     to_trio: trio.MemorySendChannel, |     to_trio: trio.MemorySendChannel, | ||||||
|     from_trio: asyncio.Queue, |     from_trio: asyncio.Queue, | ||||||
|     ) -> None: | ) -> None: | ||||||
| 
 | 
 | ||||||
|     to_trio.send_nowait('start') |     to_trio.send_nowait('start') | ||||||
| 
 | 
 | ||||||
|  | @ -488,15 +605,19 @@ async def trio_to_aio_echo_server( | ||||||
| 
 | 
 | ||||||
|     print('exiting asyncio task') |     print('exiting asyncio task') | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def trio_to_aio_echo_server( | ||||||
|  |     ctx: tractor.Context|None, | ||||||
|  | ): | ||||||
|     async with to_asyncio.open_channel_from( |     async with to_asyncio.open_channel_from( | ||||||
|         aio_echo_server, |         aio_echo_server, | ||||||
|     ) as (first, chan): |     ) as (first, chan): | ||||||
| 
 |  | ||||||
|         assert first == 'start' |         assert first == 'start' | ||||||
|  | 
 | ||||||
|         await ctx.started(first) |         await ctx.started(first) | ||||||
| 
 | 
 | ||||||
|         async with ctx.open_stream() as stream: |         async with ctx.open_stream() as stream: | ||||||
| 
 |  | ||||||
|             async for msg in stream: |             async for msg in stream: | ||||||
|                 print(f'asyncio echoing {msg}') |                 print(f'asyncio echoing {msg}') | ||||||
|                 await chan.send(msg) |                 await chan.send(msg) | ||||||
|  | @ -520,7 +641,7 @@ async def trio_to_aio_echo_server( | ||||||
|     ids='raise_error={}'.format, |     ids='raise_error={}'.format, | ||||||
| ) | ) | ||||||
| def test_echoserver_detailed_mechanics( | def test_echoserver_detailed_mechanics( | ||||||
|     arb_addr, |     reg_addr, | ||||||
|     raise_error_mid_stream, |     raise_error_mid_stream, | ||||||
| ): | ): | ||||||
| 
 | 
 | ||||||
|  | @ -560,7 +681,8 @@ def test_echoserver_detailed_mechanics( | ||||||
|                             pass |                             pass | ||||||
|                         else: |                         else: | ||||||
|                             pytest.fail( |                             pytest.fail( | ||||||
|                                 "stream wasn't stopped after sentinel?!") |                                 'stream not stopped after sentinel ?!' | ||||||
|  |                             ) | ||||||
| 
 | 
 | ||||||
|             # TODO: the case where this blocks and |             # TODO: the case where this blocks and | ||||||
|             # is cancelled by kbi or out of task cancellation |             # is cancelled by kbi or out of task cancellation | ||||||
|  | @ -572,3 +694,272 @@ def test_echoserver_detailed_mechanics( | ||||||
| 
 | 
 | ||||||
|     else: |     else: | ||||||
|         trio.run(main) |         trio.run(main) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context | ||||||
|  | async def manage_file( | ||||||
|  |     ctx: tractor.Context, | ||||||
|  |     tmp_path_str: str, | ||||||
|  |     send_sigint_to: str, | ||||||
|  |     trio_side_is_shielded: bool = True, | ||||||
|  |     bg_aio_task: bool = False, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Start an `asyncio` task that just sleeps after registering a context | ||||||
|  |     with `Actor.lifetime_stack`. Trigger a SIGINT to kill the actor tree | ||||||
|  |     and ensure the stack is closed in the infected mode child. | ||||||
|  | 
 | ||||||
|  |     To verify the teardown state just write a tmpfile to the `testdir` | ||||||
|  |     and delete it on actor close. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  | 
 | ||||||
|  |     tmp_path: Path = Path(tmp_path_str) | ||||||
|  |     tmp_file: Path = tmp_path / f'{" ".join(ctx._actor.uid)}.file' | ||||||
|  | 
 | ||||||
|  |     # create a the tmp file and tell the parent where it's at | ||||||
|  |     assert not tmp_file.is_file() | ||||||
|  |     tmp_file.touch() | ||||||
|  | 
 | ||||||
|  |     stack: ExitStack = current_actor().lifetime_stack | ||||||
|  |     stack.callback(tmp_file.unlink) | ||||||
|  | 
 | ||||||
|  |     await ctx.started(( | ||||||
|  |         str(tmp_file), | ||||||
|  |         os.getpid(), | ||||||
|  |     )) | ||||||
|  | 
 | ||||||
|  |     # expect to be cancelled from here! | ||||||
|  |     try: | ||||||
|  | 
 | ||||||
|  |         # NOTE: turns out you don't even need to sched an aio task | ||||||
|  |         # since the original issue, even though seemingly was due to | ||||||
|  |         # the guest-run being abandoned + a `._debug.pause()` inside | ||||||
|  |         # `._runtime._async_main()` (which was originally trying to | ||||||
|  |         # debug the `.lifetime_stack` not closing), IS NOT actually | ||||||
|  |         # the core issue? | ||||||
|  |         # | ||||||
|  |         # further notes: | ||||||
|  |         # | ||||||
|  |         # - `trio` only issues the " RuntimeWarning: Trio guest run | ||||||
|  |         #   got abandoned without properly finishing... weird stuff | ||||||
|  |         #   might happen" IFF you DO run a asyncio task here, BUT | ||||||
|  |         # - the original issue of the `.lifetime_stack` not closing | ||||||
|  |         #   will still happen even if you don't run an `asyncio` task | ||||||
|  |         #   here even though the "abandon" messgage won't be shown.. | ||||||
|  |         # | ||||||
|  |         # => ????? honestly i'm lost but it seems to be some issue | ||||||
|  |         #   with `asyncio` and SIGINT.. | ||||||
|  |         # | ||||||
|  |         # honestly, this REALLY reminds me why i haven't used | ||||||
|  |         # `asyncio` by choice in years.. XD | ||||||
|  |         # | ||||||
|  |         async with trio.open_nursery() as tn: | ||||||
|  |             if bg_aio_task: | ||||||
|  |                 tn.start_soon( | ||||||
|  |                     tractor.to_asyncio.run_task, | ||||||
|  |                     aio_sleep_forever, | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |             # XXX don't-need/doesn't-make-a-diff right | ||||||
|  |             # since we're already doing it from parent? | ||||||
|  |             # if send_sigint_to == 'child': | ||||||
|  |             #     os.kill( | ||||||
|  |             #         os.getpid(), | ||||||
|  |             #         signal.SIGINT, | ||||||
|  |             #     ) | ||||||
|  | 
 | ||||||
|  |             # XXX spend a half sec doing shielded checkpointing to | ||||||
|  |             # ensure that despite the `trio`-side task ignoring the | ||||||
|  |             # SIGINT, the `asyncio` side won't abandon the guest-run! | ||||||
|  |             if trio_side_is_shielded: | ||||||
|  |                 with trio.CancelScope(shield=True): | ||||||
|  |                     for i in range(5): | ||||||
|  |                         await trio.sleep(0.1) | ||||||
|  | 
 | ||||||
|  |             await trio.sleep_forever() | ||||||
|  | 
 | ||||||
|  |     # signalled manually at the OS level (aka KBI) by the parent actor. | ||||||
|  |     except KeyboardInterrupt: | ||||||
|  |         print('child raised KBI..') | ||||||
|  |         assert tmp_file.exists() | ||||||
|  |         raise | ||||||
|  | 
 | ||||||
|  |     raise RuntimeError('shoulda received a KBI?') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'trio_side_is_shielded', | ||||||
|  |     [ | ||||||
|  |         False, | ||||||
|  |         True, | ||||||
|  |     ], | ||||||
|  |     ids=[ | ||||||
|  |         'trio_side_no_shielding', | ||||||
|  |         'trio_side_does_shielded_work', | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'send_sigint_to', | ||||||
|  |     [ | ||||||
|  |         'child', | ||||||
|  |         'parent', | ||||||
|  |     ], | ||||||
|  |     ids='send_SIGINT_to={}'.format, | ||||||
|  | ) | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'bg_aio_task', | ||||||
|  |     [ | ||||||
|  |         False, | ||||||
|  | 
 | ||||||
|  |         # NOTE: (and see notes in `manage_file()` above as well) if | ||||||
|  |         # we FOR SURE SPAWN AN AIO TASK in the child it seems the | ||||||
|  |         # "silent-abandon" case (as is described in detail in | ||||||
|  |         # `to_asyncio.run_as_asyncio_guest()`) does not happen and | ||||||
|  |         # `asyncio`'s loop will at least abandon the `trio` side | ||||||
|  |         # loudly? .. prolly the state-spot to start looking for | ||||||
|  |         # a soln that results in NO ABANDONMENT.. XD | ||||||
|  |         True, | ||||||
|  |     ], | ||||||
|  |     ids=[ | ||||||
|  |         'bg_aio_task', | ||||||
|  |         'just_trio_slee', | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'wait_for_ctx', | ||||||
|  |     [ | ||||||
|  |         False, | ||||||
|  |         True, | ||||||
|  |     ], | ||||||
|  |     ids=[ | ||||||
|  |         'raise_KBI_in_rent', | ||||||
|  |         'wait_for_ctx', | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | def test_sigint_closes_lifetime_stack( | ||||||
|  |     tmp_path: Path, | ||||||
|  |     wait_for_ctx: bool, | ||||||
|  |     bg_aio_task: bool, | ||||||
|  |     trio_side_is_shielded: bool, | ||||||
|  |     debug_mode: bool, | ||||||
|  |     send_sigint_to: str, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Ensure that an infected child can use the `Actor.lifetime_stack` | ||||||
|  |     to make a file on boot and it's automatically cleaned up by the | ||||||
|  |     actor-lifetime-linked exit stack closure. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     async def main(): | ||||||
|  |         try: | ||||||
|  |             an: tractor.ActorNursery | ||||||
|  |             async with tractor.open_nursery( | ||||||
|  |                 debug_mode=debug_mode, | ||||||
|  |             ) as an: | ||||||
|  |                 p: tractor.Portal = await an.start_actor( | ||||||
|  |                     'file_mngr', | ||||||
|  |                     enable_modules=[__name__], | ||||||
|  |                     infect_asyncio=True, | ||||||
|  |                 ) | ||||||
|  |                 async with p.open_context( | ||||||
|  |                     manage_file, | ||||||
|  |                     tmp_path_str=str(tmp_path), | ||||||
|  |                     send_sigint_to=send_sigint_to, | ||||||
|  |                     bg_aio_task=bg_aio_task, | ||||||
|  |                     trio_side_is_shielded=trio_side_is_shielded, | ||||||
|  |                 ) as (ctx, first): | ||||||
|  | 
 | ||||||
|  |                     path_str, cpid = first | ||||||
|  |                     tmp_file: Path = Path(path_str) | ||||||
|  |                     assert tmp_file.exists() | ||||||
|  | 
 | ||||||
|  |                     # XXX originally to simulate what (hopefully) | ||||||
|  |                     # the below now triggers.. had to manually | ||||||
|  |                     # trigger a SIGINT from a ctl-c in the root. | ||||||
|  |                     # await trio.sleep_forever() | ||||||
|  | 
 | ||||||
|  |                     # XXX NOTE XXX signal infected-`asyncio` child to | ||||||
|  |                     # OS-cancel with SIGINT; this should trigger the | ||||||
|  |                     # bad `asyncio` cancel behaviour that can cause | ||||||
|  |                     # a guest-run abandon as was seen causing | ||||||
|  |                     # shm-buffer leaks in `piker`'s live quote stream | ||||||
|  |                     # susbys! | ||||||
|  |                     # | ||||||
|  |                     await trio.sleep(.2) | ||||||
|  |                     pid: int = ( | ||||||
|  |                         cpid if send_sigint_to == 'child' | ||||||
|  |                         else os.getpid() | ||||||
|  |                     ) | ||||||
|  |                     os.kill( | ||||||
|  |                         pid, | ||||||
|  |                         signal.SIGINT, | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                     # XXX CASE 1: without the bug fixed, in | ||||||
|  |                     # the non-KBI-raised-in-parent case, this | ||||||
|  |                     # timeout should trigger! | ||||||
|  |                     if wait_for_ctx: | ||||||
|  |                         print('waiting for ctx outcome in parent..') | ||||||
|  |                         try: | ||||||
|  |                             with trio.fail_after(1): | ||||||
|  |                                 await ctx.wait_for_result() | ||||||
|  |                         except tractor.ContextCancelled as ctxc: | ||||||
|  |                             assert ctxc.canceller == ctx.chan.uid | ||||||
|  |                             raise | ||||||
|  | 
 | ||||||
|  |                     # XXX CASE 2: this seems to be the source of the | ||||||
|  |                     # original issue which exhibited BEFORE we put | ||||||
|  |                     # a `Actor.cancel_soon()` inside | ||||||
|  |                     # `run_as_asyncio_guest()`.. | ||||||
|  |                     else: | ||||||
|  |                         raise KeyboardInterrupt | ||||||
|  | 
 | ||||||
|  |                 pytest.fail('should have raised some kinda error?!?') | ||||||
|  | 
 | ||||||
|  |         except ( | ||||||
|  |             KeyboardInterrupt, | ||||||
|  |             ContextCancelled, | ||||||
|  |         ): | ||||||
|  |             # XXX CASE 2: without the bug fixed, in the | ||||||
|  |             # KBI-raised-in-parent case, the actor teardown should | ||||||
|  |             # never get run (silently abaondoned by `asyncio`..) and | ||||||
|  |             # thus the file should leak! | ||||||
|  |             assert not tmp_file.exists() | ||||||
|  |             assert ctx.maybe_error | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: debug_mode tests once we get support for `asyncio`! | ||||||
|  | # | ||||||
|  | # -[ ] need tests to wrap both scripts: | ||||||
|  | #   - [ ] infected_asyncio_echo_server.py | ||||||
|  | #   - [ ] debugging/asyncio_bp.py | ||||||
|  | #  -[ ] consider moving ^ (some of) these ^ to `test_debugger`? | ||||||
|  | # | ||||||
|  | # -[ ] missing impl outstanding includes: | ||||||
|  | #  - [x] for sync pauses we need to ensure we open yet another | ||||||
|  | #    `greenback` portal in the asyncio task | ||||||
|  | #    => completed using `.bestow_portal(task)` inside | ||||||
|  | #     `.to_asyncio._run_asyncio_task()` right? | ||||||
|  | #   -[ ] translation func to get from `asyncio` task calling to  | ||||||
|  | #     `._debug.wait_for_parent_stdin_hijack()` which does root | ||||||
|  | #     call to do TTY locking. | ||||||
|  | # | ||||||
|  | def test_sync_breakpoint(): | ||||||
|  |     ''' | ||||||
|  |     Verify we can do sync-func/code breakpointing using the | ||||||
|  |     `breakpoint()` builtin inside infected mode actors. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     pytest.xfail('This support is not implemented yet!') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_debug_mode_crash_handling(): | ||||||
|  |     ''' | ||||||
|  |     Verify mult-actor crash handling works with a combo of infected-`asyncio`-mode | ||||||
|  |     and normal `trio` actors despite nested process trees. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     pytest.xfail('This support is not implemented yet!') | ||||||
|  |  | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -9,7 +9,7 @@ import trio | ||||||
| import tractor | import tractor | ||||||
| import pytest | import pytest | ||||||
| 
 | 
 | ||||||
| from conftest import tractor_test | from tractor._testing import tractor_test | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_must_define_ctx(): | def test_must_define_ctx(): | ||||||
|  | @ -38,10 +38,13 @@ async def async_gen_stream(sequence): | ||||||
|     assert cs.cancelled_caught |     assert cs.cancelled_caught | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | # TODO: deprecated either remove entirely | ||||||
|  | # or re-impl in terms of `MsgStream` one-sides | ||||||
|  | # wrapper, but at least remove `Portal.open_stream_from()` | ||||||
| @tractor.stream | @tractor.stream | ||||||
| async def context_stream( | async def context_stream( | ||||||
|     ctx: tractor.Context, |     ctx: tractor.Context, | ||||||
|     sequence |     sequence: list[int], | ||||||
| ): | ): | ||||||
|     for i in sequence: |     for i in sequence: | ||||||
|         await ctx.send_yield(i) |         await ctx.send_yield(i) | ||||||
|  | @ -55,7 +58,7 @@ async def context_stream( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def stream_from_single_subactor( | async def stream_from_single_subactor( | ||||||
|     arb_addr, |     reg_addr, | ||||||
|     start_method, |     start_method, | ||||||
|     stream_func, |     stream_func, | ||||||
| ): | ): | ||||||
|  | @ -64,7 +67,7 @@ async def stream_from_single_subactor( | ||||||
|     # only one per host address, spawns an actor if None |     # only one per host address, spawns an actor if None | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         arbiter_addr=arb_addr, |         registry_addrs=[reg_addr], | ||||||
|         start_method=start_method, |         start_method=start_method, | ||||||
|     ) as nursery: |     ) as nursery: | ||||||
| 
 | 
 | ||||||
|  | @ -115,13 +118,13 @@ async def stream_from_single_subactor( | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     'stream_func', [async_gen_stream, context_stream] |     'stream_func', [async_gen_stream, context_stream] | ||||||
| ) | ) | ||||||
| def test_stream_from_single_subactor(arb_addr, start_method, stream_func): | def test_stream_from_single_subactor(reg_addr, start_method, stream_func): | ||||||
|     """Verify streaming from a spawned async generator. |     """Verify streaming from a spawned async generator. | ||||||
|     """ |     """ | ||||||
|     trio.run( |     trio.run( | ||||||
|         partial( |         partial( | ||||||
|             stream_from_single_subactor, |             stream_from_single_subactor, | ||||||
|             arb_addr, |             reg_addr, | ||||||
|             start_method, |             start_method, | ||||||
|             stream_func=stream_func, |             stream_func=stream_func, | ||||||
|         ), |         ), | ||||||
|  | @ -225,14 +228,14 @@ async def a_quadruple_example(): | ||||||
|         return result_stream |         return result_stream | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def cancel_after(wait, arb_addr): | async def cancel_after(wait, reg_addr): | ||||||
|     async with tractor.open_root_actor(arbiter_addr=arb_addr): |     async with tractor.open_root_actor(registry_addrs=[reg_addr]): | ||||||
|         with trio.move_on_after(wait): |         with trio.move_on_after(wait): | ||||||
|             return await a_quadruple_example() |             return await a_quadruple_example() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.fixture(scope='module') | @pytest.fixture(scope='module') | ||||||
| def time_quad_ex(arb_addr, ci_env, spawn_backend): | def time_quad_ex(reg_addr, ci_env, spawn_backend): | ||||||
|     if spawn_backend == 'mp': |     if spawn_backend == 'mp': | ||||||
|         """no idea but the  mp *nix runs are flaking out here often... |         """no idea but the  mp *nix runs are flaking out here often... | ||||||
|         """ |         """ | ||||||
|  | @ -240,7 +243,7 @@ def time_quad_ex(arb_addr, ci_env, spawn_backend): | ||||||
| 
 | 
 | ||||||
|     timeout = 7 if platform.system() in ('Windows', 'Darwin') else 4 |     timeout = 7 if platform.system() in ('Windows', 'Darwin') else 4 | ||||||
|     start = time.time() |     start = time.time() | ||||||
|     results = trio.run(cancel_after, timeout, arb_addr) |     results = trio.run(cancel_after, timeout, reg_addr) | ||||||
|     diff = time.time() - start |     diff = time.time() - start | ||||||
|     assert results |     assert results | ||||||
|     return results, diff |     return results, diff | ||||||
|  | @ -260,14 +263,14 @@ def test_a_quadruple_example(time_quad_ex, ci_env, spawn_backend): | ||||||
|     list(map(lambda i: i/10, range(3, 9))) |     list(map(lambda i: i/10, range(3, 9))) | ||||||
| ) | ) | ||||||
| def test_not_fast_enough_quad( | def test_not_fast_enough_quad( | ||||||
|     arb_addr, time_quad_ex, cancel_delay, ci_env, spawn_backend |     reg_addr, time_quad_ex, cancel_delay, ci_env, spawn_backend | ||||||
| ): | ): | ||||||
|     """Verify we can cancel midway through the quad example and all actors |     """Verify we can cancel midway through the quad example and all actors | ||||||
|     cancel gracefully. |     cancel gracefully. | ||||||
|     """ |     """ | ||||||
|     results, diff = time_quad_ex |     results, diff = time_quad_ex | ||||||
|     delay = max(diff - cancel_delay, 0) |     delay = max(diff - cancel_delay, 0) | ||||||
|     results = trio.run(cancel_after, delay, arb_addr) |     results = trio.run(cancel_after, delay, reg_addr) | ||||||
|     system = platform.system() |     system = platform.system() | ||||||
|     if system in ('Windows', 'Darwin') and results is not None: |     if system in ('Windows', 'Darwin') and results is not None: | ||||||
|         # In CI envoirments it seems later runs are quicker then the first |         # In CI envoirments it seems later runs are quicker then the first | ||||||
|  | @ -280,7 +283,7 @@ def test_not_fast_enough_quad( | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_respawn_consumer_task( | async def test_respawn_consumer_task( | ||||||
|     arb_addr, |     reg_addr, | ||||||
|     spawn_backend, |     spawn_backend, | ||||||
|     loglevel, |     loglevel, | ||||||
| ): | ): | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ import pytest | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
| 
 | 
 | ||||||
| from conftest import tractor_test | from tractor._testing import tractor_test | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.trio | @pytest.mark.trio | ||||||
|  | @ -24,7 +24,7 @@ async def test_no_runtime(): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_self_is_registered(arb_addr): | async def test_self_is_registered(reg_addr): | ||||||
|     "Verify waiting on the arbiter to register itself using the standard api." |     "Verify waiting on the arbiter to register itself using the standard api." | ||||||
|     actor = tractor.current_actor() |     actor = tractor.current_actor() | ||||||
|     assert actor.is_arbiter |     assert actor.is_arbiter | ||||||
|  | @ -34,20 +34,20 @@ async def test_self_is_registered(arb_addr): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_self_is_registered_localportal(arb_addr): | async def test_self_is_registered_localportal(reg_addr): | ||||||
|     "Verify waiting on the arbiter to register itself using a local portal." |     "Verify waiting on the arbiter to register itself using a local portal." | ||||||
|     actor = tractor.current_actor() |     actor = tractor.current_actor() | ||||||
|     assert actor.is_arbiter |     assert actor.is_arbiter | ||||||
|     async with tractor.get_arbiter(*arb_addr) as portal: |     async with tractor.get_registry(*reg_addr) as portal: | ||||||
|         assert isinstance(portal, tractor._portal.LocalPortal) |         assert isinstance(portal, tractor._portal.LocalPortal) | ||||||
| 
 | 
 | ||||||
|         with trio.fail_after(0.2): |         with trio.fail_after(0.2): | ||||||
|             sockaddr = await portal.run_from_ns( |             sockaddr = await portal.run_from_ns( | ||||||
|                     'self', 'wait_for_actor', name='root') |                     'self', 'wait_for_actor', name='root') | ||||||
|             assert sockaddr[0] == arb_addr |             assert sockaddr[0] == reg_addr | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_local_actor_async_func(arb_addr): | def test_local_actor_async_func(reg_addr): | ||||||
|     """Verify a simple async function in-process. |     """Verify a simple async function in-process. | ||||||
|     """ |     """ | ||||||
|     nums = [] |     nums = [] | ||||||
|  | @ -55,7 +55,7 @@ def test_local_actor_async_func(arb_addr): | ||||||
|     async def print_loop(): |     async def print_loop(): | ||||||
| 
 | 
 | ||||||
|         async with tractor.open_root_actor( |         async with tractor.open_root_actor( | ||||||
|             arbiter_addr=arb_addr, |             registry_addrs=[reg_addr], | ||||||
|         ): |         ): | ||||||
|             # arbiter is started in-proc if dne |             # arbiter is started in-proc if dne | ||||||
|             assert tractor.current_actor().is_arbiter |             assert tractor.current_actor().is_arbiter | ||||||
|  |  | ||||||
|  | @ -7,8 +7,10 @@ import time | ||||||
| import pytest | import pytest | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
| from conftest import ( | from tractor._testing import ( | ||||||
|     tractor_test, |     tractor_test, | ||||||
|  | ) | ||||||
|  | from .conftest import ( | ||||||
|     sig_prog, |     sig_prog, | ||||||
|     _INT_SIGNAL, |     _INT_SIGNAL, | ||||||
|     _INT_RETURN_CODE, |     _INT_RETURN_CODE, | ||||||
|  | @ -28,9 +30,9 @@ def test_abort_on_sigint(daemon): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_cancel_remote_arbiter(daemon, arb_addr): | async def test_cancel_remote_arbiter(daemon, reg_addr): | ||||||
|     assert not tractor.current_actor().is_arbiter |     assert not tractor.current_actor().is_arbiter | ||||||
|     async with tractor.get_arbiter(*arb_addr) as portal: |     async with tractor.get_registry(*reg_addr) as portal: | ||||||
|         await portal.cancel_actor() |         await portal.cancel_actor() | ||||||
| 
 | 
 | ||||||
|     time.sleep(0.1) |     time.sleep(0.1) | ||||||
|  | @ -39,16 +41,16 @@ async def test_cancel_remote_arbiter(daemon, arb_addr): | ||||||
| 
 | 
 | ||||||
|     # no arbiter socket should exist |     # no arbiter socket should exist | ||||||
|     with pytest.raises(OSError): |     with pytest.raises(OSError): | ||||||
|         async with tractor.get_arbiter(*arb_addr) as portal: |         async with tractor.get_registry(*reg_addr) as portal: | ||||||
|             pass |             pass | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_register_duplicate_name(daemon, arb_addr): | def test_register_duplicate_name(daemon, reg_addr): | ||||||
| 
 | 
 | ||||||
|     async def main(): |     async def main(): | ||||||
| 
 | 
 | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             arbiter_addr=arb_addr, |             registry_addrs=[reg_addr], | ||||||
|         ) as n: |         ) as n: | ||||||
| 
 | 
 | ||||||
|             assert not tractor.current_actor().is_arbiter |             assert not tractor.current_actor().is_arbiter | ||||||
|  |  | ||||||
|  | @ -0,0 +1,364 @@ | ||||||
|  | ''' | ||||||
|  | Audit sub-sys APIs from `.msg._ops` | ||||||
|  | mostly for ensuring correct `contextvars` | ||||||
|  | related settings around IPC contexts. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from msgspec import ( | ||||||
|  |     Struct, | ||||||
|  | ) | ||||||
|  | import pytest | ||||||
|  | import trio | ||||||
|  | 
 | ||||||
|  | import tractor | ||||||
|  | from tractor import ( | ||||||
|  |     Context, | ||||||
|  |     MsgTypeError, | ||||||
|  |     current_ipc_ctx, | ||||||
|  |     Portal, | ||||||
|  | ) | ||||||
|  | from tractor.msg import ( | ||||||
|  |     _ops as msgops, | ||||||
|  |     Return, | ||||||
|  | ) | ||||||
|  | from tractor.msg import ( | ||||||
|  |     _codec, | ||||||
|  | ) | ||||||
|  | from tractor.msg.types import ( | ||||||
|  |     log, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class PldMsg( | ||||||
|  |     Struct, | ||||||
|  | 
 | ||||||
|  |     # TODO: with multiple structs in-spec we need to tag them! | ||||||
|  |     # -[ ] offer a built-in `PldMsg` type to inherit from which takes | ||||||
|  |     #      case of these details? | ||||||
|  |     # | ||||||
|  |     # https://jcristharif.com/msgspec/structs.html#tagged-unions | ||||||
|  |     # tag=True, | ||||||
|  |     # tag_field='msg_type', | ||||||
|  | ): | ||||||
|  |     field: str | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | maybe_msg_spec = PldMsg|None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @acm | ||||||
|  | async def maybe_expect_raises( | ||||||
|  |     raises: BaseException|None = None, | ||||||
|  |     ensure_in_message: list[str]|None = None, | ||||||
|  |     post_mortem: bool = False, | ||||||
|  |     timeout: int = 3, | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     Async wrapper for ensuring errors propagate from the inner scope. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     if tractor._state.debug_mode(): | ||||||
|  |         timeout += 999 | ||||||
|  | 
 | ||||||
|  |     with trio.fail_after(timeout): | ||||||
|  |         try: | ||||||
|  |             yield | ||||||
|  |         except BaseException as _inner_err: | ||||||
|  |             inner_err = _inner_err | ||||||
|  |             # wasn't-expected to error.. | ||||||
|  |             if raises is None: | ||||||
|  |                 raise | ||||||
|  | 
 | ||||||
|  |             else: | ||||||
|  |                 assert type(inner_err) is raises | ||||||
|  | 
 | ||||||
|  |                 # maybe check for error txt content | ||||||
|  |                 if ensure_in_message: | ||||||
|  |                     part: str | ||||||
|  |                     err_repr: str = repr(inner_err) | ||||||
|  |                     for part in ensure_in_message: | ||||||
|  |                         for i, arg in enumerate(inner_err.args): | ||||||
|  |                             if part in err_repr: | ||||||
|  |                                 break | ||||||
|  |                         # if part never matches an arg, then we're | ||||||
|  |                         # missing a match. | ||||||
|  |                         else: | ||||||
|  |                             raise ValueError( | ||||||
|  |                                 'Failed to find error message content?\n\n' | ||||||
|  |                                 f'expected: {ensure_in_message!r}\n' | ||||||
|  |                                 f'part: {part!r}\n\n' | ||||||
|  |                                 f'{inner_err.args}' | ||||||
|  |                         ) | ||||||
|  | 
 | ||||||
|  |                 if post_mortem: | ||||||
|  |                     await tractor.post_mortem() | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             if raises: | ||||||
|  |                 raise RuntimeError( | ||||||
|  |                     f'Expected a {raises.__name__!r} to be raised?' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @tractor.context( | ||||||
|  |     pld_spec=maybe_msg_spec, | ||||||
|  | ) | ||||||
|  | async def child( | ||||||
|  |     ctx: Context, | ||||||
|  |     started_value: int|PldMsg|None, | ||||||
|  |     return_value: str|None, | ||||||
|  |     validate_pld_spec: bool, | ||||||
|  |     raise_on_started_mte: bool = True, | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     Call ``Context.started()`` more then once (an error). | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     expect_started_mte: bool = started_value == 10 | ||||||
|  | 
 | ||||||
|  |     # sanaity check that child RPC context is the current one | ||||||
|  |     curr_ctx: Context = current_ipc_ctx() | ||||||
|  |     assert ctx is curr_ctx | ||||||
|  | 
 | ||||||
|  |     rx: msgops.PldRx = ctx._pld_rx | ||||||
|  |     curr_pldec: _codec.MsgDec = rx.pld_dec | ||||||
|  | 
 | ||||||
|  |     ctx_meta: dict = getattr( | ||||||
|  |         child, | ||||||
|  |         '_tractor_context_meta', | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  |     if ctx_meta: | ||||||
|  |         assert ( | ||||||
|  |             ctx_meta['pld_spec'] | ||||||
|  |             is curr_pldec.spec | ||||||
|  |             is curr_pldec.pld_spec | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # 2 cases: hdndle send-side and recv-only validation | ||||||
|  |     # - when `raise_on_started_mte == True`, send validate | ||||||
|  |     # - else, parent-recv-side only validation | ||||||
|  |     mte: MsgTypeError|None = None | ||||||
|  |     try: | ||||||
|  |         await ctx.started( | ||||||
|  |             value=started_value, | ||||||
|  |             validate_pld_spec=validate_pld_spec, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     except MsgTypeError as _mte: | ||||||
|  |         mte = _mte | ||||||
|  |         log.exception('started()` raised an MTE!\n') | ||||||
|  |         if not expect_started_mte: | ||||||
|  |             raise RuntimeError( | ||||||
|  |                 'Child-ctx-task SHOULD NOT HAVE raised an MTE for\n\n' | ||||||
|  |                 f'{started_value!r}\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         boxed_div: str = '------ - ------' | ||||||
|  |         assert boxed_div not in mte._message | ||||||
|  |         assert boxed_div not in mte.tb_str | ||||||
|  |         assert boxed_div not in repr(mte) | ||||||
|  |         assert boxed_div not in str(mte) | ||||||
|  |         mte_repr: str = repr(mte) | ||||||
|  |         for line in mte.message.splitlines(): | ||||||
|  |             assert line in mte_repr | ||||||
|  | 
 | ||||||
|  |         # since this is a *local error* there should be no | ||||||
|  |         # boxed traceback content! | ||||||
|  |         assert not mte.tb_str | ||||||
|  | 
 | ||||||
|  |         # propagate to parent? | ||||||
|  |         if raise_on_started_mte: | ||||||
|  |             raise | ||||||
|  | 
 | ||||||
|  |     # no-send-side-error fallthrough | ||||||
|  |     if ( | ||||||
|  |         validate_pld_spec | ||||||
|  |         and | ||||||
|  |         expect_started_mte | ||||||
|  |     ): | ||||||
|  |         raise RuntimeError( | ||||||
|  |             'Child-ctx-task SHOULD HAVE raised an MTE for\n\n' | ||||||
|  |             f'{started_value!r}\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     assert ( | ||||||
|  |         not expect_started_mte | ||||||
|  |         or | ||||||
|  |         not validate_pld_spec | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # if wait_for_parent_to_cancel: | ||||||
|  |     #     ... | ||||||
|  |     # | ||||||
|  |     # ^-TODO-^ logic for diff validation policies on each side: | ||||||
|  |     # | ||||||
|  |     # -[ ] ensure that if we don't validate on the send | ||||||
|  |     #   side, that we are eventually error-cancelled by our | ||||||
|  |     #   parent due to the bad `Started` payload! | ||||||
|  |     # -[ ] the boxed error should be srced from the parent's | ||||||
|  |     #   runtime NOT ours! | ||||||
|  |     # -[ ] we should still error on bad `return_value`s | ||||||
|  |     #   despite the parent not yet error-cancelling us? | ||||||
|  |     #   |_ how do we want the parent side to look in that | ||||||
|  |     #     case? | ||||||
|  |     #     -[ ] maybe the equiv of "during handling of the | ||||||
|  |     #       above error another occurred" for the case where | ||||||
|  |     #       the parent sends a MTE to this child and while | ||||||
|  |     #       waiting for the child to terminate it gets back | ||||||
|  |     #       the MTE for this case? | ||||||
|  |     # | ||||||
|  | 
 | ||||||
|  |     # XXX should always fail on recv side since we can't | ||||||
|  |     # really do much else beside terminate and relay the | ||||||
|  |     # msg-type-error from this RPC task ;) | ||||||
|  |     return return_value | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'return_value', | ||||||
|  |     [ | ||||||
|  |         'yo', | ||||||
|  |         None, | ||||||
|  |     ], | ||||||
|  |     ids=[ | ||||||
|  |         'return[invalid-"yo"]', | ||||||
|  |         'return[valid-None]', | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'started_value', | ||||||
|  |     [ | ||||||
|  |         10, | ||||||
|  |         PldMsg(field='yo'), | ||||||
|  |     ], | ||||||
|  |     ids=[ | ||||||
|  |         'Started[invalid-10]', | ||||||
|  |         'Started[valid-PldMsg]', | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'pld_check_started_value', | ||||||
|  |     [ | ||||||
|  |         True, | ||||||
|  |         False, | ||||||
|  |     ], | ||||||
|  |     ids=[ | ||||||
|  |         'check-started-pld', | ||||||
|  |         'no-started-pld-validate', | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | def test_basic_payload_spec( | ||||||
|  |     debug_mode: bool, | ||||||
|  |     loglevel: str, | ||||||
|  |     return_value: str|None, | ||||||
|  |     started_value: int|PldMsg, | ||||||
|  |     pld_check_started_value: bool, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Validate the most basic `PldRx` msg-type-spec semantics around | ||||||
|  |     a IPC `Context` endpoint start, started-sync, and final return | ||||||
|  |     value depending on set payload types and the currently applied | ||||||
|  |     pld-spec. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     invalid_return: bool = return_value == 'yo' | ||||||
|  |     invalid_started: bool = started_value == 10 | ||||||
|  | 
 | ||||||
|  |     async def main(): | ||||||
|  |         async with tractor.open_nursery( | ||||||
|  |             debug_mode=debug_mode, | ||||||
|  |             loglevel=loglevel, | ||||||
|  |         ) as an: | ||||||
|  |             p: Portal = await an.start_actor( | ||||||
|  |                 'child', | ||||||
|  |                 enable_modules=[__name__], | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             # since not opened yet. | ||||||
|  |             assert current_ipc_ctx() is None | ||||||
|  | 
 | ||||||
|  |             if invalid_started: | ||||||
|  |                 msg_type_str: str = 'Started' | ||||||
|  |                 bad_value: int = 10 | ||||||
|  |             elif invalid_return: | ||||||
|  |                 msg_type_str: str = 'Return' | ||||||
|  |                 bad_value: str = 'yo' | ||||||
|  |             else: | ||||||
|  |                 # XXX but should never be used below then.. | ||||||
|  |                 msg_type_str: str = '' | ||||||
|  |                 bad_value: str = '' | ||||||
|  | 
 | ||||||
|  |             maybe_mte: MsgTypeError|None = None | ||||||
|  |             should_raise: Exception|None = ( | ||||||
|  |                 MsgTypeError if ( | ||||||
|  |                     invalid_return | ||||||
|  |                     or | ||||||
|  |                     invalid_started | ||||||
|  |                 ) else None | ||||||
|  |             ) | ||||||
|  |             async with ( | ||||||
|  |                 maybe_expect_raises( | ||||||
|  |                     raises=should_raise, | ||||||
|  |                     ensure_in_message=[ | ||||||
|  |                         f"invalid `{msg_type_str}` msg payload", | ||||||
|  |                         f'{bad_value}', | ||||||
|  |                         f'has type {type(bad_value)!r}', | ||||||
|  |                         'not match type-spec', | ||||||
|  |                         f'`{msg_type_str}.pld: PldMsg|NoneType`', | ||||||
|  |                     ], | ||||||
|  |                     # only for debug | ||||||
|  |                     # post_mortem=True, | ||||||
|  |                 ), | ||||||
|  |                 p.open_context( | ||||||
|  |                     child, | ||||||
|  |                     return_value=return_value, | ||||||
|  |                     started_value=started_value, | ||||||
|  |                     validate_pld_spec=pld_check_started_value, | ||||||
|  |                 ) as (ctx, first), | ||||||
|  |             ): | ||||||
|  |                 # now opened with 'child' sub | ||||||
|  |                 assert current_ipc_ctx() is ctx | ||||||
|  | 
 | ||||||
|  |                 assert type(first) is PldMsg | ||||||
|  |                 assert first.field == 'yo' | ||||||
|  | 
 | ||||||
|  |                 try: | ||||||
|  |                     res: None|PldMsg = await ctx.result(hide_tb=False) | ||||||
|  |                     assert res is None | ||||||
|  |                 except MsgTypeError as mte: | ||||||
|  |                     maybe_mte = mte | ||||||
|  |                     if not invalid_return: | ||||||
|  |                         raise | ||||||
|  | 
 | ||||||
|  |                     # expected this invalid `Return.pld` so audit | ||||||
|  |                     # the error state + meta-data | ||||||
|  |                     assert mte.expected_msg_type is Return | ||||||
|  |                     assert mte.cid == ctx.cid | ||||||
|  |                     mte_repr: str = repr(mte) | ||||||
|  |                     for line in mte.message.splitlines(): | ||||||
|  |                         assert line in mte_repr | ||||||
|  | 
 | ||||||
|  |                     assert mte.tb_str | ||||||
|  |                     # await tractor.pause(shield=True) | ||||||
|  | 
 | ||||||
|  |                     # verify expected remote mte deats | ||||||
|  |                     assert ctx._local_error is None | ||||||
|  |                     assert ( | ||||||
|  |                         mte is | ||||||
|  |                         ctx._remote_error is | ||||||
|  |                         ctx.maybe_error is | ||||||
|  |                         ctx.outcome | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |             if should_raise is None: | ||||||
|  |                 assert maybe_mte is None | ||||||
|  | 
 | ||||||
|  |             await p.cancel_actor() | ||||||
|  | 
 | ||||||
|  |     trio.run(main) | ||||||
|  | @ -5,8 +5,7 @@ import pytest | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
| from tractor.experimental import msgpub | from tractor.experimental import msgpub | ||||||
| 
 | from tractor._testing import tractor_test | ||||||
| from conftest import tractor_test |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_type_checks(): | def test_type_checks(): | ||||||
|  | @ -160,7 +159,7 @@ async def test_required_args(callwith_expecterror): | ||||||
| ) | ) | ||||||
| def test_multi_actor_subs_arbiter_pub( | def test_multi_actor_subs_arbiter_pub( | ||||||
|     loglevel, |     loglevel, | ||||||
|     arb_addr, |     reg_addr, | ||||||
|     pub_actor, |     pub_actor, | ||||||
| ): | ): | ||||||
|     """Try out the neato @pub decorator system. |     """Try out the neato @pub decorator system. | ||||||
|  | @ -170,7 +169,7 @@ def test_multi_actor_subs_arbiter_pub( | ||||||
|     async def main(): |     async def main(): | ||||||
| 
 | 
 | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             arbiter_addr=arb_addr, |             registry_addrs=[reg_addr], | ||||||
|             enable_modules=[__name__], |             enable_modules=[__name__], | ||||||
|         ) as n: |         ) as n: | ||||||
| 
 | 
 | ||||||
|  | @ -255,12 +254,12 @@ def test_multi_actor_subs_arbiter_pub( | ||||||
| 
 | 
 | ||||||
| def test_single_subactor_pub_multitask_subs( | def test_single_subactor_pub_multitask_subs( | ||||||
|     loglevel, |     loglevel, | ||||||
|     arb_addr, |     reg_addr, | ||||||
| ): | ): | ||||||
|     async def main(): |     async def main(): | ||||||
| 
 | 
 | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             arbiter_addr=arb_addr, |             registry_addrs=[reg_addr], | ||||||
|             enable_modules=[__name__], |             enable_modules=[__name__], | ||||||
|         ) as n: |         ) as n: | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -34,7 +34,6 @@ def test_resource_only_entered_once(key_on): | ||||||
|     global _resource |     global _resource | ||||||
|     _resource = 0 |     _resource = 0 | ||||||
| 
 | 
 | ||||||
|     kwargs = {} |  | ||||||
|     key = None |     key = None | ||||||
|     if key_on == 'key_value': |     if key_on == 'key_value': | ||||||
|         key = 'some_common_key' |         key = 'some_common_key' | ||||||
|  | @ -139,7 +138,7 @@ def test_open_local_sub_to_stream(): | ||||||
|     N local tasks using ``trionics.maybe_open_context():``. |     N local tasks using ``trionics.maybe_open_context():``. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     timeout = 3 if platform.system() != "Windows" else 10 |     timeout: float = 3.6 if platform.system() != "Windows" else 10 | ||||||
| 
 | 
 | ||||||
|     async def main(): |     async def main(): | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,244 @@ | ||||||
|  | ''' | ||||||
|  | Special attention cases for using "infect `asyncio`" mode from a root | ||||||
|  | actor; i.e. not using a std `trio.run()` bootstrap. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | import asyncio | ||||||
|  | from functools import partial | ||||||
|  | 
 | ||||||
|  | import pytest | ||||||
|  | import trio | ||||||
|  | import tractor | ||||||
|  | from tractor import ( | ||||||
|  |     to_asyncio, | ||||||
|  | ) | ||||||
|  | from tests.test_infected_asyncio import ( | ||||||
|  |     aio_echo_server, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'raise_error_mid_stream', | ||||||
|  |     [ | ||||||
|  |         False, | ||||||
|  |         Exception, | ||||||
|  |         KeyboardInterrupt, | ||||||
|  |     ], | ||||||
|  |     ids='raise_error={}'.format, | ||||||
|  | ) | ||||||
|  | def test_infected_root_actor( | ||||||
|  |     raise_error_mid_stream: bool|Exception, | ||||||
|  | 
 | ||||||
|  |     # conftest wide | ||||||
|  |     loglevel: str, | ||||||
|  |     debug_mode: bool, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Verify you can run the `tractor` runtime with `Actor.is_infected_aio() == True` | ||||||
|  |     in the root actor. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     async def _trio_main(): | ||||||
|  |         with trio.fail_after(2): | ||||||
|  |             first: str | ||||||
|  |             chan: to_asyncio.LinkedTaskChannel | ||||||
|  |             async with ( | ||||||
|  |                 tractor.open_root_actor( | ||||||
|  |                     debug_mode=debug_mode, | ||||||
|  |                     loglevel=loglevel, | ||||||
|  |                 ), | ||||||
|  |                 to_asyncio.open_channel_from( | ||||||
|  |                     aio_echo_server, | ||||||
|  |                 ) as (first, chan), | ||||||
|  |             ): | ||||||
|  |                 assert first == 'start' | ||||||
|  | 
 | ||||||
|  |                 for i in range(1000): | ||||||
|  |                     await chan.send(i) | ||||||
|  |                     out = await chan.receive() | ||||||
|  |                     assert out == i | ||||||
|  |                     print(f'asyncio echoing {i}') | ||||||
|  | 
 | ||||||
|  |                     if raise_error_mid_stream and i == 500: | ||||||
|  |                         raise raise_error_mid_stream | ||||||
|  | 
 | ||||||
|  |                     if out is None: | ||||||
|  |                         try: | ||||||
|  |                             out = await chan.receive() | ||||||
|  |                         except trio.EndOfChannel: | ||||||
|  |                             break | ||||||
|  |                         else: | ||||||
|  |                             raise RuntimeError( | ||||||
|  |                                 'aio channel never stopped?' | ||||||
|  |                             ) | ||||||
|  | 
 | ||||||
|  |     if raise_error_mid_stream: | ||||||
|  |         with pytest.raises(raise_error_mid_stream): | ||||||
|  |             tractor.to_asyncio.run_as_asyncio_guest( | ||||||
|  |                 trio_main=_trio_main, | ||||||
|  |             ) | ||||||
|  |     else: | ||||||
|  |         tractor.to_asyncio.run_as_asyncio_guest( | ||||||
|  |             trio_main=_trio_main, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def sync_and_err( | ||||||
|  |     # just signature placeholders for compat with | ||||||
|  |     # ``to_asyncio.open_channel_from()`` | ||||||
|  |     to_trio: trio.MemorySendChannel, | ||||||
|  |     from_trio: asyncio.Queue, | ||||||
|  |     ev: asyncio.Event, | ||||||
|  | 
 | ||||||
|  | ): | ||||||
|  |     if to_trio: | ||||||
|  |         to_trio.send_nowait('start') | ||||||
|  | 
 | ||||||
|  |     await ev.wait() | ||||||
|  |     raise RuntimeError('asyncio-side') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     'aio_err_trigger', | ||||||
|  |     [ | ||||||
|  |         'before_start_point', | ||||||
|  |         'after_trio_task_starts', | ||||||
|  |         'after_start_point', | ||||||
|  |     ], | ||||||
|  |     ids='aio_err_triggered={}'.format | ||||||
|  | ) | ||||||
|  | def test_trio_prestarted_task_bubbles( | ||||||
|  |     aio_err_trigger: str, | ||||||
|  | 
 | ||||||
|  |     # conftest wide | ||||||
|  |     loglevel: str, | ||||||
|  |     debug_mode: bool, | ||||||
|  | ): | ||||||
|  |     async def pre_started_err( | ||||||
|  |         raise_err: bool = False, | ||||||
|  |         pre_sleep: float|None = None, | ||||||
|  |         aio_trigger: asyncio.Event|None = None, | ||||||
|  |         task_status=trio.TASK_STATUS_IGNORED, | ||||||
|  |     ): | ||||||
|  |         ''' | ||||||
|  |         Maybe pre-started error then sleep. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         if pre_sleep is not None: | ||||||
|  |             print(f'Sleeping from trio for {pre_sleep!r}s !') | ||||||
|  |             await trio.sleep(pre_sleep) | ||||||
|  | 
 | ||||||
|  |         # signal aio-task to raise JUST AFTER this task | ||||||
|  |         # starts but has not yet `.started()` | ||||||
|  |         if aio_trigger: | ||||||
|  |             print('Signalling aio-task to raise from `trio`!!') | ||||||
|  |             aio_trigger.set() | ||||||
|  | 
 | ||||||
|  |         if raise_err: | ||||||
|  |             print('Raising from trio!') | ||||||
|  |             raise TypeError('trio-side') | ||||||
|  | 
 | ||||||
|  |         task_status.started() | ||||||
|  |         await trio.sleep_forever() | ||||||
|  | 
 | ||||||
|  |     async def _trio_main(): | ||||||
|  |         # with trio.fail_after(2): | ||||||
|  |         with trio.fail_after(999): | ||||||
|  |             first: str | ||||||
|  |             chan: to_asyncio.LinkedTaskChannel | ||||||
|  |             aio_ev = asyncio.Event() | ||||||
|  | 
 | ||||||
|  |             async with ( | ||||||
|  |                 tractor.open_root_actor( | ||||||
|  |                     debug_mode=False, | ||||||
|  |                     loglevel=loglevel, | ||||||
|  |                 ), | ||||||
|  |             ): | ||||||
|  |                 # TODO, tests for this with 3.13 egs? | ||||||
|  |                 # from tractor.devx import open_crash_handler | ||||||
|  |                 # with open_crash_handler(): | ||||||
|  |                 async with ( | ||||||
|  |                     # where we'll start a sub-task that errors BEFORE | ||||||
|  |                     # calling `.started()` such that the error should | ||||||
|  |                     # bubble before the guest run terminates! | ||||||
|  |                     trio.open_nursery() as tn, | ||||||
|  | 
 | ||||||
|  |                     # THEN start an infect task which should error just | ||||||
|  |                     # after the trio-side's task does. | ||||||
|  |                     to_asyncio.open_channel_from( | ||||||
|  |                         partial( | ||||||
|  |                             sync_and_err, | ||||||
|  |                             ev=aio_ev, | ||||||
|  |                         ) | ||||||
|  |                     ) as (first, chan), | ||||||
|  |                 ): | ||||||
|  | 
 | ||||||
|  |                     for i in range(5): | ||||||
|  |                         pre_sleep: float|None = None | ||||||
|  |                         last_iter: bool = (i == 4) | ||||||
|  | 
 | ||||||
|  |                         # TODO, missing cases? | ||||||
|  |                         # -[ ] error as well on | ||||||
|  |                         #    'after_start_point' case as well for | ||||||
|  |                         #    another case? | ||||||
|  |                         raise_err: bool = False | ||||||
|  | 
 | ||||||
|  |                         if last_iter: | ||||||
|  |                             raise_err: bool = True | ||||||
|  | 
 | ||||||
|  |                             # trigger aio task to error on next loop | ||||||
|  |                             # tick/checkpoint | ||||||
|  |                             if aio_err_trigger == 'before_start_point': | ||||||
|  |                                 aio_ev.set() | ||||||
|  | 
 | ||||||
|  |                             pre_sleep: float = 0 | ||||||
|  | 
 | ||||||
|  |                         await tn.start( | ||||||
|  |                             pre_started_err, | ||||||
|  |                             raise_err, | ||||||
|  |                             pre_sleep, | ||||||
|  |                             (aio_ev if ( | ||||||
|  |                                     aio_err_trigger == 'after_trio_task_starts' | ||||||
|  |                                     and | ||||||
|  |                                     last_iter | ||||||
|  |                                 ) else None | ||||||
|  |                             ), | ||||||
|  |                         ) | ||||||
|  | 
 | ||||||
|  |                         if ( | ||||||
|  |                             aio_err_trigger == 'after_start_point' | ||||||
|  |                             and | ||||||
|  |                             last_iter | ||||||
|  |                         ): | ||||||
|  |                             aio_ev.set() | ||||||
|  | 
 | ||||||
|  |     with pytest.raises( | ||||||
|  |         expected_exception=ExceptionGroup, | ||||||
|  |     ) as excinfo: | ||||||
|  |         tractor.to_asyncio.run_as_asyncio_guest( | ||||||
|  |             trio_main=_trio_main, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     eg = excinfo.value | ||||||
|  |     rte_eg, rest_eg = eg.split(RuntimeError) | ||||||
|  | 
 | ||||||
|  |     # ensure the trio-task's error bubbled despite the aio-side | ||||||
|  |     # having (maybe) errored first. | ||||||
|  |     if aio_err_trigger in ( | ||||||
|  |         'after_trio_task_starts', | ||||||
|  |         'after_start_point', | ||||||
|  |     ): | ||||||
|  |         assert len(errs := rest_eg.exceptions) == 1 | ||||||
|  |         typerr = errs[0] | ||||||
|  |         assert ( | ||||||
|  |             type(typerr) is TypeError | ||||||
|  |             and | ||||||
|  |             'trio-side' in typerr.args | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # when aio errors BEFORE (last) trio task is scheduled, we should | ||||||
|  |     # never see anythinb but the aio-side. | ||||||
|  |     else: | ||||||
|  |         assert len(rtes := rte_eg.exceptions) == 1 | ||||||
|  |         assert 'asyncio-side' in rtes[0].args[0] | ||||||
|  | @ -1,6 +1,8 @@ | ||||||
| """ | ''' | ||||||
| RPC related | RPC (or maybe better labelled as "RTS: remote task scheduling"?) | ||||||
| """ | related API and error checks. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
| import itertools | import itertools | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
|  | @ -13,9 +15,19 @@ async def sleep_back_actor( | ||||||
|     func_name, |     func_name, | ||||||
|     func_defined, |     func_defined, | ||||||
|     exposed_mods, |     exposed_mods, | ||||||
|  |     *, | ||||||
|  |     reg_addr: tuple, | ||||||
| ): | ): | ||||||
|     if actor_name: |     if actor_name: | ||||||
|         async with tractor.find_actor(actor_name) as portal: |         async with tractor.find_actor( | ||||||
|  |             actor_name, | ||||||
|  |             # NOTE: must be set manually since | ||||||
|  |             # the subactor doesn't have the reg_addr | ||||||
|  |             # fixture code run in it! | ||||||
|  |             # TODO: maybe we should just set this once in the | ||||||
|  |             # _state mod and derive to all children? | ||||||
|  |             registry_addrs=[reg_addr], | ||||||
|  |         ) as portal: | ||||||
|             try: |             try: | ||||||
|                 await portal.run(__name__, func_name) |                 await portal.run(__name__, func_name) | ||||||
|             except tractor.RemoteActorError as err: |             except tractor.RemoteActorError as err: | ||||||
|  | @ -24,7 +36,7 @@ async def sleep_back_actor( | ||||||
|                 if not exposed_mods: |                 if not exposed_mods: | ||||||
|                     expect = tractor.ModuleNotExposed |                     expect = tractor.ModuleNotExposed | ||||||
| 
 | 
 | ||||||
|                 assert err.type is expect |                 assert err.boxed_type is expect | ||||||
|                 raise |                 raise | ||||||
|     else: |     else: | ||||||
|         await trio.sleep(float('inf')) |         await trio.sleep(float('inf')) | ||||||
|  | @ -42,14 +54,25 @@ async def short_sleep(): | ||||||
|         (['tmp_mod'], 'import doggy', ModuleNotFoundError), |         (['tmp_mod'], 'import doggy', ModuleNotFoundError), | ||||||
|         (['tmp_mod'], '4doggy', SyntaxError), |         (['tmp_mod'], '4doggy', SyntaxError), | ||||||
|     ], |     ], | ||||||
|     ids=['no_mods', 'this_mod', 'this_mod_bad_func', 'fail_to_import', |     ids=[ | ||||||
|          'fail_on_syntax'], |         'no_mods', | ||||||
|  |         'this_mod', | ||||||
|  |         'this_mod_bad_func', | ||||||
|  |         'fail_to_import', | ||||||
|  |         'fail_on_syntax', | ||||||
|  |     ], | ||||||
| ) | ) | ||||||
| def test_rpc_errors(arb_addr, to_call, testdir): | def test_rpc_errors( | ||||||
|     """Test errors when making various RPC requests to an actor |     reg_addr, | ||||||
|  |     to_call, | ||||||
|  |     testdir, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Test errors when making various RPC requests to an actor | ||||||
|     that either doesn't have the requested module exposed or doesn't define |     that either doesn't have the requested module exposed or doesn't define | ||||||
|     the named function. |     the named function. | ||||||
|     """ | 
 | ||||||
|  |     ''' | ||||||
|     exposed_mods, funcname, inside_err = to_call |     exposed_mods, funcname, inside_err = to_call | ||||||
|     subactor_exposed_mods = [] |     subactor_exposed_mods = [] | ||||||
|     func_defined = globals().get(funcname, False) |     func_defined = globals().get(funcname, False) | ||||||
|  | @ -77,8 +100,13 @@ def test_rpc_errors(arb_addr, to_call, testdir): | ||||||
| 
 | 
 | ||||||
|         # spawn a subactor which calls us back |         # spawn a subactor which calls us back | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             arbiter_addr=arb_addr, |             registry_addrs=[reg_addr], | ||||||
|             enable_modules=exposed_mods.copy(), |             enable_modules=exposed_mods.copy(), | ||||||
|  | 
 | ||||||
|  |             # NOTE: will halt test in REPL if uncommented, so only | ||||||
|  |             # do that if actually debugging subactor but keep it | ||||||
|  |             # disabled for the test. | ||||||
|  |             # debug_mode=True, | ||||||
|         ) as n: |         ) as n: | ||||||
| 
 | 
 | ||||||
|             actor = tractor.current_actor() |             actor = tractor.current_actor() | ||||||
|  | @ -95,6 +123,7 @@ def test_rpc_errors(arb_addr, to_call, testdir): | ||||||
|                 exposed_mods=exposed_mods, |                 exposed_mods=exposed_mods, | ||||||
|                 func_defined=True if func_defined else False, |                 func_defined=True if func_defined else False, | ||||||
|                 enable_modules=subactor_exposed_mods, |                 enable_modules=subactor_exposed_mods, | ||||||
|  |                 reg_addr=reg_addr, | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|     def run(): |     def run(): | ||||||
|  | @ -105,18 +134,20 @@ def test_rpc_errors(arb_addr, to_call, testdir): | ||||||
|         run() |         run() | ||||||
|     else: |     else: | ||||||
|         # underlying errors aren't propagated upwards (yet) |         # underlying errors aren't propagated upwards (yet) | ||||||
|         with pytest.raises(remote_err) as err: |         with pytest.raises( | ||||||
|  |             expected_exception=(remote_err, ExceptionGroup), | ||||||
|  |         ) as err: | ||||||
|             run() |             run() | ||||||
| 
 | 
 | ||||||
|         # get raw instance from pytest wrapper |         # get raw instance from pytest wrapper | ||||||
|         value = err.value |         value = err.value | ||||||
| 
 | 
 | ||||||
|         # might get multiple `trio.Cancelled`s as well inside an inception |         # might get multiple `trio.Cancelled`s as well inside an inception | ||||||
|         if isinstance(value, trio.MultiError): |         if isinstance(value, ExceptionGroup): | ||||||
|             value = next(itertools.dropwhile( |             value = next(itertools.dropwhile( | ||||||
|                 lambda exc: not isinstance(exc, tractor.RemoteActorError), |                 lambda exc: not isinstance(exc, tractor.RemoteActorError), | ||||||
|                 value.exceptions |                 value.exceptions | ||||||
|             )) |             )) | ||||||
| 
 | 
 | ||||||
|         if getattr(value, 'type', None): |         if getattr(value, 'type', None): | ||||||
|             assert value.type is inside_err |             assert value.boxed_type is inside_err | ||||||
|  |  | ||||||
|  | @ -8,7 +8,7 @@ import pytest | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
| 
 | 
 | ||||||
| from conftest import tractor_test | from tractor._testing import tractor_test | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| _file_path: str = '' | _file_path: str = '' | ||||||
|  | @ -64,7 +64,8 @@ async def test_lifetime_stack_wipes_tmpfile( | ||||||
| 
 | 
 | ||||||
|     except ( |     except ( | ||||||
|         tractor.RemoteActorError, |         tractor.RemoteActorError, | ||||||
|         tractor.BaseExceptionGroup, |         # tractor.BaseExceptionGroup, | ||||||
|  |         BaseExceptionGroup, | ||||||
|     ): |     ): | ||||||
|         pass |         pass | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,13 +2,15 @@ | ||||||
| Spawning basics | Spawning basics | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
| from typing import Optional | from typing import ( | ||||||
|  |     Any, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| import trio | import trio | ||||||
| import tractor | import tractor | ||||||
| 
 | 
 | ||||||
| from conftest import tractor_test | from tractor._testing import tractor_test | ||||||
| 
 | 
 | ||||||
| data_to_pass_down = {'doggy': 10, 'kitty': 4} | data_to_pass_down = {'doggy': 10, 'kitty': 4} | ||||||
| 
 | 
 | ||||||
|  | @ -16,24 +18,21 @@ data_to_pass_down = {'doggy': 10, 'kitty': 4} | ||||||
| async def spawn( | async def spawn( | ||||||
|     is_arbiter: bool, |     is_arbiter: bool, | ||||||
|     data: dict, |     data: dict, | ||||||
|     arb_addr: tuple[str, int], |     reg_addr: tuple[str, int], | ||||||
| ): | ): | ||||||
|     namespaces = [__name__] |     namespaces = [__name__] | ||||||
| 
 | 
 | ||||||
|     await trio.sleep(0.1) |     await trio.sleep(0.1) | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_root_actor( |     async with tractor.open_root_actor( | ||||||
|         arbiter_addr=arb_addr, |         arbiter_addr=reg_addr, | ||||||
|     ): |     ): | ||||||
| 
 |  | ||||||
|         actor = tractor.current_actor() |         actor = tractor.current_actor() | ||||||
|         assert actor.is_arbiter == is_arbiter |         assert actor.is_arbiter == is_arbiter | ||||||
|         data = data_to_pass_down |         data = data_to_pass_down | ||||||
| 
 | 
 | ||||||
|         if actor.is_arbiter: |         if actor.is_arbiter: | ||||||
| 
 |             async with tractor.open_nursery() as nursery: | ||||||
|             async with tractor.open_nursery( |  | ||||||
|             ) as nursery: |  | ||||||
| 
 | 
 | ||||||
|                 # forks here |                 # forks here | ||||||
|                 portal = await nursery.run_in_actor( |                 portal = await nursery.run_in_actor( | ||||||
|  | @ -41,7 +40,7 @@ async def spawn( | ||||||
|                     is_arbiter=False, |                     is_arbiter=False, | ||||||
|                     name='sub-actor', |                     name='sub-actor', | ||||||
|                     data=data, |                     data=data, | ||||||
|                     arb_addr=arb_addr, |                     reg_addr=reg_addr, | ||||||
|                     enable_modules=namespaces, |                     enable_modules=namespaces, | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|  | @ -55,12 +54,14 @@ async def spawn( | ||||||
|             return 10 |             return 10 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_local_arbiter_subactor_global_state(arb_addr): | def test_local_arbiter_subactor_global_state( | ||||||
|  |     reg_addr, | ||||||
|  | ): | ||||||
|     result = trio.run( |     result = trio.run( | ||||||
|         spawn, |         spawn, | ||||||
|         True, |         True, | ||||||
|         data_to_pass_down, |         data_to_pass_down, | ||||||
|         arb_addr, |         reg_addr, | ||||||
|     ) |     ) | ||||||
|     assert result == 10 |     assert result == 10 | ||||||
| 
 | 
 | ||||||
|  | @ -94,7 +95,9 @@ async def test_movie_theatre_convo(start_method): | ||||||
|         await portal.cancel_actor() |         await portal.cancel_actor() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def cellar_door(return_value: Optional[str]): | async def cellar_door( | ||||||
|  |     return_value: str|None, | ||||||
|  | ): | ||||||
|     return return_value |     return return_value | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -104,16 +107,18 @@ async def cellar_door(return_value: Optional[str]): | ||||||
| ) | ) | ||||||
| @tractor_test | @tractor_test | ||||||
| async def test_most_beautiful_word( | async def test_most_beautiful_word( | ||||||
|     start_method, |     start_method: str, | ||||||
|     return_value |     return_value: Any, | ||||||
|  |     debug_mode: bool, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|     The main ``tractor`` routine. |     The main ``tractor`` routine. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     with trio.fail_after(1): |     with trio.fail_after(1): | ||||||
|         async with tractor.open_nursery() as n: |         async with tractor.open_nursery( | ||||||
| 
 |             debug_mode=debug_mode, | ||||||
|  |         ) as n: | ||||||
|             portal = await n.run_in_actor( |             portal = await n.run_in_actor( | ||||||
|                 cellar_door, |                 cellar_door, | ||||||
|                 return_value=return_value, |                 return_value=return_value, | ||||||
|  | @ -140,7 +145,7 @@ async def check_loglevel(level): | ||||||
| def test_loglevel_propagated_to_subactor( | def test_loglevel_propagated_to_subactor( | ||||||
|     start_method, |     start_method, | ||||||
|     capfd, |     capfd, | ||||||
|     arb_addr, |     reg_addr, | ||||||
| ): | ): | ||||||
|     if start_method == 'mp_forkserver': |     if start_method == 'mp_forkserver': | ||||||
|         pytest.skip( |         pytest.skip( | ||||||
|  | @ -152,7 +157,7 @@ def test_loglevel_propagated_to_subactor( | ||||||
|         async with tractor.open_nursery( |         async with tractor.open_nursery( | ||||||
|             name='arbiter', |             name='arbiter', | ||||||
|             start_method=start_method, |             start_method=start_method, | ||||||
|             arbiter_addr=arb_addr, |             arbiter_addr=reg_addr, | ||||||
| 
 | 
 | ||||||
|         ) as tn: |         ) as tn: | ||||||
|             await tn.run_in_actor( |             await tn.run_in_actor( | ||||||
|  |  | ||||||
|  | @ -66,13 +66,13 @@ async def ensure_sequence( | ||||||
| async def open_sequence_streamer( | async def open_sequence_streamer( | ||||||
| 
 | 
 | ||||||
|     sequence: list[int], |     sequence: list[int], | ||||||
|     arb_addr: tuple[str, int], |     reg_addr: tuple[str, int], | ||||||
|     start_method: str, |     start_method: str, | ||||||
| 
 | 
 | ||||||
| ) -> tractor.MsgStream: | ) -> tractor.MsgStream: | ||||||
| 
 | 
 | ||||||
|     async with tractor.open_nursery( |     async with tractor.open_nursery( | ||||||
|         arbiter_addr=arb_addr, |         arbiter_addr=reg_addr, | ||||||
|         start_method=start_method, |         start_method=start_method, | ||||||
|     ) as tn: |     ) as tn: | ||||||
| 
 | 
 | ||||||
|  | @ -93,7 +93,7 @@ async def open_sequence_streamer( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_stream_fan_out_to_local_subscriptions( | def test_stream_fan_out_to_local_subscriptions( | ||||||
|     arb_addr, |     reg_addr, | ||||||
|     start_method, |     start_method, | ||||||
| ): | ): | ||||||
| 
 | 
 | ||||||
|  | @ -103,7 +103,7 @@ def test_stream_fan_out_to_local_subscriptions( | ||||||
| 
 | 
 | ||||||
|         async with open_sequence_streamer( |         async with open_sequence_streamer( | ||||||
|             sequence, |             sequence, | ||||||
|             arb_addr, |             reg_addr, | ||||||
|             start_method, |             start_method, | ||||||
|         ) as stream: |         ) as stream: | ||||||
| 
 | 
 | ||||||
|  | @ -138,7 +138,7 @@ def test_stream_fan_out_to_local_subscriptions( | ||||||
|     ] |     ] | ||||||
| ) | ) | ||||||
| def test_consumer_and_parent_maybe_lag( | def test_consumer_and_parent_maybe_lag( | ||||||
|     arb_addr, |     reg_addr, | ||||||
|     start_method, |     start_method, | ||||||
|     task_delays, |     task_delays, | ||||||
| ): | ): | ||||||
|  | @ -150,7 +150,7 @@ def test_consumer_and_parent_maybe_lag( | ||||||
| 
 | 
 | ||||||
|         async with open_sequence_streamer( |         async with open_sequence_streamer( | ||||||
|             sequence, |             sequence, | ||||||
|             arb_addr, |             reg_addr, | ||||||
|             start_method, |             start_method, | ||||||
|         ) as stream: |         ) as stream: | ||||||
| 
 | 
 | ||||||
|  | @ -211,7 +211,7 @@ def test_consumer_and_parent_maybe_lag( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_faster_task_to_recv_is_cancelled_by_slower( | def test_faster_task_to_recv_is_cancelled_by_slower( | ||||||
|     arb_addr, |     reg_addr, | ||||||
|     start_method, |     start_method, | ||||||
| ): | ): | ||||||
|     ''' |     ''' | ||||||
|  | @ -225,7 +225,7 @@ def test_faster_task_to_recv_is_cancelled_by_slower( | ||||||
| 
 | 
 | ||||||
|         async with open_sequence_streamer( |         async with open_sequence_streamer( | ||||||
|             sequence, |             sequence, | ||||||
|             arb_addr, |             reg_addr, | ||||||
|             start_method, |             start_method, | ||||||
| 
 | 
 | ||||||
|         ) as stream: |         ) as stream: | ||||||
|  | @ -271,7 +271,7 @@ def test_faster_task_to_recv_is_cancelled_by_slower( | ||||||
|                         # the faster subtask was cancelled |                         # the faster subtask was cancelled | ||||||
|                         break |                         break | ||||||
| 
 | 
 | ||||||
|                 # await tractor.breakpoint() |                 # await tractor.pause() | ||||||
|                 # await stream.receive() |                 # await stream.receive() | ||||||
|                 print(f'final value: {value}') |                 print(f'final value: {value}') | ||||||
| 
 | 
 | ||||||
|  | @ -302,7 +302,7 @@ def test_subscribe_errors_after_close(): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_ensure_slow_consumers_lag_out( | def test_ensure_slow_consumers_lag_out( | ||||||
|     arb_addr, |     reg_addr, | ||||||
|     start_method, |     start_method, | ||||||
| ): | ): | ||||||
|     '''This is a pure local task test; no tractor |     '''This is a pure local task test; no tractor | ||||||
|  |  | ||||||
|  | @ -3,9 +3,13 @@ Reminders for oddities in `trio` that we need to stay aware of and/or | ||||||
| want to see changed. | want to see changed. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
|  | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
| import pytest | import pytest | ||||||
| import trio | import trio | ||||||
| from trio_typing import TaskStatus | from trio import TaskStatus | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|  | @ -80,3 +84,115 @@ def test_stashed_child_nursery(use_start_soon): | ||||||
| 
 | 
 | ||||||
|     with pytest.raises(NameError): |     with pytest.raises(NameError): | ||||||
|         trio.run(main) |         trio.run(main) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     ('unmask_from_canc', 'canc_from_finally'), | ||||||
|  |     [ | ||||||
|  |         (True, False), | ||||||
|  |         (True, True), | ||||||
|  |         pytest.param(False, True, | ||||||
|  |                      marks=pytest.mark.xfail(reason="never raises!") | ||||||
|  |         ), | ||||||
|  |     ], | ||||||
|  |     # TODO, ask ronny how to impl this .. XD | ||||||
|  |     # ids='unmask_from_canc={0}, canc_from_finally={1}',#.format, | ||||||
|  | ) | ||||||
|  | def test_acm_embedded_nursery_propagates_enter_err( | ||||||
|  |     canc_from_finally: bool, | ||||||
|  |     unmask_from_canc: bool, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Demo how a masking `trio.Cancelled` could be handled by unmasking from the | ||||||
|  |     `.__context__` field when a user (by accident) re-raises from a `finally:`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     import tractor | ||||||
|  | 
 | ||||||
|  |     @acm | ||||||
|  |     async def maybe_raise_from_masking_exc( | ||||||
|  |         tn: trio.Nursery, | ||||||
|  |         unmask_from: BaseException|None = trio.Cancelled | ||||||
|  | 
 | ||||||
|  |         # TODO, maybe offer a collection? | ||||||
|  |         # unmask_from: set[BaseException] = { | ||||||
|  |         #     trio.Cancelled, | ||||||
|  |         # }, | ||||||
|  |     ): | ||||||
|  |         if not unmask_from: | ||||||
|  |             yield | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |         try: | ||||||
|  |             yield | ||||||
|  |         except* unmask_from as be_eg: | ||||||
|  | 
 | ||||||
|  |             # TODO, if we offer `unmask_from: set` | ||||||
|  |             # for masker_exc_type in unmask_from: | ||||||
|  | 
 | ||||||
|  |             matches, rest = be_eg.split(unmask_from) | ||||||
|  |             if not matches: | ||||||
|  |                 raise | ||||||
|  | 
 | ||||||
|  |             for exc_match in be_eg.exceptions: | ||||||
|  |                 if ( | ||||||
|  |                     (exc_ctx := exc_match.__context__) | ||||||
|  |                     and | ||||||
|  |                     type(exc_ctx) not in { | ||||||
|  |                         # trio.Cancelled,  # always by default? | ||||||
|  |                         unmask_from, | ||||||
|  |                     } | ||||||
|  |                 ): | ||||||
|  |                     exc_ctx.add_note( | ||||||
|  |                         f'\n' | ||||||
|  |                         f'WARNING: the above error was masked by a {unmask_from!r} !?!\n' | ||||||
|  |                         f'Are you always cancelling? Say from a `finally:` ?\n\n' | ||||||
|  | 
 | ||||||
|  |                         f'{tn!r}' | ||||||
|  |                     ) | ||||||
|  |                     raise exc_ctx from exc_match | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     @acm | ||||||
|  |     async def wraps_tn_that_always_cancels(): | ||||||
|  |         async with ( | ||||||
|  |             trio.open_nursery() as tn, | ||||||
|  |             maybe_raise_from_masking_exc( | ||||||
|  |                 tn=tn, | ||||||
|  |                 unmask_from=( | ||||||
|  |                     trio.Cancelled | ||||||
|  |                     if unmask_from_canc | ||||||
|  |                     else None | ||||||
|  |                 ), | ||||||
|  |             ) | ||||||
|  |         ): | ||||||
|  |             try: | ||||||
|  |                 yield tn | ||||||
|  |             finally: | ||||||
|  |                 if canc_from_finally: | ||||||
|  |                     tn.cancel_scope.cancel() | ||||||
|  |                     await trio.lowlevel.checkpoint() | ||||||
|  | 
 | ||||||
|  |     async def _main(): | ||||||
|  |         with tractor.devx.open_crash_handler() as bxerr: | ||||||
|  |             assert not bxerr.value | ||||||
|  | 
 | ||||||
|  |             async with ( | ||||||
|  |                 wraps_tn_that_always_cancels() as tn, | ||||||
|  |             ): | ||||||
|  |                 assert not tn.cancel_scope.cancel_called | ||||||
|  |                 assert 0 | ||||||
|  | 
 | ||||||
|  |         assert ( | ||||||
|  |             (err := bxerr.value) | ||||||
|  |             and | ||||||
|  |             type(err) is AssertionError | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(ExceptionGroup) as excinfo: | ||||||
|  |         trio.run(_main) | ||||||
|  | 
 | ||||||
|  |     eg: ExceptionGroup = excinfo.value | ||||||
|  |     assert_eg, rest_eg = eg.split(AssertionError) | ||||||
|  | 
 | ||||||
|  |     assert len(assert_eg.exceptions) == 1 | ||||||
|  |  | ||||||
|  | @ -18,71 +18,51 @@ | ||||||
| tractor: structured concurrent ``trio``-"actors". | tractor: structured concurrent ``trio``-"actors". | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
| from exceptiongroup import BaseExceptionGroup |  | ||||||
| 
 | 
 | ||||||
| from ._clustering import open_actor_cluster | from ._clustering import ( | ||||||
| from ._ipc import Channel |     open_actor_cluster as open_actor_cluster, | ||||||
|  | ) | ||||||
| from ._context import ( | from ._context import ( | ||||||
|     Context, |     Context as Context,  # the type | ||||||
|     context, |     context as context,  # a func-decorator | ||||||
| ) | ) | ||||||
| from ._streaming import ( | from ._streaming import ( | ||||||
|     MsgStream, |     MsgStream as MsgStream, | ||||||
|     stream, |     stream as stream, | ||||||
| ) | ) | ||||||
| from ._discovery import ( | from ._discovery import ( | ||||||
|     get_arbiter, |     get_registry as get_registry, | ||||||
|     find_actor, |     find_actor as find_actor, | ||||||
|     wait_for_actor, |     wait_for_actor as wait_for_actor, | ||||||
|     query_actor, |     query_actor as query_actor, | ||||||
|  | ) | ||||||
|  | from ._supervise import ( | ||||||
|  |     open_nursery as open_nursery, | ||||||
|  |     ActorNursery as ActorNursery, | ||||||
| ) | ) | ||||||
| from ._supervise import open_nursery |  | ||||||
| from ._state import ( | from ._state import ( | ||||||
|     current_actor, |     current_actor as current_actor, | ||||||
|     is_root_process, |     is_root_process as is_root_process, | ||||||
|  |     current_ipc_ctx as current_ipc_ctx, | ||||||
| ) | ) | ||||||
| from ._exceptions import ( | from ._exceptions import ( | ||||||
|     RemoteActorError, |     ContextCancelled as ContextCancelled, | ||||||
|     ModuleNotExposed, |     ModuleNotExposed as ModuleNotExposed, | ||||||
|     ContextCancelled, |     MsgTypeError as MsgTypeError, | ||||||
|  |     RemoteActorError as RemoteActorError, | ||||||
|  |     TransportClosed as TransportClosed, | ||||||
| ) | ) | ||||||
| from ._debug import ( | from .devx import ( | ||||||
|     breakpoint, |     breakpoint as breakpoint, | ||||||
|     post_mortem, |     pause as pause, | ||||||
|  |     pause_from_sync as pause_from_sync, | ||||||
|  |     post_mortem as post_mortem, | ||||||
| ) | ) | ||||||
| from . import msg | from . import msg as msg | ||||||
| from ._root import ( | from ._root import ( | ||||||
|     run_daemon, |     run_daemon as run_daemon, | ||||||
|     open_root_actor, |     open_root_actor as open_root_actor, | ||||||
| ) | ) | ||||||
| from ._portal import Portal | from ._ipc import Channel as Channel | ||||||
| from ._runtime import Actor | from ._portal import Portal as Portal | ||||||
| 
 | from ._runtime import Actor as Actor | ||||||
| 
 |  | ||||||
| __all__ = [ |  | ||||||
|     'Actor', |  | ||||||
|     'Channel', |  | ||||||
|     'Context', |  | ||||||
|     'ContextCancelled', |  | ||||||
|     'ModuleNotExposed', |  | ||||||
|     'MsgStream', |  | ||||||
|     'BaseExceptionGroup', |  | ||||||
|     'Portal', |  | ||||||
|     'RemoteActorError', |  | ||||||
|     'breakpoint', |  | ||||||
|     'context', |  | ||||||
|     'current_actor', |  | ||||||
|     'find_actor', |  | ||||||
|     'get_arbiter', |  | ||||||
|     'is_root_process', |  | ||||||
|     'msg', |  | ||||||
|     'open_actor_cluster', |  | ||||||
|     'open_nursery', |  | ||||||
|     'open_root_actor', |  | ||||||
|     'post_mortem', |  | ||||||
|     'query_actor', |  | ||||||
|     'run_daemon', |  | ||||||
|     'stream', |  | ||||||
|     'to_asyncio', |  | ||||||
|     'wait_for_actor', |  | ||||||
| ] |  | ||||||
|  |  | ||||||
|  | @ -18,8 +18,6 @@ | ||||||
| This is the "bootloader" for actors started using the native trio backend. | This is the "bootloader" for actors started using the native trio backend. | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
| import sys |  | ||||||
| import trio |  | ||||||
| import argparse | import argparse | ||||||
| 
 | 
 | ||||||
| from ast import literal_eval | from ast import literal_eval | ||||||
|  | @ -37,9 +35,8 @@ def parse_ipaddr(arg): | ||||||
|     return (str(host), int(port)) |     return (str(host), int(port)) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| from ._entry import _trio_main |  | ||||||
| 
 |  | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|  |     __tracebackhide__: bool = True | ||||||
| 
 | 
 | ||||||
|     parser = argparse.ArgumentParser() |     parser = argparse.ArgumentParser() | ||||||
|     parser.add_argument("--uid", type=parse_uid) |     parser.add_argument("--uid", type=parse_uid) | ||||||
|  |  | ||||||
							
								
								
									
										2602
									
								
								tractor/_context.py
								
								
								
								
							
							
						
						
									
										2602
									
								
								tractor/_context.py
								
								
								
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -1,922 +0,0 @@ | ||||||
| # tractor: structured concurrent "actors". |  | ||||||
| # Copyright 2018-eternity Tyler Goodlet. |  | ||||||
| 
 |  | ||||||
| # This program is free software: you can redistribute it and/or modify |  | ||||||
| # it under the terms of the GNU Affero General Public License as published by |  | ||||||
| # the Free Software Foundation, either version 3 of the License, or |  | ||||||
| # (at your option) any later version. |  | ||||||
| 
 |  | ||||||
| # This program is distributed in the hope that it will be useful, |  | ||||||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the |  | ||||||
| # GNU Affero General Public License for more details. |  | ||||||
| 
 |  | ||||||
| # You should have received a copy of the GNU Affero General Public License |  | ||||||
| # along with this program.  If not, see <https://www.gnu.org/licenses/>. |  | ||||||
| 
 |  | ||||||
| """ |  | ||||||
| Multi-core debugging for da peeps! |  | ||||||
| 
 |  | ||||||
| """ |  | ||||||
| from __future__ import annotations |  | ||||||
| import bdb |  | ||||||
| import os |  | ||||||
| import sys |  | ||||||
| import signal |  | ||||||
| from functools import ( |  | ||||||
|     partial, |  | ||||||
|     cached_property, |  | ||||||
| ) |  | ||||||
| from contextlib import asynccontextmanager as acm |  | ||||||
| from typing import ( |  | ||||||
|     Any, |  | ||||||
|     Optional, |  | ||||||
|     Callable, |  | ||||||
|     AsyncIterator, |  | ||||||
|     AsyncGenerator, |  | ||||||
| ) |  | ||||||
| from types import FrameType |  | ||||||
| 
 |  | ||||||
| import pdbp |  | ||||||
| import tractor |  | ||||||
| import trio |  | ||||||
| from trio_typing import TaskStatus |  | ||||||
| 
 |  | ||||||
| from .log import get_logger |  | ||||||
| from ._discovery import get_root |  | ||||||
| from ._state import ( |  | ||||||
|     is_root_process, |  | ||||||
|     debug_mode, |  | ||||||
| ) |  | ||||||
| from ._exceptions import ( |  | ||||||
|     is_multi_cancelled, |  | ||||||
|     ContextCancelled, |  | ||||||
| ) |  | ||||||
| from ._ipc import Channel |  | ||||||
| 
 |  | ||||||
| log = get_logger(__name__) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| __all__ = ['breakpoint', 'post_mortem'] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class Lock: |  | ||||||
|     ''' |  | ||||||
|     Actor global debug lock state. |  | ||||||
| 
 |  | ||||||
|     Mostly to avoid a lot of ``global`` declarations for now XD. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     repl: MultiActorPdb | None = None |  | ||||||
|     # placeholder for function to set a ``trio.Event`` on debugger exit |  | ||||||
|     # pdb_release_hook: Optional[Callable] = None |  | ||||||
| 
 |  | ||||||
|     _trio_handler: Callable[ |  | ||||||
|         [int, Optional[FrameType]], Any |  | ||||||
|     ] | int | None = None |  | ||||||
| 
 |  | ||||||
|     # actor-wide variable pointing to current task name using debugger |  | ||||||
|     local_task_in_debug: str | None = None |  | ||||||
| 
 |  | ||||||
|     # NOTE: set by the current task waiting on the root tty lock from |  | ||||||
|     # the CALLER side of the `lock_tty_for_child()` context entry-call |  | ||||||
|     # and must be cancelled if this actor is cancelled via IPC |  | ||||||
|     # request-message otherwise deadlocks with the parent actor may |  | ||||||
|     # ensure |  | ||||||
|     _debugger_request_cs: Optional[trio.CancelScope] = None |  | ||||||
| 
 |  | ||||||
|     # NOTE: set only in the root actor for the **local** root spawned task |  | ||||||
|     # which has acquired the lock (i.e. this is on the callee side of |  | ||||||
|     # the `lock_tty_for_child()` context entry). |  | ||||||
|     _root_local_task_cs_in_debug: Optional[trio.CancelScope] = None |  | ||||||
| 
 |  | ||||||
|     # actor tree-wide actor uid that supposedly has the tty lock |  | ||||||
|     global_actor_in_debug: Optional[tuple[str, str]] = None |  | ||||||
| 
 |  | ||||||
|     local_pdb_complete: Optional[trio.Event] = None |  | ||||||
|     no_remote_has_tty: Optional[trio.Event] = None |  | ||||||
| 
 |  | ||||||
|     # lock in root actor preventing multi-access to local tty |  | ||||||
|     _debug_lock: trio.StrictFIFOLock = trio.StrictFIFOLock() |  | ||||||
| 
 |  | ||||||
|     _orig_sigint_handler: Optional[Callable] = None |  | ||||||
|     _blocked: set[tuple[str, str]] = set() |  | ||||||
| 
 |  | ||||||
|     @classmethod |  | ||||||
|     def shield_sigint(cls): |  | ||||||
|         cls._orig_sigint_handler = signal.signal( |  | ||||||
|             signal.SIGINT, |  | ||||||
|             shield_sigint_handler, |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|     @classmethod |  | ||||||
|     def unshield_sigint(cls): |  | ||||||
|         # always restore ``trio``'s sigint handler. see notes below in |  | ||||||
|         # the pdb factory about the nightmare that is that code swapping |  | ||||||
|         # out the handler when the repl activates... |  | ||||||
|         signal.signal(signal.SIGINT, cls._trio_handler) |  | ||||||
|         cls._orig_sigint_handler = None |  | ||||||
| 
 |  | ||||||
|     @classmethod |  | ||||||
|     def release(cls): |  | ||||||
|         try: |  | ||||||
|             cls._debug_lock.release() |  | ||||||
|         except RuntimeError: |  | ||||||
|             # uhhh makes no sense but been seeing the non-owner |  | ||||||
|             # release error even though this is definitely the task |  | ||||||
|             # that locked? |  | ||||||
|             owner = cls._debug_lock.statistics().owner |  | ||||||
|             if owner: |  | ||||||
|                 raise |  | ||||||
| 
 |  | ||||||
|         # actor-local state, irrelevant for non-root. |  | ||||||
|         cls.global_actor_in_debug = None |  | ||||||
|         cls.local_task_in_debug = None |  | ||||||
| 
 |  | ||||||
|         try: |  | ||||||
|             # sometimes the ``trio`` might already be terminated in |  | ||||||
|             # which case this call will raise. |  | ||||||
|             if cls.local_pdb_complete is not None: |  | ||||||
|                 cls.local_pdb_complete.set() |  | ||||||
|         finally: |  | ||||||
|             # restore original sigint handler |  | ||||||
|             cls.unshield_sigint() |  | ||||||
|             cls.repl = None |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class TractorConfig(pdbp.DefaultConfig): |  | ||||||
|     ''' |  | ||||||
|     Custom ``pdbp`` goodness :surfer: |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     use_pygments: bool = True |  | ||||||
|     sticky_by_default: bool = False |  | ||||||
|     enable_hidden_frames: bool = False |  | ||||||
| 
 |  | ||||||
|     # much thanks @mdmintz for the hot tip! |  | ||||||
|     # fixes line spacing issue when resizing terminal B) |  | ||||||
|     truncate_long_lines: bool = False |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class MultiActorPdb(pdbp.Pdb): |  | ||||||
|     ''' |  | ||||||
|     Add teardown hooks to the regular ``pdbp.Pdb``. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     # override the pdbp config with our coolio one |  | ||||||
|     DefaultConfig = TractorConfig |  | ||||||
| 
 |  | ||||||
|     # def preloop(self): |  | ||||||
|     #     print('IN PRELOOP') |  | ||||||
|     #     super().preloop() |  | ||||||
| 
 |  | ||||||
|     # TODO: figure out how to disallow recursive .set_trace() entry |  | ||||||
|     # since that'll cause deadlock for us. |  | ||||||
|     def set_continue(self): |  | ||||||
|         try: |  | ||||||
|             super().set_continue() |  | ||||||
|         finally: |  | ||||||
|             Lock.release() |  | ||||||
| 
 |  | ||||||
|     def set_quit(self): |  | ||||||
|         try: |  | ||||||
|             super().set_quit() |  | ||||||
|         finally: |  | ||||||
|             Lock.release() |  | ||||||
| 
 |  | ||||||
|     # XXX NOTE: we only override this because apparently the stdlib pdb |  | ||||||
|     # bois likes to touch the SIGINT handler as much as i like to touch |  | ||||||
|     # my d$%&. |  | ||||||
|     def _cmdloop(self): |  | ||||||
|         self.cmdloop() |  | ||||||
| 
 |  | ||||||
|     @cached_property |  | ||||||
|     def shname(self) -> str | None: |  | ||||||
|         ''' |  | ||||||
|         Attempt to return the login shell name with a special check for |  | ||||||
|         the infamous `xonsh` since it seems to have some issues much |  | ||||||
|         different from std shells when it comes to flushing the prompt? |  | ||||||
| 
 |  | ||||||
|         ''' |  | ||||||
|         # SUPER HACKY and only really works if `xonsh` is not used |  | ||||||
|         # before spawning further sub-shells.. |  | ||||||
|         shpath = os.getenv('SHELL', None) |  | ||||||
| 
 |  | ||||||
|         if shpath: |  | ||||||
|             if ( |  | ||||||
|                 os.getenv('XONSH_LOGIN', default=False) |  | ||||||
|                 or 'xonsh' in shpath |  | ||||||
|             ): |  | ||||||
|                 return 'xonsh' |  | ||||||
| 
 |  | ||||||
|             return os.path.basename(shpath) |  | ||||||
| 
 |  | ||||||
|         return None |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @acm |  | ||||||
| async def _acquire_debug_lock_from_root_task( |  | ||||||
|     uid: tuple[str, str] |  | ||||||
| 
 |  | ||||||
| ) -> AsyncIterator[trio.StrictFIFOLock]: |  | ||||||
|     ''' |  | ||||||
|     Acquire a root-actor local FIFO lock which tracks mutex access of |  | ||||||
|     the process tree's global debugger breakpoint. |  | ||||||
| 
 |  | ||||||
|     This lock avoids tty clobbering (by preventing multiple processes |  | ||||||
|     reading from stdstreams) and ensures multi-actor, sequential access |  | ||||||
|     to the ``pdb`` repl. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     task_name = trio.lowlevel.current_task().name |  | ||||||
| 
 |  | ||||||
|     log.runtime( |  | ||||||
|         f"Attempting to acquire TTY lock, remote task: {task_name}:{uid}" |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     we_acquired = False |  | ||||||
| 
 |  | ||||||
|     try: |  | ||||||
|         log.runtime( |  | ||||||
|             f"entering lock checkpoint, remote task: {task_name}:{uid}" |  | ||||||
|         ) |  | ||||||
|         we_acquired = True |  | ||||||
| 
 |  | ||||||
|         # NOTE: if the surrounding cancel scope from the |  | ||||||
|         # `lock_tty_for_child()` caller is cancelled, this line should |  | ||||||
|         # unblock and NOT leave us in some kind of |  | ||||||
|         # a "child-locked-TTY-but-child-is-uncontactable-over-IPC" |  | ||||||
|         # condition. |  | ||||||
|         await Lock._debug_lock.acquire() |  | ||||||
| 
 |  | ||||||
|         if Lock.no_remote_has_tty is None: |  | ||||||
|             # mark the tty lock as being in use so that the runtime |  | ||||||
|             # can try to avoid clobbering any connection from a child |  | ||||||
|             # that's currently relying on it. |  | ||||||
|             Lock.no_remote_has_tty = trio.Event() |  | ||||||
| 
 |  | ||||||
|         Lock.global_actor_in_debug = uid |  | ||||||
|         log.runtime(f"TTY lock acquired, remote task: {task_name}:{uid}") |  | ||||||
| 
 |  | ||||||
|         # NOTE: critical section: this yield is unshielded! |  | ||||||
| 
 |  | ||||||
|         # IF we received a cancel during the shielded lock entry of some |  | ||||||
|         # next-in-queue requesting task, then the resumption here will |  | ||||||
|         # result in that ``trio.Cancelled`` being raised to our caller |  | ||||||
|         # (likely from ``lock_tty_for_child()`` below)!  In |  | ||||||
|         # this case the ``finally:`` below should trigger and the |  | ||||||
|         # surrounding caller side context should cancel normally |  | ||||||
|         # relaying back to the caller. |  | ||||||
| 
 |  | ||||||
|         yield Lock._debug_lock |  | ||||||
| 
 |  | ||||||
|     finally: |  | ||||||
|         if ( |  | ||||||
|             we_acquired |  | ||||||
|             and Lock._debug_lock.locked() |  | ||||||
|         ): |  | ||||||
|             Lock._debug_lock.release() |  | ||||||
| 
 |  | ||||||
|         # IFF there are no more requesting tasks queued up fire, the |  | ||||||
|         # "tty-unlocked" event thereby alerting any monitors of the lock that |  | ||||||
|         # we are now back in the "tty unlocked" state. This is basically |  | ||||||
|         # and edge triggered signal around an empty queue of sub-actor |  | ||||||
|         # tasks that may have tried to acquire the lock. |  | ||||||
|         stats = Lock._debug_lock.statistics() |  | ||||||
|         if ( |  | ||||||
|             not stats.owner |  | ||||||
|         ): |  | ||||||
|             log.runtime(f"No more tasks waiting on tty lock! says {uid}") |  | ||||||
|             if Lock.no_remote_has_tty is not None: |  | ||||||
|                 Lock.no_remote_has_tty.set() |  | ||||||
|                 Lock.no_remote_has_tty = None |  | ||||||
| 
 |  | ||||||
|         Lock.global_actor_in_debug = None |  | ||||||
| 
 |  | ||||||
|         log.runtime( |  | ||||||
|             f"TTY lock released, remote task: {task_name}:{uid}" |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @tractor.context |  | ||||||
| async def lock_tty_for_child( |  | ||||||
| 
 |  | ||||||
|     ctx: tractor.Context, |  | ||||||
|     subactor_uid: tuple[str, str] |  | ||||||
| 
 |  | ||||||
| ) -> str: |  | ||||||
|     ''' |  | ||||||
|     Lock the TTY in the root process of an actor tree in a new |  | ||||||
|     inter-actor-context-task such that the ``pdbp`` debugger console |  | ||||||
|     can be mutex-allocated to the calling sub-actor for REPL control |  | ||||||
|     without interference by other processes / threads. |  | ||||||
| 
 |  | ||||||
|     NOTE: this task must be invoked in the root process of the actor |  | ||||||
|     tree. It is meant to be invoked as an rpc-task and should be |  | ||||||
|     highly reliable at releasing the mutex complete! |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     task_name = trio.lowlevel.current_task().name |  | ||||||
| 
 |  | ||||||
|     if tuple(subactor_uid) in Lock._blocked: |  | ||||||
|         log.warning( |  | ||||||
|             f'Actor {subactor_uid} is blocked from acquiring debug lock\n' |  | ||||||
|             f"remote task: {task_name}:{subactor_uid}" |  | ||||||
|         ) |  | ||||||
|         ctx._enter_debugger_on_cancel = False |  | ||||||
|         await ctx.cancel(f'Debug lock blocked for {subactor_uid}') |  | ||||||
|         return 'pdb_lock_blocked' |  | ||||||
| 
 |  | ||||||
|     # TODO: when we get to true remote debugging |  | ||||||
|     # this will deliver stdin data? |  | ||||||
| 
 |  | ||||||
|     log.debug( |  | ||||||
|         "Attempting to acquire TTY lock\n" |  | ||||||
|         f"remote task: {task_name}:{subactor_uid}" |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     log.debug(f"Actor {subactor_uid} is WAITING on stdin hijack lock") |  | ||||||
|     Lock.shield_sigint() |  | ||||||
| 
 |  | ||||||
|     try: |  | ||||||
|         with ( |  | ||||||
|             trio.CancelScope(shield=True) as debug_lock_cs, |  | ||||||
|         ): |  | ||||||
|             Lock._root_local_task_cs_in_debug = debug_lock_cs |  | ||||||
|             async with _acquire_debug_lock_from_root_task(subactor_uid): |  | ||||||
| 
 |  | ||||||
|                 # indicate to child that we've locked stdio |  | ||||||
|                 await ctx.started('Locked') |  | ||||||
|                 log.debug( |  | ||||||
|                     f"Actor {subactor_uid} acquired stdin hijack lock" |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|                 # wait for unlock pdb by child |  | ||||||
|                 async with ctx.open_stream() as stream: |  | ||||||
|                     assert await stream.receive() == 'pdb_unlock' |  | ||||||
| 
 |  | ||||||
|         return "pdb_unlock_complete" |  | ||||||
| 
 |  | ||||||
|     finally: |  | ||||||
|         Lock._root_local_task_cs_in_debug = None |  | ||||||
|         Lock.unshield_sigint() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| async def wait_for_parent_stdin_hijack( |  | ||||||
|     actor_uid: tuple[str, str], |  | ||||||
|     task_status: TaskStatus[trio.CancelScope] = trio.TASK_STATUS_IGNORED |  | ||||||
| ): |  | ||||||
|     ''' |  | ||||||
|     Connect to the root actor via a ``Context`` and invoke a task which |  | ||||||
|     locks a root-local TTY lock: ``lock_tty_for_child()``; this func |  | ||||||
|     should be called in a new task from a child actor **and never the |  | ||||||
|     root*. |  | ||||||
| 
 |  | ||||||
|     This function is used by any sub-actor to acquire mutex access to |  | ||||||
|     the ``pdb`` REPL and thus the root's TTY for interactive debugging |  | ||||||
|     (see below inside ``_breakpoint()``). It can be used to ensure that |  | ||||||
|     an intermediate nursery-owning actor does not clobber its children |  | ||||||
|     if they are in debug (see below inside |  | ||||||
|     ``maybe_wait_for_debugger()``). |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     with trio.CancelScope(shield=True) as cs: |  | ||||||
|         Lock._debugger_request_cs = cs |  | ||||||
| 
 |  | ||||||
|         try: |  | ||||||
|             async with get_root() as portal: |  | ||||||
| 
 |  | ||||||
|                 # this syncs to child's ``Context.started()`` call. |  | ||||||
|                 async with portal.open_context( |  | ||||||
| 
 |  | ||||||
|                     tractor._debug.lock_tty_for_child, |  | ||||||
|                     subactor_uid=actor_uid, |  | ||||||
| 
 |  | ||||||
|                 ) as (ctx, val): |  | ||||||
| 
 |  | ||||||
|                     log.debug('locked context') |  | ||||||
|                     assert val == 'Locked' |  | ||||||
| 
 |  | ||||||
|                     async with ctx.open_stream() as stream: |  | ||||||
|                         # unblock local caller |  | ||||||
| 
 |  | ||||||
|                         try: |  | ||||||
|                             assert Lock.local_pdb_complete |  | ||||||
|                             task_status.started(cs) |  | ||||||
|                             await Lock.local_pdb_complete.wait() |  | ||||||
| 
 |  | ||||||
|                         finally: |  | ||||||
|                             # TODO: shielding currently can cause hangs... |  | ||||||
|                             # with trio.CancelScope(shield=True): |  | ||||||
|                             await stream.send('pdb_unlock') |  | ||||||
| 
 |  | ||||||
|                         # sync with callee termination |  | ||||||
|                         assert await ctx.result() == "pdb_unlock_complete" |  | ||||||
| 
 |  | ||||||
|                 log.debug('exitting child side locking task context') |  | ||||||
| 
 |  | ||||||
|         except ContextCancelled: |  | ||||||
|             log.warning('Root actor cancelled debug lock') |  | ||||||
|             raise |  | ||||||
| 
 |  | ||||||
|         finally: |  | ||||||
|             Lock.local_task_in_debug = None |  | ||||||
|             log.debug('Exiting debugger from child') |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def mk_mpdb() -> tuple[MultiActorPdb, Callable]: |  | ||||||
| 
 |  | ||||||
|     pdb = MultiActorPdb() |  | ||||||
|     # signal.signal = pdbp.hideframe(signal.signal) |  | ||||||
| 
 |  | ||||||
|     Lock.shield_sigint() |  | ||||||
| 
 |  | ||||||
|     # XXX: These are the important flags mentioned in |  | ||||||
|     # https://github.com/python-trio/trio/issues/1155 |  | ||||||
|     # which resolve the traceback spews to console. |  | ||||||
|     pdb.allow_kbdint = True |  | ||||||
|     pdb.nosigint = True |  | ||||||
| 
 |  | ||||||
|     return pdb, Lock.unshield_sigint |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| async def _breakpoint( |  | ||||||
| 
 |  | ||||||
|     debug_func, |  | ||||||
| 
 |  | ||||||
|     # TODO: |  | ||||||
|     # shield: bool = False |  | ||||||
| 
 |  | ||||||
| ) -> None: |  | ||||||
|     ''' |  | ||||||
|     Breakpoint entry for engaging debugger instance sync-interaction, |  | ||||||
|     from async code, executing in actor runtime (task). |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     __tracebackhide__ = True |  | ||||||
|     actor = tractor.current_actor() |  | ||||||
|     pdb, undo_sigint = mk_mpdb() |  | ||||||
|     task_name = trio.lowlevel.current_task().name |  | ||||||
| 
 |  | ||||||
|     # TODO: is it possible to debug a trio.Cancelled except block? |  | ||||||
|     # right now it seems like we can kinda do with by shielding |  | ||||||
|     # around ``tractor.breakpoint()`` but not if we move the shielded |  | ||||||
|     # scope here??? |  | ||||||
|     # with trio.CancelScope(shield=shield): |  | ||||||
|     #     await trio.lowlevel.checkpoint() |  | ||||||
| 
 |  | ||||||
|     if ( |  | ||||||
|         not Lock.local_pdb_complete |  | ||||||
|         or Lock.local_pdb_complete.is_set() |  | ||||||
|     ): |  | ||||||
|         Lock.local_pdb_complete = trio.Event() |  | ||||||
| 
 |  | ||||||
|     # TODO: need a more robust check for the "root" actor |  | ||||||
|     if ( |  | ||||||
|         not is_root_process() |  | ||||||
|         and actor._parent_chan  # a connected child |  | ||||||
|     ): |  | ||||||
| 
 |  | ||||||
|         if Lock.local_task_in_debug: |  | ||||||
| 
 |  | ||||||
|             # Recurrence entry case: this task already has the lock and |  | ||||||
|             # is likely recurrently entering a breakpoint |  | ||||||
|             if Lock.local_task_in_debug == task_name: |  | ||||||
|                 # noop on recurrent entry case but we want to trigger |  | ||||||
|                 # a checkpoint to allow other actors error-propagate and |  | ||||||
|                 # potetially avoid infinite re-entries in some subactor. |  | ||||||
|                 await trio.lowlevel.checkpoint() |  | ||||||
|                 return |  | ||||||
| 
 |  | ||||||
|             # if **this** actor is already in debug mode block here |  | ||||||
|             # waiting for the control to be released - this allows |  | ||||||
|             # support for recursive entries to `tractor.breakpoint()` |  | ||||||
|             log.warning(f"{actor.uid} already has a debug lock, waiting...") |  | ||||||
| 
 |  | ||||||
|             await Lock.local_pdb_complete.wait() |  | ||||||
|             await trio.sleep(0.1) |  | ||||||
| 
 |  | ||||||
|         # mark local actor as "in debug mode" to avoid recurrent |  | ||||||
|         # entries/requests to the root process |  | ||||||
|         Lock.local_task_in_debug = task_name |  | ||||||
| 
 |  | ||||||
|         # this **must** be awaited by the caller and is done using the |  | ||||||
|         # root nursery so that the debugger can continue to run without |  | ||||||
|         # being restricted by the scope of a new task nursery. |  | ||||||
| 
 |  | ||||||
|         # TODO: if we want to debug a trio.Cancelled triggered exception |  | ||||||
|         # we have to figure out how to avoid having the service nursery |  | ||||||
|         # cancel on this task start? I *think* this works below: |  | ||||||
|         # ```python |  | ||||||
|         #   actor._service_n.cancel_scope.shield = shield |  | ||||||
|         # ``` |  | ||||||
|         # but not entirely sure if that's a sane way to implement it? |  | ||||||
|         try: |  | ||||||
|             with trio.CancelScope(shield=True): |  | ||||||
|                 await actor._service_n.start( |  | ||||||
|                     wait_for_parent_stdin_hijack, |  | ||||||
|                     actor.uid, |  | ||||||
|                 ) |  | ||||||
|                 Lock.repl = pdb |  | ||||||
|         except RuntimeError: |  | ||||||
|             Lock.release() |  | ||||||
| 
 |  | ||||||
|             if actor._cancel_called: |  | ||||||
|                 # service nursery won't be usable and we |  | ||||||
|                 # don't want to lock up the root either way since |  | ||||||
|                 # we're in (the midst of) cancellation. |  | ||||||
|                 return |  | ||||||
| 
 |  | ||||||
|             raise |  | ||||||
| 
 |  | ||||||
|     elif is_root_process(): |  | ||||||
| 
 |  | ||||||
|         # we also wait in the root-parent for any child that |  | ||||||
|         # may have the tty locked prior |  | ||||||
|         # TODO: wait, what about multiple root tasks acquiring it though? |  | ||||||
|         if Lock.global_actor_in_debug == actor.uid: |  | ||||||
|             # re-entrant root process already has it: noop. |  | ||||||
|             return |  | ||||||
| 
 |  | ||||||
|         # XXX: since we need to enter pdb synchronously below, |  | ||||||
|         # we have to release the lock manually from pdb completion |  | ||||||
|         # callbacks. Can't think of a nicer way then this atm. |  | ||||||
|         if Lock._debug_lock.locked(): |  | ||||||
|             log.warning( |  | ||||||
|                 'Root actor attempting to shield-acquire active tty lock' |  | ||||||
|                 f' owned by {Lock.global_actor_in_debug}') |  | ||||||
| 
 |  | ||||||
|             # must shield here to avoid hitting a ``Cancelled`` and |  | ||||||
|             # a child getting stuck bc we clobbered the tty |  | ||||||
|             with trio.CancelScope(shield=True): |  | ||||||
|                 await Lock._debug_lock.acquire() |  | ||||||
|         else: |  | ||||||
|             # may be cancelled |  | ||||||
|             await Lock._debug_lock.acquire() |  | ||||||
| 
 |  | ||||||
|         Lock.global_actor_in_debug = actor.uid |  | ||||||
|         Lock.local_task_in_debug = task_name |  | ||||||
|         Lock.repl = pdb |  | ||||||
| 
 |  | ||||||
|     try: |  | ||||||
|         # block here one (at the appropriate frame *up*) where |  | ||||||
|         # ``breakpoint()`` was awaited and begin handling stdio. |  | ||||||
|         log.debug("Entering the synchronous world of pdb") |  | ||||||
|         debug_func(actor, pdb) |  | ||||||
| 
 |  | ||||||
|     except bdb.BdbQuit: |  | ||||||
|         Lock.release() |  | ||||||
|         raise |  | ||||||
| 
 |  | ||||||
|     # XXX: apparently we can't do this without showing this frame |  | ||||||
|     # in the backtrace on first entry to the REPL? Seems like an odd |  | ||||||
|     # behaviour that should have been fixed by now. This is also why |  | ||||||
|     # we scrapped all the @cm approaches that were tried previously. |  | ||||||
|     # finally: |  | ||||||
|     #     __tracebackhide__ = True |  | ||||||
|     #     # frame = sys._getframe() |  | ||||||
|     #     # last_f = frame.f_back |  | ||||||
|     #     # last_f.f_globals['__tracebackhide__'] = True |  | ||||||
|     #     # signal.signal = pdbp.hideframe(signal.signal) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def shield_sigint_handler( |  | ||||||
|     signum: int, |  | ||||||
|     frame: 'frame',  # type: ignore # noqa |  | ||||||
|     # pdb_obj: Optional[MultiActorPdb] = None, |  | ||||||
|     *args, |  | ||||||
| 
 |  | ||||||
| ) -> None: |  | ||||||
|     ''' |  | ||||||
|     Specialized, debugger-aware SIGINT handler. |  | ||||||
| 
 |  | ||||||
|     In childred we always ignore to avoid deadlocks since cancellation |  | ||||||
|     should always be managed by the parent supervising actor. The root |  | ||||||
|     is always cancelled on ctrl-c. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     __tracebackhide__ = True |  | ||||||
| 
 |  | ||||||
|     uid_in_debug = Lock.global_actor_in_debug |  | ||||||
| 
 |  | ||||||
|     actor = tractor.current_actor() |  | ||||||
|     # print(f'{actor.uid} in HANDLER with ') |  | ||||||
| 
 |  | ||||||
|     def do_cancel(): |  | ||||||
|         # If we haven't tried to cancel the runtime then do that instead |  | ||||||
|         # of raising a KBI (which may non-gracefully destroy |  | ||||||
|         # a ``trio.run()``). |  | ||||||
|         if not actor._cancel_called: |  | ||||||
|             actor.cancel_soon() |  | ||||||
| 
 |  | ||||||
|         # If the runtime is already cancelled it likely means the user |  | ||||||
|         # hit ctrl-c again because teardown didn't full take place in |  | ||||||
|         # which case we do the "hard" raising of a local KBI. |  | ||||||
|         else: |  | ||||||
|             raise KeyboardInterrupt |  | ||||||
| 
 |  | ||||||
|     any_connected = False |  | ||||||
| 
 |  | ||||||
|     if uid_in_debug is not None: |  | ||||||
|         # try to see if the supposed (sub)actor in debug still |  | ||||||
|         # has an active connection to *this* actor, and if not |  | ||||||
|         # it's likely they aren't using the TTY lock / debugger |  | ||||||
|         # and we should propagate SIGINT normally. |  | ||||||
|         chans = actor._peers.get(tuple(uid_in_debug)) |  | ||||||
|         if chans: |  | ||||||
|             any_connected = any(chan.connected() for chan in chans) |  | ||||||
|             if not any_connected: |  | ||||||
|                 log.warning( |  | ||||||
|                     'A global actor reported to be in debug ' |  | ||||||
|                     'but no connection exists for this child:\n' |  | ||||||
|                     f'{uid_in_debug}\n' |  | ||||||
|                     'Allowing SIGINT propagation..' |  | ||||||
|                 ) |  | ||||||
|                 return do_cancel() |  | ||||||
| 
 |  | ||||||
|     # only set in the actor actually running the REPL |  | ||||||
|     pdb_obj = Lock.repl |  | ||||||
| 
 |  | ||||||
|     # root actor branch that reports whether or not a child |  | ||||||
|     # has locked debugger. |  | ||||||
|     if ( |  | ||||||
|         is_root_process() |  | ||||||
|         and uid_in_debug is not None |  | ||||||
| 
 |  | ||||||
|         # XXX: only if there is an existing connection to the |  | ||||||
|         # (sub-)actor in debug do we ignore SIGINT in this |  | ||||||
|         # parent! Otherwise we may hang waiting for an actor |  | ||||||
|         # which has already terminated to unlock. |  | ||||||
|         and any_connected |  | ||||||
|     ): |  | ||||||
|         # we are root and some actor is in debug mode |  | ||||||
|         # if uid_in_debug is not None: |  | ||||||
| 
 |  | ||||||
|         if pdb_obj: |  | ||||||
|             name = uid_in_debug[0] |  | ||||||
|             if name != 'root': |  | ||||||
|                 log.pdb( |  | ||||||
|                     f"Ignoring SIGINT, child in debug mode: `{uid_in_debug}`" |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|             else: |  | ||||||
|                 log.pdb( |  | ||||||
|                     "Ignoring SIGINT while in debug mode" |  | ||||||
|                 ) |  | ||||||
|     elif ( |  | ||||||
|         is_root_process() |  | ||||||
|     ): |  | ||||||
|         if pdb_obj: |  | ||||||
|             log.pdb( |  | ||||||
|                 "Ignoring SIGINT since debug mode is enabled" |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|         if ( |  | ||||||
|             Lock._root_local_task_cs_in_debug |  | ||||||
|             and not Lock._root_local_task_cs_in_debug.cancel_called |  | ||||||
|         ): |  | ||||||
|             Lock._root_local_task_cs_in_debug.cancel() |  | ||||||
| 
 |  | ||||||
|             # revert back to ``trio`` handler asap! |  | ||||||
|             Lock.unshield_sigint() |  | ||||||
| 
 |  | ||||||
|     # child actor that has locked the debugger |  | ||||||
|     elif not is_root_process(): |  | ||||||
| 
 |  | ||||||
|         chan: Channel = actor._parent_chan |  | ||||||
|         if not chan or not chan.connected(): |  | ||||||
|             log.warning( |  | ||||||
|                 'A global actor reported to be in debug ' |  | ||||||
|                 'but no connection exists for its parent:\n' |  | ||||||
|                 f'{uid_in_debug}\n' |  | ||||||
|                 'Allowing SIGINT propagation..' |  | ||||||
|             ) |  | ||||||
|             return do_cancel() |  | ||||||
| 
 |  | ||||||
|         task = Lock.local_task_in_debug |  | ||||||
|         if ( |  | ||||||
|             task |  | ||||||
|             and pdb_obj |  | ||||||
|         ): |  | ||||||
|             log.pdb( |  | ||||||
|                 f"Ignoring SIGINT while task in debug mode: `{task}`" |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|         # TODO: how to handle the case of an intermediary-child actor |  | ||||||
|         # that **is not** marked in debug mode? See oustanding issue: |  | ||||||
|         # https://github.com/goodboy/tractor/issues/320 |  | ||||||
|         # elif debug_mode(): |  | ||||||
| 
 |  | ||||||
|     else:  # XXX: shouldn't ever get here? |  | ||||||
|         print("WTFWTFWTF") |  | ||||||
|         raise KeyboardInterrupt |  | ||||||
| 
 |  | ||||||
|     # NOTE: currently (at least on ``fancycompleter`` 0.9.2) |  | ||||||
|     # it looks to be that the last command that was run (eg. ll) |  | ||||||
|     # will be repeated by default. |  | ||||||
| 
 |  | ||||||
|     # maybe redraw/print last REPL output to console since |  | ||||||
|     # we want to alert the user that more input is expect since |  | ||||||
|     # nothing has been done dur to ignoring sigint. |  | ||||||
|     if ( |  | ||||||
|         pdb_obj  # only when this actor has a REPL engaged |  | ||||||
|     ): |  | ||||||
|         # XXX: yah, mega hack, but how else do we catch this madness XD |  | ||||||
|         if pdb_obj.shname == 'xonsh': |  | ||||||
|             pdb_obj.stdout.write(pdb_obj.prompt) |  | ||||||
| 
 |  | ||||||
|         pdb_obj.stdout.flush() |  | ||||||
| 
 |  | ||||||
|         # TODO: make this work like sticky mode where if there is output |  | ||||||
|         # detected as written to the tty we redraw this part underneath |  | ||||||
|         # and erase the past draw of this same bit above? |  | ||||||
|         # pdb_obj.sticky = True |  | ||||||
|         # pdb_obj._print_if_sticky() |  | ||||||
| 
 |  | ||||||
|         # also see these links for an approach from ``ptk``: |  | ||||||
|         # https://github.com/goodboy/tractor/issues/130#issuecomment-663752040 |  | ||||||
|         # https://github.com/prompt-toolkit/python-prompt-toolkit/blob/c2c6af8a0308f9e5d7c0e28cb8a02963fe0ce07a/prompt_toolkit/patch_stdout.py |  | ||||||
| 
 |  | ||||||
|         # XXX LEGACY: lol, see ``pdbpp`` issue: |  | ||||||
|         # https://github.com/pdbpp/pdbpp/issues/496 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def _set_trace( |  | ||||||
|     actor: tractor.Actor | None = None, |  | ||||||
|     pdb: MultiActorPdb | None = None, |  | ||||||
| ): |  | ||||||
|     __tracebackhide__ = True |  | ||||||
|     actor = actor or tractor.current_actor() |  | ||||||
| 
 |  | ||||||
|     # start 2 levels up in user code |  | ||||||
|     frame: Optional[FrameType] = sys._getframe() |  | ||||||
|     if frame: |  | ||||||
|         frame = frame.f_back  # type: ignore |  | ||||||
| 
 |  | ||||||
|     if ( |  | ||||||
|         frame |  | ||||||
|         and pdb |  | ||||||
|         and actor is not None |  | ||||||
|     ): |  | ||||||
|         log.pdb(f"\nAttaching pdb to actor: {actor.uid}\n") |  | ||||||
|         # no f!#$&* idea, but when we're in async land |  | ||||||
|         # we need 2x frames up? |  | ||||||
|         frame = frame.f_back |  | ||||||
| 
 |  | ||||||
|     else: |  | ||||||
|         pdb, undo_sigint = mk_mpdb() |  | ||||||
| 
 |  | ||||||
|         # we entered the global ``breakpoint()`` built-in from sync |  | ||||||
|         # code? |  | ||||||
|         Lock.local_task_in_debug = 'sync' |  | ||||||
| 
 |  | ||||||
|     pdb.set_trace(frame=frame) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| breakpoint = partial( |  | ||||||
|     _breakpoint, |  | ||||||
|     _set_trace, |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def _post_mortem( |  | ||||||
|     actor: tractor.Actor, |  | ||||||
|     pdb: MultiActorPdb, |  | ||||||
| 
 |  | ||||||
| ) -> None: |  | ||||||
|     ''' |  | ||||||
|     Enter the ``pdbpp`` port mortem entrypoint using our custom |  | ||||||
|     debugger instance. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     log.pdb(f"\nAttaching to pdb in crashed actor: {actor.uid}\n") |  | ||||||
| 
 |  | ||||||
|     # TODO: you need ``pdbpp`` master (at least this commit |  | ||||||
|     # https://github.com/pdbpp/pdbpp/commit/b757794857f98d53e3ebbe70879663d7d843a6c2) |  | ||||||
|     # to fix this and avoid the hang it causes. See issue: |  | ||||||
|     # https://github.com/pdbpp/pdbpp/issues/480 |  | ||||||
|     # TODO: help with a 3.10+ major release if/when it arrives. |  | ||||||
| 
 |  | ||||||
|     pdbp.xpm(Pdb=lambda: pdb) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| post_mortem = partial( |  | ||||||
|     _breakpoint, |  | ||||||
|     _post_mortem, |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| async def _maybe_enter_pm(err): |  | ||||||
|     if ( |  | ||||||
|         debug_mode() |  | ||||||
| 
 |  | ||||||
|         # NOTE: don't enter debug mode recursively after quitting pdb |  | ||||||
|         # Iow, don't re-enter the repl if the `quit` command was issued |  | ||||||
|         # by the user. |  | ||||||
|         and not isinstance(err, bdb.BdbQuit) |  | ||||||
| 
 |  | ||||||
|         # XXX: if the error is the likely result of runtime-wide |  | ||||||
|         # cancellation, we don't want to enter the debugger since |  | ||||||
|         # there's races between when the parent actor has killed all |  | ||||||
|         # comms and when the child tries to contact said parent to |  | ||||||
|         # acquire the tty lock. |  | ||||||
| 
 |  | ||||||
|         # Really we just want to mostly avoid catching KBIs here so there |  | ||||||
|         # might be a simpler check we can do? |  | ||||||
|         and not is_multi_cancelled(err) |  | ||||||
|     ): |  | ||||||
|         log.debug("Actor crashed, entering debug mode") |  | ||||||
|         try: |  | ||||||
|             await post_mortem() |  | ||||||
|         finally: |  | ||||||
|             Lock.release() |  | ||||||
|             return True |  | ||||||
| 
 |  | ||||||
|     else: |  | ||||||
|         return False |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @acm |  | ||||||
| async def acquire_debug_lock( |  | ||||||
|     subactor_uid: tuple[str, str], |  | ||||||
| ) -> AsyncGenerator[None, tuple]: |  | ||||||
|     ''' |  | ||||||
|     Grab root's debug lock on entry, release on exit. |  | ||||||
| 
 |  | ||||||
|     This helper is for actor's who don't actually need |  | ||||||
|     to acquired the debugger but want to wait until the |  | ||||||
|     lock is free in the process-tree root. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     if not debug_mode(): |  | ||||||
|         yield None |  | ||||||
|         return |  | ||||||
| 
 |  | ||||||
|     async with trio.open_nursery() as n: |  | ||||||
|         cs = await n.start( |  | ||||||
|             wait_for_parent_stdin_hijack, |  | ||||||
|             subactor_uid, |  | ||||||
|         ) |  | ||||||
|         yield None |  | ||||||
|         cs.cancel() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| async def maybe_wait_for_debugger( |  | ||||||
|     poll_steps: int = 2, |  | ||||||
|     poll_delay: float = 0.1, |  | ||||||
|     child_in_debug: bool = False, |  | ||||||
| 
 |  | ||||||
| ) -> None: |  | ||||||
| 
 |  | ||||||
|     if ( |  | ||||||
|         not debug_mode() |  | ||||||
|         and not child_in_debug |  | ||||||
|     ): |  | ||||||
|         return |  | ||||||
| 
 |  | ||||||
|     if ( |  | ||||||
|         is_root_process() |  | ||||||
|     ): |  | ||||||
|         # If we error in the root but the debugger is |  | ||||||
|         # engaged we don't want to prematurely kill (and |  | ||||||
|         # thus clobber access to) the local tty since it |  | ||||||
|         # will make the pdb repl unusable. |  | ||||||
|         # Instead try to wait for pdb to be released before |  | ||||||
|         # tearing down. |  | ||||||
| 
 |  | ||||||
|         sub_in_debug = None |  | ||||||
| 
 |  | ||||||
|         for _ in range(poll_steps): |  | ||||||
| 
 |  | ||||||
|             if Lock.global_actor_in_debug: |  | ||||||
|                 sub_in_debug = tuple(Lock.global_actor_in_debug) |  | ||||||
| 
 |  | ||||||
|             log.debug('Root polling for debug') |  | ||||||
| 
 |  | ||||||
|             with trio.CancelScope(shield=True): |  | ||||||
|                 await trio.sleep(poll_delay) |  | ||||||
| 
 |  | ||||||
|                 # TODO: could this make things more deterministic?  wait |  | ||||||
|                 # to see if a sub-actor task will be scheduled and grab |  | ||||||
|                 # the tty lock on the next tick? |  | ||||||
|                 # XXX: doesn't seem to work |  | ||||||
|                 # await trio.testing.wait_all_tasks_blocked(cushion=0) |  | ||||||
| 
 |  | ||||||
|                 debug_complete = Lock.no_remote_has_tty |  | ||||||
|                 if ( |  | ||||||
|                     (debug_complete and |  | ||||||
|                      not debug_complete.is_set()) |  | ||||||
|                 ): |  | ||||||
|                     log.debug( |  | ||||||
|                         'Root has errored but pdb is in use by ' |  | ||||||
|                         f'child {sub_in_debug}\n' |  | ||||||
|                         'Waiting on tty lock to release..') |  | ||||||
| 
 |  | ||||||
|                     await debug_complete.wait() |  | ||||||
| 
 |  | ||||||
|                 await trio.sleep(poll_delay) |  | ||||||
|                 continue |  | ||||||
|         else: |  | ||||||
|             log.debug( |  | ||||||
|                     'Root acquired TTY LOCK' |  | ||||||
|             ) |  | ||||||
|  | @ -15,52 +15,71 @@ | ||||||
| # along with this program.  If not, see <https://www.gnu.org/licenses/>. | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
| Actor discovery API. | Discovery (protocols) API for automatic addressing and location | ||||||
|  | management of (service) actors. | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
|  | from __future__ import annotations | ||||||
| from typing import ( | from typing import ( | ||||||
|     Optional, |  | ||||||
|     Union, |  | ||||||
|     AsyncGenerator, |     AsyncGenerator, | ||||||
|  |     AsyncContextManager, | ||||||
|  |     TYPE_CHECKING, | ||||||
| ) | ) | ||||||
| from contextlib import asynccontextmanager as acm | from contextlib import asynccontextmanager as acm | ||||||
| 
 | 
 | ||||||
|  | from tractor.log import get_logger | ||||||
|  | from .trionics import gather_contexts | ||||||
| from ._ipc import _connect_chan, Channel | from ._ipc import _connect_chan, Channel | ||||||
| from ._portal import ( | from ._portal import ( | ||||||
|     Portal, |     Portal, | ||||||
|     open_portal, |     open_portal, | ||||||
|     LocalPortal, |     LocalPortal, | ||||||
| ) | ) | ||||||
| from ._state import current_actor, _runtime_vars | from ._state import ( | ||||||
|  |     current_actor, | ||||||
|  |     _runtime_vars, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from ._runtime import Actor | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @acm | @acm | ||||||
| async def get_arbiter( | async def get_registry( | ||||||
| 
 |  | ||||||
|     host: str, |     host: str, | ||||||
|     port: int, |     port: int, | ||||||
| 
 | 
 | ||||||
| ) -> AsyncGenerator[Union[Portal, LocalPortal], None]: | ) -> AsyncGenerator[ | ||||||
|  |     Portal | LocalPortal | None, | ||||||
|  |     None, | ||||||
|  | ]: | ||||||
|     ''' |     ''' | ||||||
|     Return a portal instance connected to a local or remote |     Return a portal instance connected to a local or remote | ||||||
|     arbiter. |     registry-service actor; if a connection already exists re-use it | ||||||
|  |     (presumably to call a `.register_actor()` registry runtime RPC | ||||||
|  |     ep). | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     actor = current_actor() |     actor: Actor = current_actor() | ||||||
| 
 |     if actor.is_registrar: | ||||||
|     if not actor: |  | ||||||
|         raise RuntimeError("No actor instance has been defined yet?") |  | ||||||
| 
 |  | ||||||
|     if actor.is_arbiter: |  | ||||||
|         # we're already the arbiter |         # we're already the arbiter | ||||||
|         # (likely a re-entrant call from the arbiter actor) |         # (likely a re-entrant call from the arbiter actor) | ||||||
|         yield LocalPortal(actor, Channel((host, port))) |         yield LocalPortal( | ||||||
|  |             actor, | ||||||
|  |             Channel((host, port)) | ||||||
|  |         ) | ||||||
|     else: |     else: | ||||||
|         async with _connect_chan(host, port) as chan: |         # TODO: try to look pre-existing connection from | ||||||
|  |         # `Actor._peers` and use it instead? | ||||||
|  |         async with ( | ||||||
|  |             _connect_chan(host, port) as chan, | ||||||
|  |             open_portal(chan) as regstr_ptl, | ||||||
|  |         ): | ||||||
|  |             yield regstr_ptl | ||||||
| 
 | 
 | ||||||
|             async with open_portal(chan) as arb_portal: |  | ||||||
| 
 |  | ||||||
|                 yield arb_portal |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @acm | @acm | ||||||
|  | @ -68,62 +87,104 @@ async def get_root( | ||||||
|     **kwargs, |     **kwargs, | ||||||
| ) -> AsyncGenerator[Portal, None]: | ) -> AsyncGenerator[Portal, None]: | ||||||
| 
 | 
 | ||||||
|  |     # TODO: rename mailbox to `_root_maddr` when we finally | ||||||
|  |     # add and impl libp2p multi-addrs? | ||||||
|     host, port = _runtime_vars['_root_mailbox'] |     host, port = _runtime_vars['_root_mailbox'] | ||||||
|     assert host is not None |     assert host is not None | ||||||
| 
 | 
 | ||||||
|     async with _connect_chan(host, port) as chan: |     async with ( | ||||||
|         async with open_portal(chan, **kwargs) as portal: |         _connect_chan(host, port) as chan, | ||||||
|  |         open_portal(chan, **kwargs) as portal, | ||||||
|  |     ): | ||||||
|         yield portal |         yield portal | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def get_peer_by_name( | ||||||
|  |     name: str, | ||||||
|  |     # uuid: str|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> list[Channel]|None:  # at least 1 | ||||||
|  |     ''' | ||||||
|  |     Scan for an existing connection (set) to a named actor | ||||||
|  |     and return any channels from `Actor._peers`. | ||||||
|  | 
 | ||||||
|  |     This is an optimization method over querying the registrar for | ||||||
|  |     the same info. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     actor: Actor = current_actor() | ||||||
|  |     to_scan: dict[tuple, list[Channel]] = actor._peers.copy() | ||||||
|  |     pchan: Channel|None = actor._parent_chan | ||||||
|  |     if pchan: | ||||||
|  |         to_scan[pchan.uid].append(pchan) | ||||||
|  | 
 | ||||||
|  |     for aid, chans in to_scan.items(): | ||||||
|  |         _, peer_name = aid | ||||||
|  |         if name == peer_name: | ||||||
|  |             if not chans: | ||||||
|  |                 log.warning( | ||||||
|  |                     'No IPC chans for matching peer {peer_name}\n' | ||||||
|  |                 ) | ||||||
|  |                 continue | ||||||
|  |             return chans | ||||||
|  | 
 | ||||||
|  |     return None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @acm | @acm | ||||||
| async def query_actor( | async def query_actor( | ||||||
|     name: str, |     name: str, | ||||||
|     arbiter_sockaddr: Optional[tuple[str, int]] = None, |     regaddr: tuple[str, int]|None = None, | ||||||
| 
 | 
 | ||||||
| ) -> AsyncGenerator[tuple[str, int], None]: | ) -> AsyncGenerator[ | ||||||
|  |     tuple[str, int]|None, | ||||||
|  |     None, | ||||||
|  | ]: | ||||||
|     ''' |     ''' | ||||||
|     Simple address lookup for a given actor name. |     Lookup a transport address (by actor name) via querying a registrar | ||||||
|  |     listening @ `regaddr`. | ||||||
| 
 | 
 | ||||||
|     Returns the (socket) address or ``None``. |     Returns the transport protocol (socket) address or `None` if no | ||||||
|  |     entry under that name exists. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     actor = current_actor() |     actor: Actor = current_actor() | ||||||
|     async with get_arbiter( |     if ( | ||||||
|         *arbiter_sockaddr or actor._arb_addr |         name == 'registrar' | ||||||
|     ) as arb_portal: |         and actor.is_registrar | ||||||
|  |     ): | ||||||
|  |         raise RuntimeError( | ||||||
|  |             'The current actor IS the registry!?' | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         sockaddr = await arb_portal.run_from_ns( |     maybe_peers: list[Channel]|None = get_peer_by_name(name) | ||||||
|  |     if maybe_peers: | ||||||
|  |         yield maybe_peers[0].raddr | ||||||
|  |         return | ||||||
|  | 
 | ||||||
|  |     reg_portal: Portal | ||||||
|  |     regaddr: tuple[str, int] = regaddr or actor.reg_addrs[0] | ||||||
|  |     async with get_registry(*regaddr) as reg_portal: | ||||||
|  |         # TODO: return portals to all available actors - for now | ||||||
|  |         # just the last one that registered | ||||||
|  |         sockaddr: tuple[str, int] = await reg_portal.run_from_ns( | ||||||
|             'self', |             'self', | ||||||
|             'find_actor', |             'find_actor', | ||||||
|             name=name, |             name=name, | ||||||
|         ) |         ) | ||||||
| 
 |         yield sockaddr | ||||||
|         # TODO: return portals to all available actors - for now just |  | ||||||
|         # the last one that registered |  | ||||||
|         if name == 'arbiter' and actor.is_arbiter: |  | ||||||
|             raise RuntimeError("The current actor is the arbiter") |  | ||||||
| 
 |  | ||||||
|         yield sockaddr if sockaddr else None |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @acm | @acm | ||||||
| async def find_actor( | async def maybe_open_portal( | ||||||
|  |     addr: tuple[str, int], | ||||||
|     name: str, |     name: str, | ||||||
|     arbiter_sockaddr: tuple[str, int] | None = None | ): | ||||||
| 
 |  | ||||||
| ) -> AsyncGenerator[Optional[Portal], None]: |  | ||||||
|     ''' |  | ||||||
|     Ask the arbiter to find actor(s) by name. |  | ||||||
| 
 |  | ||||||
|     Returns a connected portal to the last registered matching actor |  | ||||||
|     known to the arbiter. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     async with query_actor( |     async with query_actor( | ||||||
|         name=name, |         name=name, | ||||||
|         arbiter_sockaddr=arbiter_sockaddr, |         regaddr=addr, | ||||||
|     ) as sockaddr: |     ) as sockaddr: | ||||||
|  |         pass | ||||||
| 
 | 
 | ||||||
|     if sockaddr: |     if sockaddr: | ||||||
|         async with _connect_chan(*sockaddr) as chan: |         async with _connect_chan(*sockaddr) as chan: | ||||||
|  | @ -133,30 +194,121 @@ async def find_actor( | ||||||
|         yield None |         yield None | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @acm | ||||||
|  | async def find_actor( | ||||||
|  |     name: str, | ||||||
|  |     registry_addrs: list[tuple[str, int]]|None = None, | ||||||
|  | 
 | ||||||
|  |     only_first: bool = True, | ||||||
|  |     raise_on_none: bool = False, | ||||||
|  | 
 | ||||||
|  | ) -> AsyncGenerator[ | ||||||
|  |     Portal | list[Portal] | None, | ||||||
|  |     None, | ||||||
|  | ]: | ||||||
|  |     ''' | ||||||
|  |     Ask the arbiter to find actor(s) by name. | ||||||
|  | 
 | ||||||
|  |     Returns a connected portal to the last registered matching actor | ||||||
|  |     known to the arbiter. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # optimization path, use any pre-existing peer channel | ||||||
|  |     maybe_peers: list[Channel]|None = get_peer_by_name(name) | ||||||
|  |     if maybe_peers and only_first: | ||||||
|  |         async with open_portal(maybe_peers[0]) as peer_portal: | ||||||
|  |             yield peer_portal | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |     if not registry_addrs: | ||||||
|  |         # XXX NOTE: make sure to dynamically read the value on | ||||||
|  |         # every call since something may change it globally (eg. | ||||||
|  |         # like in our discovery test suite)! | ||||||
|  |         from . import _root | ||||||
|  |         registry_addrs = ( | ||||||
|  |             _runtime_vars['_registry_addrs'] | ||||||
|  |             or | ||||||
|  |             _root._default_lo_addrs | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     maybe_portals: list[ | ||||||
|  |         AsyncContextManager[tuple[str, int]] | ||||||
|  |     ] = list( | ||||||
|  |         maybe_open_portal( | ||||||
|  |             addr=addr, | ||||||
|  |             name=name, | ||||||
|  |         ) | ||||||
|  |         for addr in registry_addrs | ||||||
|  |     ) | ||||||
|  |     portals: list[Portal] | ||||||
|  |     async with gather_contexts( | ||||||
|  |         mngrs=maybe_portals, | ||||||
|  |     ) as portals: | ||||||
|  |         # log.runtime( | ||||||
|  |         #     'Gathered portals:\n' | ||||||
|  |         #     f'{portals}' | ||||||
|  |         # ) | ||||||
|  |         # NOTE: `gather_contexts()` will return a | ||||||
|  |         # `tuple[None, None, ..., None]` if no contact | ||||||
|  |         # can be made with any regstrar at any of the | ||||||
|  |         # N provided addrs! | ||||||
|  |         if not any(portals): | ||||||
|  |             if raise_on_none: | ||||||
|  |                 raise RuntimeError( | ||||||
|  |                     f'No actor "{name}" found registered @ {registry_addrs}' | ||||||
|  |                 ) | ||||||
|  |             yield None | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |         portals: list[Portal] = list(portals) | ||||||
|  |         if only_first: | ||||||
|  |             yield portals[0] | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             # TODO: currently this may return multiple portals | ||||||
|  |             # given there are multi-homed or multiple registrars.. | ||||||
|  |             # SO, we probably need de-duplication logic? | ||||||
|  |             yield portals | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @acm | @acm | ||||||
| async def wait_for_actor( | async def wait_for_actor( | ||||||
|     name: str, |     name: str, | ||||||
|     arbiter_sockaddr: tuple[str, int] | None = None, |     registry_addr: tuple[str, int] | None = None, | ||||||
|     # registry_addr: tuple[str, int] | None = None, |  | ||||||
| 
 | 
 | ||||||
| ) -> AsyncGenerator[Portal, None]: | ) -> AsyncGenerator[Portal, None]: | ||||||
|     ''' |     ''' | ||||||
|     Wait on an actor to register with the arbiter. |     Wait on at least one peer actor to register `name` with the | ||||||
| 
 |     registrar, yield a `Portal to the first registree. | ||||||
|     A portal to the first registered actor is returned. |  | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     actor = current_actor() |     actor: Actor = current_actor() | ||||||
| 
 | 
 | ||||||
|     async with get_arbiter( |     # optimization path, use any pre-existing peer channel | ||||||
|         *arbiter_sockaddr or actor._arb_addr, |     maybe_peers: list[Channel]|None = get_peer_by_name(name) | ||||||
|     ) as arb_portal: |     if maybe_peers: | ||||||
|         sockaddrs = await arb_portal.run_from_ns( |         async with open_portal(maybe_peers[0]) as peer_portal: | ||||||
|  |             yield peer_portal | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |     regaddr: tuple[str, int] = ( | ||||||
|  |         registry_addr | ||||||
|  |         or | ||||||
|  |         actor.reg_addrs[0] | ||||||
|  |     ) | ||||||
|  |     # TODO: use `.trionics.gather_contexts()` like | ||||||
|  |     # above in `find_actor()` as well? | ||||||
|  |     reg_portal: Portal | ||||||
|  |     async with get_registry(*regaddr) as reg_portal: | ||||||
|  |         sockaddrs = await reg_portal.run_from_ns( | ||||||
|             'self', |             'self', | ||||||
|             'wait_for_actor', |             'wait_for_actor', | ||||||
|             name=name, |             name=name, | ||||||
|         ) |         ) | ||||||
|         sockaddr = sockaddrs[-1] | 
 | ||||||
|  |         # get latest registered addr by default? | ||||||
|  |         # TODO: offer multi-portal yields in multi-homed case? | ||||||
|  |         sockaddr: tuple[str, int] = sockaddrs[-1] | ||||||
| 
 | 
 | ||||||
|         async with _connect_chan(*sockaddr) as chan: |         async with _connect_chan(*sockaddr) as chan: | ||||||
|             async with open_portal(chan) as portal: |             async with open_portal(chan) as portal: | ||||||
|  |  | ||||||
|  | @ -20,6 +20,9 @@ Sub-process entry points. | ||||||
| """ | """ | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
| from functools import partial | from functools import partial | ||||||
|  | import multiprocessing as mp | ||||||
|  | import os | ||||||
|  | import textwrap | ||||||
| from typing import ( | from typing import ( | ||||||
|     Any, |     Any, | ||||||
|     TYPE_CHECKING, |     TYPE_CHECKING, | ||||||
|  | @ -32,6 +35,7 @@ from .log import ( | ||||||
|     get_logger, |     get_logger, | ||||||
| ) | ) | ||||||
| from . import _state | from . import _state | ||||||
|  | from .devx import _debug | ||||||
| from .to_asyncio import run_as_asyncio_guest | from .to_asyncio import run_as_asyncio_guest | ||||||
| from ._runtime import ( | from ._runtime import ( | ||||||
|     async_main, |     async_main, | ||||||
|  | @ -47,8 +51,8 @@ log = get_logger(__name__) | ||||||
| 
 | 
 | ||||||
| def _mp_main( | def _mp_main( | ||||||
| 
 | 
 | ||||||
|     actor: Actor,  # type: ignore |     actor: Actor, | ||||||
|     accept_addr: tuple[str, int], |     accept_addrs: list[tuple[str, int]], | ||||||
|     forkserver_info: tuple[Any, Any, Any, Any, Any], |     forkserver_info: tuple[Any, Any, Any, Any, Any], | ||||||
|     start_method: SpawnMethodKey, |     start_method: SpawnMethodKey, | ||||||
|     parent_addr: tuple[str, int] | None = None, |     parent_addr: tuple[str, int] | None = None, | ||||||
|  | @ -56,29 +60,31 @@ def _mp_main( | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     ''' |     ''' | ||||||
|     The routine called *after fork* which invokes a fresh ``trio.run`` |     The routine called *after fork* which invokes a fresh `trio.run()` | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     actor._forkserver_info = forkserver_info |     actor._forkserver_info = forkserver_info | ||||||
|     from ._spawn import try_set_start_method |     from ._spawn import try_set_start_method | ||||||
|     spawn_ctx = try_set_start_method(start_method) |     spawn_ctx: mp.context.BaseContext = try_set_start_method(start_method) | ||||||
|  |     assert spawn_ctx | ||||||
| 
 | 
 | ||||||
|     if actor.loglevel is not None: |     if actor.loglevel is not None: | ||||||
|         log.info( |         log.info( | ||||||
|             f"Setting loglevel for {actor.uid} to {actor.loglevel}") |             f'Setting loglevel for {actor.uid} to {actor.loglevel}' | ||||||
|  |         ) | ||||||
|         get_console_log(actor.loglevel) |         get_console_log(actor.loglevel) | ||||||
| 
 | 
 | ||||||
|     assert spawn_ctx |     # TODO: use scops headers like for `trio` below! | ||||||
|  |     # (well after we libify it maybe..) | ||||||
|     log.info( |     log.info( | ||||||
|         f"Started new {spawn_ctx.current_process()} for {actor.uid}") |         f'Started new {spawn_ctx.current_process()} for {actor.uid}' | ||||||
| 
 |     #     f"parent_addr is {parent_addr}" | ||||||
|     _state._current_actor = actor |     ) | ||||||
| 
 |     _state._current_actor: Actor = actor | ||||||
|     log.debug(f"parent_addr is {parent_addr}") |  | ||||||
|     trio_main = partial( |     trio_main = partial( | ||||||
|         async_main, |         async_main, | ||||||
|         actor, |         actor=actor, | ||||||
|         accept_addr, |         accept_addrs=accept_addrs, | ||||||
|         parent_addr=parent_addr |         parent_addr=parent_addr | ||||||
|     ) |     ) | ||||||
|     try: |     try: | ||||||
|  | @ -91,12 +97,114 @@ def _mp_main( | ||||||
|         pass  # handle it the same way trio does? |         pass  # handle it the same way trio does? | ||||||
| 
 | 
 | ||||||
|     finally: |     finally: | ||||||
|         log.info(f"Actor {actor.uid} terminated") |         log.info( | ||||||
|  |             f'`mp`-subactor {actor.uid} exited' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: move this func to some kinda `.devx._conc_lang.py` eventually | ||||||
|  | # as we work out our multi-domain state-flow-syntax! | ||||||
|  | def nest_from_op( | ||||||
|  |     input_op: str, | ||||||
|  |     # | ||||||
|  |     # ?TODO? an idea for a syntax to the state of concurrent systems | ||||||
|  |     # as a "3-domain" (execution, scope, storage) model and using | ||||||
|  |     # a minimal ascii/utf-8 operator-set. | ||||||
|  |     # | ||||||
|  |     # try not to take any of this seriously yet XD | ||||||
|  |     # | ||||||
|  |     # > is a "play operator" indicating (CPU bound) | ||||||
|  |     #   exec/work/ops required at the "lowest level computing" | ||||||
|  |     # | ||||||
|  |     # execution primititves (tasks, threads, actors..) denote their | ||||||
|  |     # lifetime with '(' and ')' since parentheses normally are used | ||||||
|  |     # in many langs to denote function calls. | ||||||
|  |     # | ||||||
|  |     # starting = ( | ||||||
|  |     # >(  opening/starting; beginning of the thread-of-exec (toe?) | ||||||
|  |     # (>  opened/started,  (finished spawning toe) | ||||||
|  |     # |_<Task: blah blah..>  repr of toe, in py these look like <objs> | ||||||
|  |     # | ||||||
|  |     # >) closing/exiting/stopping, | ||||||
|  |     # )> closed/exited/stopped, | ||||||
|  |     # |_<Task: blah blah..> | ||||||
|  |     #   [OR <), )< ?? ] | ||||||
|  |     # | ||||||
|  |     # ending = ) | ||||||
|  |     # >c) cancelling to close/exit | ||||||
|  |     # c)> cancelled (caused close), OR? | ||||||
|  |     #  |_<Actor: ..> | ||||||
|  |     #   OR maybe "<c)" which better indicates the cancel being | ||||||
|  |     #   "delivered/returned" / returned" to LHS? | ||||||
|  |     # | ||||||
|  |     # >x)  erroring to eventuall exit | ||||||
|  |     # x)>  errored and terminated | ||||||
|  |     #  |_<Actor: ...> | ||||||
|  |     # | ||||||
|  |     # scopes: supers/nurseries, IPC-ctxs, sessions, perms, etc. | ||||||
|  |     # >{  opening | ||||||
|  |     # {>  opened | ||||||
|  |     # }>  closed | ||||||
|  |     # >}  closing | ||||||
|  |     # | ||||||
|  |     # storage: like queues, shm-buffers, files, etc.. | ||||||
|  |     # >[  opening | ||||||
|  |     # [>  opened | ||||||
|  |     #  |_<FileObj: ..> | ||||||
|  |     # | ||||||
|  |     # >]  closing | ||||||
|  |     # ]>  closed | ||||||
|  | 
 | ||||||
|  |     # IPC ops: channels, transports, msging | ||||||
|  |     # =>  req msg | ||||||
|  |     # <=  resp msg | ||||||
|  |     # <=> 2-way streaming (of msgs) | ||||||
|  |     # <-  recv 1 msg | ||||||
|  |     # ->  send 1 msg | ||||||
|  |     # | ||||||
|  |     # TODO: still not sure on R/L-HS approach..? | ||||||
|  |     # =>(  send-req to exec start (task, actor, thread..) | ||||||
|  |     # (<=  recv-req to ^ | ||||||
|  |     # | ||||||
|  |     # (<=  recv-req ^ | ||||||
|  |     # <=(  recv-resp opened remote exec primitive | ||||||
|  |     # <=)  recv-resp closed | ||||||
|  |     # | ||||||
|  |     # )<=c req to stop due to cancel | ||||||
|  |     # c=>) req to stop due to cancel | ||||||
|  |     # | ||||||
|  |     # =>{  recv-req to open | ||||||
|  |     # <={  send-status that it closed | ||||||
|  | 
 | ||||||
|  |     tree_str: str, | ||||||
|  | 
 | ||||||
|  |     # NOTE: so move back-from-the-left of the `input_op` by | ||||||
|  |     # this amount. | ||||||
|  |     back_from_op: int = 0, | ||||||
|  | ) -> str: | ||||||
|  |     ''' | ||||||
|  |     Depth-increment the input (presumably hierarchy/supervision) | ||||||
|  |     input "tree string" below the provided `input_op` execution | ||||||
|  |     operator, so injecting a `"\n|_{input_op}\n"`and indenting the | ||||||
|  |     `tree_str` to nest content aligned with the ops last char. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     return ( | ||||||
|  |         f'{input_op}\n' | ||||||
|  |         + | ||||||
|  |         textwrap.indent( | ||||||
|  |             tree_str, | ||||||
|  |             prefix=( | ||||||
|  |                 len(input_op) | ||||||
|  |                 - | ||||||
|  |                 (back_from_op + 1) | ||||||
|  |             ) * ' ', | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _trio_main( | def _trio_main( | ||||||
| 
 |     actor: Actor, | ||||||
|     actor: Actor,  # type: ignore |  | ||||||
|     *, |     *, | ||||||
|     parent_addr: tuple[str, int] | None = None, |     parent_addr: tuple[str, int] | None = None, | ||||||
|     infect_asyncio: bool = False, |     infect_asyncio: bool = False, | ||||||
|  | @ -106,33 +214,73 @@ def _trio_main( | ||||||
|     Entry point for a `trio_run_in_process` subactor. |     Entry point for a `trio_run_in_process` subactor. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     log.info(f"Started new trio process for {actor.uid}") |     _debug.hide_runtime_frames() | ||||||
| 
 |  | ||||||
|     if actor.loglevel is not None: |  | ||||||
|         log.info( |  | ||||||
|             f"Setting loglevel for {actor.uid} to {actor.loglevel}") |  | ||||||
|         get_console_log(actor.loglevel) |  | ||||||
| 
 |  | ||||||
|     log.info( |  | ||||||
|         f"Started {actor.uid}") |  | ||||||
| 
 | 
 | ||||||
|     _state._current_actor = actor |     _state._current_actor = actor | ||||||
| 
 |  | ||||||
|     log.debug(f"parent_addr is {parent_addr}") |  | ||||||
|     trio_main = partial( |     trio_main = partial( | ||||||
|         async_main, |         async_main, | ||||||
|         actor, |         actor, | ||||||
|         parent_addr=parent_addr |         parent_addr=parent_addr | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  |     if actor.loglevel is not None: | ||||||
|  |         get_console_log(actor.loglevel) | ||||||
|  |         actor_info: str = ( | ||||||
|  |             f'|_{actor}\n' | ||||||
|  |             f'  uid: {actor.uid}\n' | ||||||
|  |             f'  pid: {os.getpid()}\n' | ||||||
|  |             f'  parent_addr: {parent_addr}\n' | ||||||
|  |             f'  loglevel: {actor.loglevel}\n' | ||||||
|  |         ) | ||||||
|  |         log.info( | ||||||
|  |             'Starting new `trio` subactor:\n' | ||||||
|  |             + | ||||||
|  |             nest_from_op( | ||||||
|  |                 input_op='>(',  # see syntax ideas above | ||||||
|  |                 tree_str=actor_info, | ||||||
|  |                 back_from_op=1, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |     logmeth = log.info | ||||||
|  |     exit_status: str = ( | ||||||
|  |         'Subactor exited\n' | ||||||
|  |         + | ||||||
|  |         nest_from_op( | ||||||
|  |             input_op=')>',  # like a "closed-to-play"-icon from super perspective | ||||||
|  |             tree_str=actor_info, | ||||||
|  |             back_from_op=1, | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|     try: |     try: | ||||||
|         if infect_asyncio: |         if infect_asyncio: | ||||||
|             actor._infected_aio = True |             actor._infected_aio = True | ||||||
|             run_as_asyncio_guest(trio_main) |             run_as_asyncio_guest(trio_main) | ||||||
|         else: |         else: | ||||||
|             trio.run(trio_main) |             trio.run(trio_main) | ||||||
|  | 
 | ||||||
|     except KeyboardInterrupt: |     except KeyboardInterrupt: | ||||||
|         log.cancel(f"Actor {actor.uid} received KBI") |         logmeth = log.cancel | ||||||
|  |         exit_status: str = ( | ||||||
|  |             'Actor received KBI (aka an OS-cancel)\n' | ||||||
|  |             + | ||||||
|  |             nest_from_op( | ||||||
|  |                 input_op='c)>',  # closed due to cancel (see above) | ||||||
|  |                 tree_str=actor_info, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |     except BaseException as err: | ||||||
|  |         logmeth = log.error | ||||||
|  |         exit_status: str = ( | ||||||
|  |             'Main actor task exited due to crash?\n' | ||||||
|  |             + | ||||||
|  |             nest_from_op( | ||||||
|  |                 input_op='x)>',  # closed by error | ||||||
|  |                 tree_str=actor_info, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         # NOTE since we raise a tb will already be shown on the | ||||||
|  |         # console, thus we do NOT use `.exception()` above. | ||||||
|  |         raise err | ||||||
| 
 | 
 | ||||||
|     finally: |     finally: | ||||||
|         log.info(f"Actor {actor.uid} terminated") |         logmeth(exit_status) | ||||||
|  |  | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										536
									
								
								tractor/_ipc.py
								
								
								
								
							
							
						
						
									
										536
									
								
								tractor/_ipc.py
								
								
								
								
							|  | @ -19,38 +19,64 @@ Inter-process comms abstractions | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
| import platform |  | ||||||
| import struct |  | ||||||
| import typing |  | ||||||
| from collections.abc import ( | from collections.abc import ( | ||||||
|     AsyncGenerator, |     AsyncGenerator, | ||||||
|     AsyncIterator, |     AsyncIterator, | ||||||
| ) | ) | ||||||
|  | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  |     contextmanager as cm, | ||||||
|  | ) | ||||||
|  | import platform | ||||||
|  | from pprint import pformat | ||||||
|  | import struct | ||||||
|  | import typing | ||||||
| from typing import ( | from typing import ( | ||||||
|     Any, |     Any, | ||||||
|  |     Callable, | ||||||
|     runtime_checkable, |     runtime_checkable, | ||||||
|     Optional, |  | ||||||
|     Protocol, |     Protocol, | ||||||
|     Type, |     Type, | ||||||
|     TypeVar, |     TypeVar, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| from tricycle import BufferedReceiveStream |  | ||||||
| import msgspec | import msgspec | ||||||
|  | from tricycle import BufferedReceiveStream | ||||||
| import trio | import trio | ||||||
| from async_generator import asynccontextmanager |  | ||||||
| 
 | 
 | ||||||
| from .log import get_logger | from tractor.log import get_logger | ||||||
| from ._exceptions import TransportClosed | from tractor._exceptions import ( | ||||||
|  |     MsgTypeError, | ||||||
|  |     pack_from_raise, | ||||||
|  |     TransportClosed, | ||||||
|  |     _mk_send_mte, | ||||||
|  |     _mk_recv_mte, | ||||||
|  | ) | ||||||
|  | from tractor.msg import ( | ||||||
|  |     _ctxvar_MsgCodec, | ||||||
|  |     # _codec,  XXX see `self._codec` sanity/debug checks | ||||||
|  |     MsgCodec, | ||||||
|  |     types as msgtypes, | ||||||
|  |     pretty_struct, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
| log = get_logger(__name__) | log = get_logger(__name__) | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| _is_windows = platform.system() == 'Windows' | _is_windows = platform.system() == 'Windows' | ||||||
| log = get_logger(__name__) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_stream_addrs(stream: trio.SocketStream) -> tuple: | def get_stream_addrs( | ||||||
|     # should both be IP sockets |     stream: trio.SocketStream | ||||||
|  | ) -> tuple[ | ||||||
|  |     tuple[str, int],  # local | ||||||
|  |     tuple[str, int],  # remote | ||||||
|  | ]: | ||||||
|  |     ''' | ||||||
|  |     Return the `trio` streaming transport prot's socket-addrs for | ||||||
|  |     both the local and remote sides as a pair. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # rn, should both be IP sockets | ||||||
|     lsockname = stream.socket.getsockname() |     lsockname = stream.socket.getsockname() | ||||||
|     rsockname = stream.socket.getpeername() |     rsockname = stream.socket.getpeername() | ||||||
|     return ( |     return ( | ||||||
|  | @ -59,16 +85,22 @@ def get_stream_addrs(stream: trio.SocketStream) -> tuple: | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| MsgType = TypeVar("MsgType") | # from tractor.msg.types import MsgType | ||||||
| 
 | # ?TODO? this should be our `Union[*msgtypes.__spec__]` alias now right..? | ||||||
| # TODO: consider using a generic def and indexing with our eventual | # => BLEH, except can't bc prots must inherit typevar or param-spec | ||||||
| # msg definition/types? | #   vars.. | ||||||
| # - https://docs.python.org/3/library/typing.html#typing.Protocol | MsgType = TypeVar('MsgType') | ||||||
| # - https://jcristharif.com/msgspec/usage.html#structs |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | # TODO: break up this mod into a subpkg so we can start adding new | ||||||
|  | # backends and move this type stuff into a dedicated file.. Bo | ||||||
|  | # | ||||||
| @runtime_checkable | @runtime_checkable | ||||||
| class MsgTransport(Protocol[MsgType]): | class MsgTransport(Protocol[MsgType]): | ||||||
|  | # | ||||||
|  | # ^-TODO-^ consider using a generic def and indexing with our | ||||||
|  | # eventual msg definition/types? | ||||||
|  | # - https://docs.python.org/3/library/typing.html#typing.Protocol | ||||||
| 
 | 
 | ||||||
|     stream: trio.SocketStream |     stream: trio.SocketStream | ||||||
|     drained: list[MsgType] |     drained: list[MsgType] | ||||||
|  | @ -103,20 +135,37 @@ class MsgTransport(Protocol[MsgType]): | ||||||
|         ... |         ... | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # TODO: not sure why we have to inherit here, but it seems to be an | # TODO: typing oddity.. not sure why we have to inherit here, but it | ||||||
| # issue with ``get_msg_transport()`` returning a ``Type[Protocol]``; | # seems to be an issue with `get_msg_transport()` returning | ||||||
| # probably should make a `mypy` issue? | # a `Type[Protocol]`; probably should make a `mypy` issue? | ||||||
| class MsgpackTCPStream(MsgTransport): | class MsgpackTCPStream(MsgTransport): | ||||||
|     ''' |     ''' | ||||||
|     A ``trio.SocketStream`` delivering ``msgpack`` formatted data |     A ``trio.SocketStream`` delivering ``msgpack`` formatted data | ||||||
|     using the ``msgspec`` codec lib. |     using the ``msgspec`` codec lib. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|  |     layer_key: int = 4 | ||||||
|  |     name_key: str = 'tcp' | ||||||
|  | 
 | ||||||
|  |     # TODO: better naming for this? | ||||||
|  |     # -[ ] check how libp2p does naming for such things? | ||||||
|  |     codec_key: str = 'msgpack' | ||||||
|  | 
 | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
|         stream: trio.SocketStream, |         stream: trio.SocketStream, | ||||||
|         prefix_size: int = 4, |         prefix_size: int = 4, | ||||||
| 
 | 
 | ||||||
|  |         # XXX optionally provided codec pair for `msgspec`: | ||||||
|  |         # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types | ||||||
|  |         # | ||||||
|  |         # TODO: define this as a `Codec` struct which can be | ||||||
|  |         # overriden dynamically by the application/runtime? | ||||||
|  |         codec: tuple[ | ||||||
|  |             Callable[[Any], Any]|None,  # coder | ||||||
|  |             Callable[[type, Any], Any]|None,  # decoder | ||||||
|  |         ]|None = None, | ||||||
|  | 
 | ||||||
|     ) -> None: |     ) -> None: | ||||||
| 
 | 
 | ||||||
|         self.stream = stream |         self.stream = stream | ||||||
|  | @ -126,30 +175,44 @@ class MsgpackTCPStream(MsgTransport): | ||||||
|         self._laddr, self._raddr = get_stream_addrs(stream) |         self._laddr, self._raddr = get_stream_addrs(stream) | ||||||
| 
 | 
 | ||||||
|         # create read loop instance |         # create read loop instance | ||||||
|         self._agen = self._iter_packets() |         self._aiter_pkts = self._iter_packets() | ||||||
|         self._send_lock = trio.StrictFIFOLock() |         self._send_lock = trio.StrictFIFOLock() | ||||||
| 
 | 
 | ||||||
|         # public i guess? |         # public i guess? | ||||||
|         self.drained: list[dict] = [] |         self.drained: list[dict] = [] | ||||||
| 
 | 
 | ||||||
|         self.recv_stream = BufferedReceiveStream(transport_stream=stream) |         self.recv_stream = BufferedReceiveStream( | ||||||
|  |             transport_stream=stream | ||||||
|  |         ) | ||||||
|         self.prefix_size = prefix_size |         self.prefix_size = prefix_size | ||||||
| 
 | 
 | ||||||
|         # TODO: struct aware messaging coders |         # allow for custom IPC msg interchange format | ||||||
|         self.encode = msgspec.msgpack.Encoder().encode |         # dynamic override Bo | ||||||
|         self.decode = msgspec.msgpack.Decoder().decode  # dict[str, Any]) |         self._task = trio.lowlevel.current_task() | ||||||
|  | 
 | ||||||
|  |         # XXX for ctxvar debug only! | ||||||
|  |         # self._codec: MsgCodec = ( | ||||||
|  |         #     codec | ||||||
|  |         #     or | ||||||
|  |         #     _codec._ctxvar_MsgCodec.get() | ||||||
|  |         # ) | ||||||
| 
 | 
 | ||||||
|     async def _iter_packets(self) -> AsyncGenerator[dict, None]: |     async def _iter_packets(self) -> AsyncGenerator[dict, None]: | ||||||
|         '''Yield packets from the underlying stream. |         ''' | ||||||
|  |         Yield `bytes`-blob decoded packets from the underlying TCP | ||||||
|  |         stream using the current task's `MsgCodec`. | ||||||
|  | 
 | ||||||
|  |         This is a streaming routine implemented as an async generator | ||||||
|  |         func (which was the original design, but could be changed?) | ||||||
|  |         and is allocated by a `.__call__()` inside `.__init__()` where | ||||||
|  |         it is assigned to the `._aiter_pkts` attr. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         import msgspec  # noqa |  | ||||||
|         decodes_failed: int = 0 |         decodes_failed: int = 0 | ||||||
| 
 | 
 | ||||||
|         while True: |         while True: | ||||||
|             try: |             try: | ||||||
|                 header = await self.recv_stream.receive_exactly(4) |                 header: bytes = await self.recv_stream.receive_exactly(4) | ||||||
| 
 |  | ||||||
|             except ( |             except ( | ||||||
|                 ValueError, |                 ValueError, | ||||||
|                 ConnectionResetError, |                 ConnectionResetError, | ||||||
|  | @ -158,25 +221,122 @@ class MsgpackTCPStream(MsgTransport): | ||||||
|                 # seem to be getting racy failures here on |                 # seem to be getting racy failures here on | ||||||
|                 # arbiter/registry name subs.. |                 # arbiter/registry name subs.. | ||||||
|                 trio.BrokenResourceError, |                 trio.BrokenResourceError, | ||||||
|             ): |  | ||||||
|                 raise TransportClosed( |  | ||||||
|                     f'transport {self} was already closed prior ro read' |  | ||||||
|                 ) |  | ||||||
| 
 | 
 | ||||||
|  |             ) as trans_err: | ||||||
|  | 
 | ||||||
|  |                 loglevel = 'transport' | ||||||
|  |                 match trans_err: | ||||||
|  |                     # case ( | ||||||
|  |                     #     ConnectionResetError() | ||||||
|  |                     # ): | ||||||
|  |                     #     loglevel = 'transport' | ||||||
|  | 
 | ||||||
|  |                     # peer actor (graceful??) TCP EOF but `tricycle` | ||||||
|  |                     # seems to raise a 0-bytes-read? | ||||||
|  |                     case ValueError() if ( | ||||||
|  |                         'unclean EOF' in trans_err.args[0] | ||||||
|  |                     ): | ||||||
|  |                         pass | ||||||
|  | 
 | ||||||
|  |                     # peer actor (task) prolly shutdown quickly due | ||||||
|  |                     # to cancellation | ||||||
|  |                     case trio.BrokenResourceError() if ( | ||||||
|  |                         'Connection reset by peer' in trans_err.args[0] | ||||||
|  |                     ): | ||||||
|  |                         pass | ||||||
|  | 
 | ||||||
|  |                     # unless the disconnect condition falls under "a | ||||||
|  |                     # normal operation breakage" we usualy console warn | ||||||
|  |                     # about it. | ||||||
|  |                     case _: | ||||||
|  |                         loglevel: str = 'warning' | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |                 raise TransportClosed( | ||||||
|  |                     message=( | ||||||
|  |                         f'IPC transport already closed by peer\n' | ||||||
|  |                         f'x)> {type(trans_err)}\n' | ||||||
|  |                         f' |_{self}\n' | ||||||
|  |                     ), | ||||||
|  |                     loglevel=loglevel, | ||||||
|  |                 ) from trans_err | ||||||
|  | 
 | ||||||
|  |             # XXX definitely can happen if transport is closed | ||||||
|  |             # manually by another `trio.lowlevel.Task` in the | ||||||
|  |             # same actor; we use this in some simulated fault | ||||||
|  |             # testing for ex, but generally should never happen | ||||||
|  |             # under normal operation! | ||||||
|  |             # | ||||||
|  |             # NOTE: as such we always re-raise this error from the | ||||||
|  |             #       RPC msg loop! | ||||||
|  |             except trio.ClosedResourceError as closure_err: | ||||||
|  |                 raise TransportClosed( | ||||||
|  |                     message=( | ||||||
|  |                         f'IPC transport already manually closed locally?\n' | ||||||
|  |                         f'x)> {type(closure_err)} \n' | ||||||
|  |                         f' |_{self}\n' | ||||||
|  |                     ), | ||||||
|  |                     loglevel='error', | ||||||
|  |                     raise_on_report=( | ||||||
|  |                         closure_err.args[0] == 'another task closed this fd' | ||||||
|  |                         or | ||||||
|  |                         closure_err.args[0] in ['another task closed this fd'] | ||||||
|  |                     ), | ||||||
|  |                 ) from closure_err | ||||||
|  | 
 | ||||||
|  |             # graceful TCP EOF disconnect | ||||||
|             if header == b'': |             if header == b'': | ||||||
|                 raise TransportClosed( |                 raise TransportClosed( | ||||||
|                     f'transport {self} was already closed prior ro read' |                     message=( | ||||||
|  |                         f'IPC transport already gracefully closed\n' | ||||||
|  |                         f')>\n' | ||||||
|  |                         f'|_{self}\n' | ||||||
|  |                     ), | ||||||
|  |                     loglevel='transport', | ||||||
|  |                     # cause=???  # handy or no? | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|  |             size: int | ||||||
|             size, = struct.unpack("<I", header) |             size, = struct.unpack("<I", header) | ||||||
| 
 | 
 | ||||||
|             log.transport(f'received header {size}')  # type: ignore |             log.transport(f'received header {size}')  # type: ignore | ||||||
| 
 |             msg_bytes: bytes = await self.recv_stream.receive_exactly(size) | ||||||
|             msg_bytes = await self.recv_stream.receive_exactly(size) |  | ||||||
| 
 | 
 | ||||||
|             log.transport(f"received {msg_bytes}")  # type: ignore |             log.transport(f"received {msg_bytes}")  # type: ignore | ||||||
|             try: |             try: | ||||||
|                 yield self.decode(msg_bytes) |                 # NOTE: lookup the `trio.Task.context`'s var for | ||||||
|  |                 # the current `MsgCodec`. | ||||||
|  |                 codec: MsgCodec = _ctxvar_MsgCodec.get() | ||||||
|  | 
 | ||||||
|  |                 # XXX for ctxvar debug only! | ||||||
|  |                 # if self._codec.pld_spec != codec.pld_spec: | ||||||
|  |                 #     assert ( | ||||||
|  |                 #         task := trio.lowlevel.current_task() | ||||||
|  |                 #     ) is not self._task | ||||||
|  |                 #     self._task = task | ||||||
|  |                 #     self._codec = codec | ||||||
|  |                 #     log.runtime( | ||||||
|  |                 #         f'Using new codec in {self}.recv()\n' | ||||||
|  |                 #         f'codec: {self._codec}\n\n' | ||||||
|  |                 #         f'msg_bytes: {msg_bytes}\n' | ||||||
|  |                 #     ) | ||||||
|  |                 yield codec.decode(msg_bytes) | ||||||
|  | 
 | ||||||
|  |             # XXX NOTE: since the below error derives from | ||||||
|  |             # `DecodeError` we need to catch is specially | ||||||
|  |             # and always raise such that spec violations | ||||||
|  |             # are never allowed to be caught silently! | ||||||
|  |             except msgspec.ValidationError as verr: | ||||||
|  |                 msgtyperr: MsgTypeError = _mk_recv_mte( | ||||||
|  |                     msg=msg_bytes, | ||||||
|  |                     codec=codec, | ||||||
|  |                     src_validation_error=verr, | ||||||
|  |                 ) | ||||||
|  |                 # XXX deliver up to `Channel.recv()` where | ||||||
|  |                 # a re-raise and `Error`-pack can inject the far | ||||||
|  |                 # end actor `.uid`. | ||||||
|  |                 yield msgtyperr | ||||||
|  | 
 | ||||||
|             except ( |             except ( | ||||||
|                 msgspec.DecodeError, |                 msgspec.DecodeError, | ||||||
|                 UnicodeDecodeError, |                 UnicodeDecodeError, | ||||||
|  | @ -186,30 +346,96 @@ class MsgpackTCPStream(MsgTransport): | ||||||
|                     # do with a channel drop - hope that receiving from the |                     # do with a channel drop - hope that receiving from the | ||||||
|                     # channel will raise an expected error and bubble up. |                     # channel will raise an expected error and bubble up. | ||||||
|                     try: |                     try: | ||||||
|                         msg_str: str | bytes = msg_bytes.decode() |                         msg_str: str|bytes = msg_bytes.decode() | ||||||
|                     except UnicodeDecodeError: |                     except UnicodeDecodeError: | ||||||
|                         msg_str = msg_bytes |                         msg_str = msg_bytes | ||||||
| 
 | 
 | ||||||
|                     log.error( |                     log.exception( | ||||||
|                         '`msgspec` failed to decode!?\n' |                         'Failed to decode msg?\n' | ||||||
|                         'dumping bytes:\n' |                         f'{codec}\n\n' | ||||||
|                         f'{msg_str!r}' |                         'Rxed bytes from wire:\n\n' | ||||||
|  |                         f'{msg_str!r}\n' | ||||||
|                     ) |                     ) | ||||||
|                     decodes_failed += 1 |                     decodes_failed += 1 | ||||||
|                 else: |                 else: | ||||||
|                     raise |                     raise | ||||||
| 
 | 
 | ||||||
|     async def send(self, msg: Any) -> None: |     async def send( | ||||||
|  |         self, | ||||||
|  |         msg: msgtypes.MsgType, | ||||||
|  | 
 | ||||||
|  |         strict_types: bool = True, | ||||||
|  |         hide_tb: bool = False, | ||||||
|  | 
 | ||||||
|  |     ) -> None: | ||||||
|  |         ''' | ||||||
|  |         Send a msgpack encoded py-object-blob-as-msg over TCP. | ||||||
|  | 
 | ||||||
|  |         If `strict_types == True` then a `MsgTypeError` will be raised on any | ||||||
|  |         invalid msg type | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|  | 
 | ||||||
|  |         # XXX see `trio._sync.AsyncContextManagerMixin` for details | ||||||
|  |         # on the `.acquire()`/`.release()` sequencing.. | ||||||
|         async with self._send_lock: |         async with self._send_lock: | ||||||
| 
 | 
 | ||||||
|             bytes_data: bytes = self.encode(msg) |             # NOTE: lookup the `trio.Task.context`'s var for | ||||||
|  |             # the current `MsgCodec`. | ||||||
|  |             codec: MsgCodec = _ctxvar_MsgCodec.get() | ||||||
|  | 
 | ||||||
|  |             # XXX for ctxvar debug only! | ||||||
|  |             # if self._codec.pld_spec != codec.pld_spec: | ||||||
|  |             #     self._codec = codec | ||||||
|  |             #     log.runtime( | ||||||
|  |             #         f'Using new codec in {self}.send()\n' | ||||||
|  |             #         f'codec: {self._codec}\n\n' | ||||||
|  |             #         f'msg: {msg}\n' | ||||||
|  |             #     ) | ||||||
|  | 
 | ||||||
|  |             if type(msg) not in msgtypes.__msg_types__: | ||||||
|  |                 if strict_types: | ||||||
|  |                     raise _mk_send_mte( | ||||||
|  |                         msg, | ||||||
|  |                         codec=codec, | ||||||
|  |                     ) | ||||||
|  |                 else: | ||||||
|  |                     log.warning( | ||||||
|  |                         'Sending non-`Msg`-spec msg?\n\n' | ||||||
|  |                         f'{msg}\n' | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |             try: | ||||||
|  |                 bytes_data: bytes = codec.encode(msg) | ||||||
|  |             except TypeError as _err: | ||||||
|  |                 typerr = _err | ||||||
|  |                 msgtyperr: MsgTypeError = _mk_send_mte( | ||||||
|  |                     msg, | ||||||
|  |                     codec=codec, | ||||||
|  |                     message=( | ||||||
|  |                         f'IPC-msg-spec violation in\n\n' | ||||||
|  |                         f'{pretty_struct.Struct.pformat(msg)}' | ||||||
|  |                     ), | ||||||
|  |                     src_type_error=typerr, | ||||||
|  |                 ) | ||||||
|  |                 raise msgtyperr from typerr | ||||||
| 
 | 
 | ||||||
|             # supposedly the fastest says, |             # supposedly the fastest says, | ||||||
|             # https://stackoverflow.com/a/54027962 |             # https://stackoverflow.com/a/54027962 | ||||||
|             size: bytes = struct.pack("<I", len(bytes_data)) |             size: bytes = struct.pack("<I", len(bytes_data)) | ||||||
| 
 |  | ||||||
|             return await self.stream.send_all(size + bytes_data) |             return await self.stream.send_all(size + bytes_data) | ||||||
| 
 | 
 | ||||||
|  |         # ?TODO? does it help ever to dynamically show this | ||||||
|  |         # frame? | ||||||
|  |         # try: | ||||||
|  |         #     <the-above_code> | ||||||
|  |         # except BaseException as _err: | ||||||
|  |         #     err = _err | ||||||
|  |         #     if not isinstance(err, MsgTypeError): | ||||||
|  |         #         __tracebackhide__: bool = False | ||||||
|  |         #     raise | ||||||
|  | 
 | ||||||
|     @property |     @property | ||||||
|     def laddr(self) -> tuple[str, int]: |     def laddr(self) -> tuple[str, int]: | ||||||
|         return self._laddr |         return self._laddr | ||||||
|  | @ -219,7 +445,7 @@ class MsgpackTCPStream(MsgTransport): | ||||||
|         return self._raddr |         return self._raddr | ||||||
| 
 | 
 | ||||||
|     async def recv(self) -> Any: |     async def recv(self) -> Any: | ||||||
|         return await self._agen.asend(None) |         return await self._aiter_pkts.asend(None) | ||||||
| 
 | 
 | ||||||
|     async def drain(self) -> AsyncIterator[dict]: |     async def drain(self) -> AsyncIterator[dict]: | ||||||
|         ''' |         ''' | ||||||
|  | @ -236,7 +462,7 @@ class MsgpackTCPStream(MsgTransport): | ||||||
|                 yield msg |                 yield msg | ||||||
| 
 | 
 | ||||||
|     def __aiter__(self): |     def __aiter__(self): | ||||||
|         return self._agen |         return self._aiter_pkts | ||||||
| 
 | 
 | ||||||
|     def connected(self) -> bool: |     def connected(self) -> bool: | ||||||
|         return self.stream.socket.fileno() != -1 |         return self.stream.socket.fileno() != -1 | ||||||
|  | @ -267,7 +493,7 @@ class Channel: | ||||||
|     def __init__( |     def __init__( | ||||||
| 
 | 
 | ||||||
|         self, |         self, | ||||||
|         destaddr: Optional[tuple[str, int]], |         destaddr: tuple[str, int]|None, | ||||||
| 
 | 
 | ||||||
|         msg_transport_type_key: tuple[str, str] = ('msgpack', 'tcp'), |         msg_transport_type_key: tuple[str, str] = ('msgpack', 'tcp'), | ||||||
| 
 | 
 | ||||||
|  | @ -285,18 +511,31 @@ class Channel: | ||||||
| 
 | 
 | ||||||
|         # Either created in ``.connect()`` or passed in by |         # Either created in ``.connect()`` or passed in by | ||||||
|         # user in ``.from_stream()``. |         # user in ``.from_stream()``. | ||||||
|         self._stream: Optional[trio.SocketStream] = None |         self._stream: trio.SocketStream|None = None | ||||||
|         self.msgstream: Optional[MsgTransport] = None |         self._transport: MsgTransport|None = None | ||||||
| 
 | 
 | ||||||
|         # set after handshake - always uid of far end |         # set after handshake - always uid of far end | ||||||
|         self.uid: Optional[tuple[str, str]] = None |         self.uid: tuple[str, str]|None = None | ||||||
| 
 | 
 | ||||||
|         self._agen = self._aiter_recv() |         self._aiter_msgs = self._iter_msgs() | ||||||
|         self._exc: Optional[Exception] = None  # set if far end actor errors |         self._exc: Exception|None = None  # set if far end actor errors | ||||||
|         self._closed: bool = False |         self._closed: bool = False | ||||||
|         # flag set on ``Portal.cancel_actor()`` indicating | 
 | ||||||
|         # remote (peer) cancellation of the far end actor runtime. |         # flag set by ``Portal.cancel_actor()`` indicating remote | ||||||
|         self._cancel_called: bool = False  # set on ``Portal.cancel_actor()`` |         # (possibly peer) cancellation of the far end actor | ||||||
|  |         # runtime. | ||||||
|  |         self._cancel_called: bool = False | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def msgstream(self) -> MsgTransport: | ||||||
|  |         log.info( | ||||||
|  |             '`Channel.msgstream` is an old name, use `._transport`' | ||||||
|  |         ) | ||||||
|  |         return self._transport | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def transport(self) -> MsgTransport: | ||||||
|  |         return self._transport | ||||||
| 
 | 
 | ||||||
|     @classmethod |     @classmethod | ||||||
|     def from_stream( |     def from_stream( | ||||||
|  | @ -307,37 +546,78 @@ class Channel: | ||||||
|     ) -> Channel: |     ) -> Channel: | ||||||
| 
 | 
 | ||||||
|         src, dst = get_stream_addrs(stream) |         src, dst = get_stream_addrs(stream) | ||||||
|         chan = Channel(destaddr=dst, **kwargs) |         chan = Channel( | ||||||
|  |             destaddr=dst, | ||||||
|  |             **kwargs, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         # set immediately here from provided instance |         # set immediately here from provided instance | ||||||
|         chan._stream = stream |         chan._stream: trio.SocketStream = stream | ||||||
|         chan.set_msg_transport(stream) |         chan.set_msg_transport(stream) | ||||||
|         return chan |         return chan | ||||||
| 
 | 
 | ||||||
|     def set_msg_transport( |     def set_msg_transport( | ||||||
|         self, |         self, | ||||||
|         stream: trio.SocketStream, |         stream: trio.SocketStream, | ||||||
|         type_key: Optional[tuple[str, str]] = None, |         type_key: tuple[str, str]|None = None, | ||||||
|  | 
 | ||||||
|  |         # XXX optionally provided codec pair for `msgspec`: | ||||||
|  |         # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types | ||||||
|  |         codec: MsgCodec|None = None, | ||||||
| 
 | 
 | ||||||
|     ) -> MsgTransport: |     ) -> MsgTransport: | ||||||
|         type_key = type_key or self._transport_key |         type_key = ( | ||||||
|         self.msgstream = get_msg_transport(type_key)(stream) |             type_key | ||||||
|         return self.msgstream |             or | ||||||
|  |             self._transport_key | ||||||
|  |         ) | ||||||
|  |         # get transport type, then | ||||||
|  |         self._transport = get_msg_transport( | ||||||
|  |             type_key | ||||||
|  |         # instantiate an instance of the msg-transport | ||||||
|  |         )( | ||||||
|  |             stream, | ||||||
|  |             codec=codec, | ||||||
|  |         ) | ||||||
|  |         return self._transport | ||||||
| 
 | 
 | ||||||
|  |     @cm | ||||||
|  |     def apply_codec( | ||||||
|  |         self, | ||||||
|  |         codec: MsgCodec, | ||||||
|  | 
 | ||||||
|  |     ) -> None: | ||||||
|  |         ''' | ||||||
|  |         Temporarily override the underlying IPC msg codec for | ||||||
|  |         dynamic enforcement of messaging schema. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         orig: MsgCodec = self._transport.codec | ||||||
|  |         try: | ||||||
|  |             self._transport.codec = codec | ||||||
|  |             yield | ||||||
|  |         finally: | ||||||
|  |             self._transport.codec = orig | ||||||
|  | 
 | ||||||
|  |     # TODO: do a .src/.dst: str for maddrs? | ||||||
|     def __repr__(self) -> str: |     def __repr__(self) -> str: | ||||||
|         if self.msgstream: |         if not self._transport: | ||||||
|  |             return '<Channel with inactive transport?>' | ||||||
|  | 
 | ||||||
|         return repr( |         return repr( | ||||||
|                 self.msgstream.stream.socket._sock).replace(  # type: ignore |             self._transport.stream.socket._sock | ||||||
|                         "socket.socket", "Channel") |         ).replace(  # type: ignore | ||||||
|         return object.__repr__(self) |             "socket.socket", | ||||||
|  |             "Channel", | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def laddr(self) -> Optional[tuple[str, int]]: |     def laddr(self) -> tuple[str, int]|None: | ||||||
|         return self.msgstream.laddr if self.msgstream else None |         return self._transport.laddr if self._transport else None | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def raddr(self) -> Optional[tuple[str, int]]: |     def raddr(self) -> tuple[str, int]|None: | ||||||
|         return self.msgstream.raddr if self.msgstream else None |         return self._transport.raddr if self._transport else None | ||||||
| 
 | 
 | ||||||
|     async def connect( |     async def connect( | ||||||
|         self, |         self, | ||||||
|  | @ -356,26 +636,62 @@ class Channel: | ||||||
|             *destaddr, |             *destaddr, | ||||||
|             **kwargs |             **kwargs | ||||||
|         ) |         ) | ||||||
|         msgstream = self.set_msg_transport(stream) |         transport = self.set_msg_transport(stream) | ||||||
| 
 | 
 | ||||||
|         log.transport( |         log.transport( | ||||||
|             f'Opened channel[{type(msgstream)}]: {self.laddr} -> {self.raddr}' |             f'Opened channel[{type(transport)}]: {self.laddr} -> {self.raddr}' | ||||||
|         ) |         ) | ||||||
|         return msgstream |         return transport | ||||||
| 
 | 
 | ||||||
|     async def send(self, item: Any) -> None: |     # TODO: something like, | ||||||
|  |     # `pdbp.hideframe_on(errors=[MsgTypeError])` | ||||||
|  |     # instead of the `try/except` hack we have rn.. | ||||||
|  |     # seems like a pretty useful thing to have in general | ||||||
|  |     # along with being able to filter certain stack frame(s / sets) | ||||||
|  |     # possibly based on the current log-level? | ||||||
|  |     async def send( | ||||||
|  |         self, | ||||||
|  |         payload: Any, | ||||||
| 
 | 
 | ||||||
|         log.transport(f"send `{item}`")  # type: ignore |         hide_tb: bool = False, | ||||||
|         assert self.msgstream |  | ||||||
| 
 | 
 | ||||||
|         await self.msgstream.send(item) |     ) -> None: | ||||||
|  |         ''' | ||||||
|  |         Send a coded msg-blob over the transport. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|  |         try: | ||||||
|  |             log.transport( | ||||||
|  |                 '=> send IPC msg:\n\n' | ||||||
|  |                 f'{pformat(payload)}\n' | ||||||
|  |             ) | ||||||
|  |             # assert self._transport  # but why typing? | ||||||
|  |             await self._transport.send( | ||||||
|  |                 payload, | ||||||
|  |                 hide_tb=hide_tb, | ||||||
|  |             ) | ||||||
|  |         except BaseException as _err: | ||||||
|  |             err = _err  # bind for introspection | ||||||
|  |             if not isinstance(_err, MsgTypeError): | ||||||
|  |                 # assert err | ||||||
|  |                 __tracebackhide__: bool = False | ||||||
|  |             else: | ||||||
|  |                 assert err.cid | ||||||
|  | 
 | ||||||
|  |             raise | ||||||
| 
 | 
 | ||||||
|     async def recv(self) -> Any: |     async def recv(self) -> Any: | ||||||
|         assert self.msgstream |         assert self._transport | ||||||
|         return await self.msgstream.recv() |         return await self._transport.recv() | ||||||
| 
 | 
 | ||||||
|  |         # TODO: auto-reconnect features like 0mq/nanomsg? | ||||||
|  |         # -[ ] implement it manually with nods to SC prot | ||||||
|  |         #      possibly on multiple transport backends? | ||||||
|  |         #  -> seems like that might be re-inventing scalability | ||||||
|  |         #     prots tho no? | ||||||
|         # try: |         # try: | ||||||
|         #     return await self.msgstream.recv() |         #     return await self._transport.recv() | ||||||
|         # except trio.BrokenResourceError: |         # except trio.BrokenResourceError: | ||||||
|         #     if self._autorecon: |         #     if self._autorecon: | ||||||
|         #         await self._reconnect() |         #         await self._reconnect() | ||||||
|  | @ -388,8 +704,8 @@ class Channel: | ||||||
|             f'Closing channel to {self.uid} ' |             f'Closing channel to {self.uid} ' | ||||||
|             f'{self.laddr} -> {self.raddr}' |             f'{self.laddr} -> {self.raddr}' | ||||||
|         ) |         ) | ||||||
|         assert self.msgstream |         assert self._transport | ||||||
|         await self.msgstream.stream.aclose() |         await self._transport.stream.aclose() | ||||||
|         self._closed = True |         self._closed = True | ||||||
| 
 | 
 | ||||||
|     async def __aenter__(self): |     async def __aenter__(self): | ||||||
|  | @ -400,8 +716,11 @@ class Channel: | ||||||
|         await self.aclose(*args) |         await self.aclose(*args) | ||||||
| 
 | 
 | ||||||
|     def __aiter__(self): |     def __aiter__(self): | ||||||
|         return self._agen |         return self._aiter_msgs | ||||||
| 
 | 
 | ||||||
|  |     # ?TODO? run any reconnection sequence? | ||||||
|  |     # -[ ] prolly should be impl-ed as deco-API? | ||||||
|  |     # | ||||||
|     # async def _reconnect(self) -> None: |     # async def _reconnect(self) -> None: | ||||||
|     #     """Handle connection failures by polling until a reconnect can be |     #     """Handle connection failures by polling until a reconnect can be | ||||||
|     #     established. |     #     established. | ||||||
|  | @ -419,7 +738,6 @@ class Channel: | ||||||
|     #             else: |     #             else: | ||||||
|     #                 log.transport("Stream connection re-established!") |     #                 log.transport("Stream connection re-established!") | ||||||
| 
 | 
 | ||||||
|     #                 # TODO: run any reconnection sequence |  | ||||||
|     #                 # on_recon = self._recon_seq |     #                 # on_recon = self._recon_seq | ||||||
|     #                 # if on_recon: |     #                 # if on_recon: | ||||||
|     #                 #     await on_recon(self) |     #                 #     await on_recon(self) | ||||||
|  | @ -433,23 +751,42 @@ class Channel: | ||||||
|     #                     " for re-establishment") |     #                     " for re-establishment") | ||||||
|     #             await trio.sleep(1) |     #             await trio.sleep(1) | ||||||
| 
 | 
 | ||||||
|     async def _aiter_recv( |     async def _iter_msgs( | ||||||
|         self |         self | ||||||
|     ) -> AsyncGenerator[Any, None]: |     ) -> AsyncGenerator[Any, None]: | ||||||
|         ''' |         ''' | ||||||
|         Async iterate items from underlying stream. |         Yield `MsgType` IPC msgs decoded and deliverd from | ||||||
|  |         an underlying `MsgTransport` protocol. | ||||||
|  | 
 | ||||||
|  |         This is a streaming routine alo implemented as an async-gen | ||||||
|  |         func (same a `MsgTransport._iter_pkts()`) gets allocated by | ||||||
|  |         a `.__call__()` inside `.__init__()` where it is assigned to | ||||||
|  |         the `._aiter_msgs` attr. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         assert self.msgstream |         assert self._transport | ||||||
|         while True: |         while True: | ||||||
|             try: |             try: | ||||||
|                 async for item in self.msgstream: |                 async for msg in self._transport: | ||||||
|                     yield item |                     match msg: | ||||||
|                     # sent = yield item |                         # NOTE: if transport/interchange delivers | ||||||
|                     # if sent is not None: |                         # a type error, we pack it with the far | ||||||
|                     #     # optimization, passing None through all the |                         # end peer `Actor.uid` and relay the | ||||||
|                     #     # time is pointless |                         # `Error`-msg upward to the `._rpc` stack | ||||||
|                     #     await self.msgstream.send(sent) |                         # for normal RAE handling. | ||||||
|  |                         case MsgTypeError(): | ||||||
|  |                             yield pack_from_raise( | ||||||
|  |                                 local_err=msg, | ||||||
|  |                                 cid=msg.cid, | ||||||
|  | 
 | ||||||
|  |                                 # XXX we pack it here bc lower | ||||||
|  |                                 # layers have no notion of an | ||||||
|  |                                 # actor-id ;) | ||||||
|  |                                 src_uid=self.uid, | ||||||
|  |                             ) | ||||||
|  |                         case _: | ||||||
|  |                             yield msg | ||||||
|  | 
 | ||||||
|             except trio.BrokenResourceError: |             except trio.BrokenResourceError: | ||||||
| 
 | 
 | ||||||
|                 # if not self._autorecon: |                 # if not self._autorecon: | ||||||
|  | @ -462,12 +799,14 @@ class Channel: | ||||||
|             #     continue |             #     continue | ||||||
| 
 | 
 | ||||||
|     def connected(self) -> bool: |     def connected(self) -> bool: | ||||||
|         return self.msgstream.connected() if self.msgstream else False |         return self._transport.connected() if self._transport else False | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @asynccontextmanager | @acm | ||||||
| async def _connect_chan( | async def _connect_chan( | ||||||
|     host: str, port: int |     host: str, | ||||||
|  |     port: int | ||||||
|  | 
 | ||||||
| ) -> typing.AsyncGenerator[Channel, None]: | ) -> typing.AsyncGenerator[Channel, None]: | ||||||
|     ''' |     ''' | ||||||
|     Create and connect a channel with disconnect on context manager |     Create and connect a channel with disconnect on context manager | ||||||
|  | @ -477,4 +816,5 @@ async def _connect_chan( | ||||||
|     chan = Channel((host, port)) |     chan = Channel((host, port)) | ||||||
|     await chan.connect() |     await chan.connect() | ||||||
|     yield chan |     yield chan | ||||||
|  |     with trio.CancelScope(shield=True): | ||||||
|         await chan.aclose() |         await chan.aclose() | ||||||
|  |  | ||||||
|  | @ -0,0 +1,151 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Multiaddress parser and utils according the spec(s) defined by | ||||||
|  | `libp2p` and used in dependent project such as `ipfs`: | ||||||
|  | 
 | ||||||
|  | - https://docs.libp2p.io/concepts/fundamentals/addressing/ | ||||||
|  | - https://github.com/libp2p/specs/blob/master/addressing/README.md | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from typing import Iterator | ||||||
|  | 
 | ||||||
|  | from bidict import bidict | ||||||
|  | 
 | ||||||
|  | # TODO: see if we can leverage libp2p ecosys projects instead of | ||||||
|  | # rolling our own (parser) impls of the above addressing specs: | ||||||
|  | # - https://github.com/libp2p/py-libp2p | ||||||
|  | # - https://docs.libp2p.io/concepts/nat/circuit-relay/#relay-addresses | ||||||
|  | # prots: bidict[int, str] = bidict({ | ||||||
|  | prots: bidict[int, str] = { | ||||||
|  |     'ipv4': 3, | ||||||
|  |     'ipv6': 3, | ||||||
|  |     'wg': 3, | ||||||
|  | 
 | ||||||
|  |     'tcp': 4, | ||||||
|  |     'udp': 4, | ||||||
|  | 
 | ||||||
|  |     # TODO: support the next-gen shite Bo | ||||||
|  |     # 'quic': 4, | ||||||
|  |     # 'ssh': 7,  # via rsyscall bootstrapping | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | prot_params: dict[str, tuple[str]] = { | ||||||
|  |     'ipv4': ('addr',), | ||||||
|  |     'ipv6': ('addr',), | ||||||
|  |     'wg': ('addr', 'port', 'pubkey'), | ||||||
|  | 
 | ||||||
|  |     'tcp': ('port',), | ||||||
|  |     'udp': ('port',), | ||||||
|  | 
 | ||||||
|  |     # 'quic': ('port',), | ||||||
|  |     # 'ssh': ('port',), | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def iter_prot_layers( | ||||||
|  |     multiaddr: str, | ||||||
|  | ) -> Iterator[ | ||||||
|  |     tuple[ | ||||||
|  |         int, | ||||||
|  |         list[str] | ||||||
|  |     ] | ||||||
|  | ]: | ||||||
|  |     ''' | ||||||
|  |     Unpack a libp2p style "multiaddress" into multiple "segments" | ||||||
|  |     for each "layer" of the protocoll stack (in OSI terms). | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     tokens: list[str] = multiaddr.split('/') | ||||||
|  |     root, tokens = tokens[0], tokens[1:] | ||||||
|  |     assert not root  # there is a root '/' on LHS | ||||||
|  |     itokens = iter(tokens) | ||||||
|  | 
 | ||||||
|  |     prot: str | None = None | ||||||
|  |     params: list[str] = [] | ||||||
|  |     for token in itokens: | ||||||
|  |         # every prot path should start with a known | ||||||
|  |         # key-str. | ||||||
|  |         if token in prots: | ||||||
|  |             if prot is None: | ||||||
|  |                 prot: str = token | ||||||
|  |             else: | ||||||
|  |                 yield prot, params | ||||||
|  |                 prot = token | ||||||
|  | 
 | ||||||
|  |             params = [] | ||||||
|  | 
 | ||||||
|  |         elif token not in prots: | ||||||
|  |             params.append(token) | ||||||
|  | 
 | ||||||
|  |     else: | ||||||
|  |         yield prot, params | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def parse_maddr( | ||||||
|  |     multiaddr: str, | ||||||
|  | ) -> dict[str, str | int | dict]: | ||||||
|  |     ''' | ||||||
|  |     Parse a libp2p style "multiaddress" into its distinct protocol | ||||||
|  |     segments where each segment is of the form: | ||||||
|  | 
 | ||||||
|  |         `../<protocol>/<param0>/<param1>/../<paramN>` | ||||||
|  | 
 | ||||||
|  |     and is loaded into a (order preserving) `layers: dict[str, | ||||||
|  |     dict[str, Any]` which holds each protocol-layer-segment of the | ||||||
|  |     original `str` path as a separate entry according to its approx | ||||||
|  |     OSI "layer number". | ||||||
|  | 
 | ||||||
|  |     Any `paramN` in the path must be distinctly defined by a str-token in the | ||||||
|  |     (module global) `prot_params` table. | ||||||
|  | 
 | ||||||
|  |     For eg. for wireguard which requires an address, port number and publickey | ||||||
|  |     the protocol params are specified as the entry: | ||||||
|  | 
 | ||||||
|  |         'wg': ('addr', 'port', 'pubkey'), | ||||||
|  | 
 | ||||||
|  |     and are thus parsed from a maddr in that order: | ||||||
|  |         `'/wg/1.1.1.1/51820/<pubkey>'` | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     layers: dict[str, str | int | dict] = {} | ||||||
|  |     for ( | ||||||
|  |         prot_key, | ||||||
|  |         params, | ||||||
|  |     ) in iter_prot_layers(multiaddr): | ||||||
|  | 
 | ||||||
|  |         layer: int = prots[prot_key]  # OSI layer used for sorting | ||||||
|  |         ep: dict[str, int | str] = {'layer': layer} | ||||||
|  |         layers[prot_key] = ep | ||||||
|  | 
 | ||||||
|  |         # TODO; validation and resolving of names: | ||||||
|  |         # - each param via a validator provided as part of the | ||||||
|  |         #   prot_params def? (also see `"port"` case below..) | ||||||
|  |         # - do a resolv step that will check addrs against | ||||||
|  |         #   any loaded network.resolv: dict[str, str] | ||||||
|  |         rparams: list = list(reversed(params)) | ||||||
|  |         for key in prot_params[prot_key]: | ||||||
|  |             val: str | int = rparams.pop() | ||||||
|  | 
 | ||||||
|  |             # TODO: UGHH, dunno what we should do for validation | ||||||
|  |             # here, put it in the params spec somehow? | ||||||
|  |             if key == 'port': | ||||||
|  |                 val = int(val) | ||||||
|  | 
 | ||||||
|  |             ep[key] = val | ||||||
|  | 
 | ||||||
|  |     return layers | ||||||
|  | @ -15,71 +15,70 @@ | ||||||
| # along with this program.  If not, see <https://www.gnu.org/licenses/>. | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| Memory boundary "Portals": an API for structured | Memory "portal" contruct. | ||||||
| concurrency linked tasks running in disparate memory domains. | 
 | ||||||
|  | "Memory portals" are both an API and set of IPC wrapping primitives | ||||||
|  | for managing structured concurrency "cancel-scope linked" tasks | ||||||
|  | running in disparate virtual memory domains - at least in different | ||||||
|  | OS processes, possibly on different (hardware) hosts. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  | from contextlib import asynccontextmanager as acm | ||||||
| import importlib | import importlib | ||||||
| import inspect | import inspect | ||||||
| from typing import ( | from typing import ( | ||||||
|     Any, Optional, |     Any, | ||||||
|     Callable, AsyncGenerator, |     Callable, | ||||||
|     Type, |     AsyncGenerator, | ||||||
|  |     TYPE_CHECKING, | ||||||
| ) | ) | ||||||
| from functools import partial | from functools import partial | ||||||
| from dataclasses import dataclass | from dataclasses import dataclass | ||||||
| from pprint import pformat |  | ||||||
| import warnings | import warnings | ||||||
| 
 | 
 | ||||||
| import trio | import trio | ||||||
| from async_generator import asynccontextmanager |  | ||||||
| 
 | 
 | ||||||
| from .trionics import maybe_open_nursery | from .trionics import maybe_open_nursery | ||||||
| from ._state import current_actor | from ._state import ( | ||||||
|  |     current_actor, | ||||||
|  | ) | ||||||
| from ._ipc import Channel | from ._ipc import Channel | ||||||
| from .log import get_logger | from .log import get_logger | ||||||
| from .msg import NamespacePath | from .msg import ( | ||||||
| from ._exceptions import ( |     # Error, | ||||||
|     unpack_error, |     PayloadMsg, | ||||||
|     NoResult, |     NamespacePath, | ||||||
|     ContextCancelled, |     Return, | ||||||
|  | ) | ||||||
|  | from ._exceptions import ( | ||||||
|  |     # unpack_error, | ||||||
|  |     NoResult, | ||||||
|  | ) | ||||||
|  | from ._context import ( | ||||||
|  |     Context, | ||||||
|  |     open_context_from_portal, | ||||||
|  | ) | ||||||
|  | from ._streaming import ( | ||||||
|  |     MsgStream, | ||||||
| ) | ) | ||||||
| from ._context import Context |  | ||||||
| from ._streaming import MsgStream |  | ||||||
| 
 | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from ._runtime import Actor | ||||||
| 
 | 
 | ||||||
| log = get_logger(__name__) | log = get_logger(__name__) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _unwrap_msg( |  | ||||||
|     msg: dict[str, Any], |  | ||||||
|     channel: Channel |  | ||||||
| 
 |  | ||||||
| ) -> Any: |  | ||||||
|     __tracebackhide__ = True |  | ||||||
|     try: |  | ||||||
|         return msg['return'] |  | ||||||
|     except KeyError: |  | ||||||
|         # internal error should never get here |  | ||||||
|         assert msg.get('cid'), "Received internal error at portal?" |  | ||||||
|         raise unpack_error(msg, channel) from None |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class MessagingError(Exception): |  | ||||||
|     'Some kind of unexpected SC messaging dialog issue' |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class Portal: | class Portal: | ||||||
|     ''' |     ''' | ||||||
|     A 'portal' to a(n) (remote) ``Actor``. |     A 'portal' to a memory-domain-separated `Actor`. | ||||||
| 
 | 
 | ||||||
|     A portal is "opened" (and eventually closed) by one side of an |     A portal is "opened" (and eventually closed) by one side of an | ||||||
|     inter-actor communication context. The side which opens the portal |     inter-actor communication context. The side which opens the portal | ||||||
|     is equivalent to a "caller" in function parlance and usually is |     is equivalent to a "caller" in function parlance and usually is | ||||||
|     either the called actor's parent (in process tree hierarchy terms) |     either the called actor's parent (in process tree hierarchy terms) | ||||||
|     or a client interested in scheduling work to be done remotely in a |     or a client interested in scheduling work to be done remotely in a | ||||||
|     far process. |     process which has a separate (virtual) memory domain. | ||||||
| 
 | 
 | ||||||
|     The portal api allows the "caller" actor to invoke remote routines |     The portal api allows the "caller" actor to invoke remote routines | ||||||
|     and receive results through an underlying ``tractor.Channel`` as |     and receive results through an underlying ``tractor.Channel`` as | ||||||
|  | @ -89,22 +88,45 @@ class Portal: | ||||||
|     like having a "portal" between the seperate actor memory spaces. |     like having a "portal" between the seperate actor memory spaces. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     # the timeout for a remote cancel request sent to |     # global timeout for remote cancel requests sent to | ||||||
|     # a(n) (peer) actor. |     # connected (peer) actors. | ||||||
|     cancel_timeout = 0.5 |     cancel_timeout: float = 0.5 | ||||||
| 
 | 
 | ||||||
|     def __init__(self, channel: Channel) -> None: |     def __init__( | ||||||
|         self.channel = channel |         self, | ||||||
|  |         channel: Channel, | ||||||
|  |     ) -> None: | ||||||
|  | 
 | ||||||
|  |         self._chan: Channel = channel | ||||||
|         # during the portal's lifetime |         # during the portal's lifetime | ||||||
|         self._result_msg: Optional[dict] = None |         self._final_result_pld: Any|None = None | ||||||
|  |         self._final_result_msg: PayloadMsg|None = None | ||||||
| 
 | 
 | ||||||
|         # When set to a ``Context`` (when _submit_for_result is called) |         # When set to a ``Context`` (when _submit_for_result is called) | ||||||
|         # it is expected that ``result()`` will be awaited at some |         # it is expected that ``result()`` will be awaited at some | ||||||
|         # point. |         # point. | ||||||
|         self._expect_result: Context | None = None |         self._expect_result_ctx: Context|None = None | ||||||
|         self._streams: set[MsgStream] = set() |         self._streams: set[MsgStream] = set() | ||||||
|         self.actor = current_actor() |         self.actor: Actor = current_actor() | ||||||
| 
 | 
 | ||||||
|  |     @property | ||||||
|  |     def chan(self) -> Channel: | ||||||
|  |         return self._chan | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def channel(self) -> Channel: | ||||||
|  |         ''' | ||||||
|  |         Proxy to legacy attr name.. | ||||||
|  | 
 | ||||||
|  |         Consider the shorter `Portal.chan` instead of `.channel` ;) | ||||||
|  |         ''' | ||||||
|  |         log.debug( | ||||||
|  |             'Consider the shorter `Portal.chan` instead of `.channel` ;)' | ||||||
|  |         ) | ||||||
|  |         return self.chan | ||||||
|  | 
 | ||||||
|  |     # TODO: factor this out into a `.highlevel` API-wrapper that uses | ||||||
|  |     # a single `.open_context()` call underneath. | ||||||
|     async def _submit_for_result( |     async def _submit_for_result( | ||||||
|         self, |         self, | ||||||
|         ns: str, |         ns: str, | ||||||
|  | @ -112,32 +134,34 @@ class Portal: | ||||||
|         **kwargs |         **kwargs | ||||||
|     ) -> None: |     ) -> None: | ||||||
| 
 | 
 | ||||||
|         assert self._expect_result is None, \ |         if self._expect_result_ctx is not None: | ||||||
|                 "A pending main result has already been submitted" |             raise RuntimeError( | ||||||
| 
 |                 'A pending main result has already been submitted' | ||||||
|         self._expect_result = await self.actor.start_remote_task( |  | ||||||
|             self.channel, |  | ||||||
|             ns, |  | ||||||
|             func, |  | ||||||
|             kwargs |  | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|     async def _return_once( |         self._expect_result_ctx: Context = await self.actor.start_remote_task( | ||||||
|  |             self.channel, | ||||||
|  |             nsf=NamespacePath(f'{ns}:{func}'), | ||||||
|  |             kwargs=kwargs, | ||||||
|  |             portal=self, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # TODO: we should deprecate this API right? since if we remove | ||||||
|  |     # `.run_in_actor()` (and instead move it to a `.highlevel` | ||||||
|  |     # wrapper api (around a single `.open_context()` call) we don't | ||||||
|  |     # really have any notion of a "main" remote task any more? | ||||||
|  |     # | ||||||
|  |     # @api_frame | ||||||
|  |     async def wait_for_result( | ||||||
|         self, |         self, | ||||||
|         ctx: Context, |         hide_tb: bool = True, | ||||||
| 
 |     ) -> Any: | ||||||
|     ) -> dict[str, Any]: |  | ||||||
| 
 |  | ||||||
|         assert ctx._remote_func_type == 'asyncfunc'  # single response |  | ||||||
|         msg = await ctx._recv_chan.receive() |  | ||||||
|         return msg |  | ||||||
| 
 |  | ||||||
|     async def result(self) -> Any: |  | ||||||
|         ''' |         ''' | ||||||
|         Return the result(s) from the remote actor's "main" task. |         Return the final result delivered by a `Return`-msg from the | ||||||
|  |         remote peer actor's "main" task's `return` statement. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         # __tracebackhide__ = True |         __tracebackhide__: bool = hide_tb | ||||||
|         # Check for non-rpc errors slapped on the |         # Check for non-rpc errors slapped on the | ||||||
|         # channel for which we always raise |         # channel for which we always raise | ||||||
|         exc = self.channel._exc |         exc = self.channel._exc | ||||||
|  | @ -145,7 +169,7 @@ class Portal: | ||||||
|             raise exc |             raise exc | ||||||
| 
 | 
 | ||||||
|         # not expecting a "main" result |         # not expecting a "main" result | ||||||
|         if self._expect_result is None: |         if self._expect_result_ctx is None: | ||||||
|             log.warning( |             log.warning( | ||||||
|                 f"Portal for {self.channel.uid} not expecting a final" |                 f"Portal for {self.channel.uid} not expecting a final" | ||||||
|                 " result?\nresult() should only be called if subactor" |                 " result?\nresult() should only be called if subactor" | ||||||
|  | @ -153,14 +177,41 @@ class Portal: | ||||||
|             return NoResult |             return NoResult | ||||||
| 
 | 
 | ||||||
|         # expecting a "main" result |         # expecting a "main" result | ||||||
|         assert self._expect_result |         assert self._expect_result_ctx | ||||||
| 
 | 
 | ||||||
|         if self._result_msg is None: |         if self._final_result_msg is None: | ||||||
|             self._result_msg = await self._return_once( |             try: | ||||||
|                 self._expect_result |                 ( | ||||||
|  |                     self._final_result_msg, | ||||||
|  |                     self._final_result_pld, | ||||||
|  |                 ) = await self._expect_result_ctx._pld_rx.recv_msg_w_pld( | ||||||
|  |                     ipc=self._expect_result_ctx, | ||||||
|  |                     expect_msg=Return, | ||||||
|                 ) |                 ) | ||||||
|  |             except BaseException as err: | ||||||
|  |                 # TODO: wrap this into `@api_frame` optionally with | ||||||
|  |                 # some kinda filtering mechanism like log levels? | ||||||
|  |                 __tracebackhide__: bool = False | ||||||
|  |                 raise err | ||||||
| 
 | 
 | ||||||
|         return _unwrap_msg(self._result_msg, self.channel) |         return self._final_result_pld | ||||||
|  | 
 | ||||||
|  |     # TODO: factor this out into a `.highlevel` API-wrapper that uses | ||||||
|  |     # a single `.open_context()` call underneath. | ||||||
|  |     async def result( | ||||||
|  |         self, | ||||||
|  |         *args, | ||||||
|  |         **kwargs, | ||||||
|  |     ) -> Any|Exception: | ||||||
|  |         typname: str = type(self).__name__ | ||||||
|  |         log.warning( | ||||||
|  |             f'`{typname}.result()` is DEPRECATED!\n' | ||||||
|  |             f'Use `{typname}.wait_for_result()` instead!\n' | ||||||
|  |         ) | ||||||
|  |         return await self.wait_for_result( | ||||||
|  |             *args, | ||||||
|  |             **kwargs, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     async def _cancel_streams(self): |     async def _cancel_streams(self): | ||||||
|         # terminate all locally running async generator |         # terminate all locally running async generator | ||||||
|  | @ -191,33 +242,60 @@ class Portal: | ||||||
| 
 | 
 | ||||||
|     ) -> bool: |     ) -> bool: | ||||||
|         ''' |         ''' | ||||||
|         Cancel the actor on the other end of this portal. |         Cancel the actor runtime (and thus process) on the far | ||||||
|  |         end of this portal. | ||||||
|  | 
 | ||||||
|  |         **NOTE** THIS CANCELS THE ENTIRE RUNTIME AND THE | ||||||
|  |         SUBPROCESS, it DOES NOT just cancel the remote task. If you | ||||||
|  |         want to have a handle to cancel a remote ``tri.Task`` look | ||||||
|  |         at `.open_context()` and the definition of | ||||||
|  |         `._context.Context.cancel()` which CAN be used for this | ||||||
|  |         purpose. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         if not self.channel.connected(): |         __runtimeframe__: int = 1  # noqa | ||||||
|             log.cancel("This channel is already closed can't cancel") | 
 | ||||||
|  |         chan: Channel = self.channel | ||||||
|  |         if not chan.connected(): | ||||||
|  |             log.runtime( | ||||||
|  |                 'This channel is already closed, skipping cancel request..' | ||||||
|  |             ) | ||||||
|             return False |             return False | ||||||
| 
 | 
 | ||||||
|  |         reminfo: str = ( | ||||||
|  |             f'c)=> {self.channel.uid}\n' | ||||||
|  |             f'  |_{chan}\n' | ||||||
|  |         ) | ||||||
|         log.cancel( |         log.cancel( | ||||||
|             f"Sending actor cancel request to {self.channel.uid} on " |             f'Requesting actor-runtime cancel for peer\n\n' | ||||||
|             f"{self.channel}") |             f'{reminfo}' | ||||||
| 
 |         ) | ||||||
|         self.channel._cancel_called = True |  | ||||||
| 
 | 
 | ||||||
|  |         # XXX the one spot we set it? | ||||||
|  |         self.channel._cancel_called: bool = True | ||||||
|         try: |         try: | ||||||
|             # send cancel cmd - might not get response |             # send cancel cmd - might not get response | ||||||
|             # XXX: sure would be nice to make this work with a proper shield |             # XXX: sure would be nice to make this work with | ||||||
|  |             # a proper shield | ||||||
|             with trio.move_on_after( |             with trio.move_on_after( | ||||||
|                 timeout |                 timeout | ||||||
|                 or self.cancel_timeout |                 or | ||||||
|  |                 self.cancel_timeout | ||||||
|             ) as cs: |             ) as cs: | ||||||
|                 cs.shield = True |                 cs.shield: bool = True | ||||||
| 
 |                 await self.run_from_ns( | ||||||
|                 await self.run_from_ns('self', 'cancel') |                     'self', | ||||||
|  |                     'cancel', | ||||||
|  |                 ) | ||||||
|                 return True |                 return True | ||||||
| 
 | 
 | ||||||
|             if cs.cancelled_caught: |             if cs.cancelled_caught: | ||||||
|                 log.cancel(f"May have failed to cancel {self.channel.uid}") |                 # may timeout and we never get an ack (obvi racy) | ||||||
|  |                 # but that doesn't mean it wasn't cancelled. | ||||||
|  |                 log.debug( | ||||||
|  |                     'May have failed to cancel peer?\n' | ||||||
|  |                     f'{reminfo}' | ||||||
|  |                 ) | ||||||
| 
 | 
 | ||||||
|             # if we get here some weird cancellation case happened |             # if we get here some weird cancellation case happened | ||||||
|             return False |             return False | ||||||
|  | @ -226,11 +304,15 @@ class Portal: | ||||||
|             trio.ClosedResourceError, |             trio.ClosedResourceError, | ||||||
|             trio.BrokenResourceError, |             trio.BrokenResourceError, | ||||||
|         ): |         ): | ||||||
|             log.cancel( |             log.debug( | ||||||
|                 f"{self.channel} for {self.channel.uid} was already " |                 'IPC chan for actor already closed or broken?\n\n' | ||||||
|                 "closed or broken?") |                 f'{self.channel.uid}\n' | ||||||
|  |                 f' |_{self.channel}\n' | ||||||
|  |             ) | ||||||
|             return False |             return False | ||||||
| 
 | 
 | ||||||
|  |     # TODO: do we still need this for low level `Actor`-runtime | ||||||
|  |     # method calls or can we also remove it? | ||||||
|     async def run_from_ns( |     async def run_from_ns( | ||||||
|         self, |         self, | ||||||
|         namespace_path: str, |         namespace_path: str, | ||||||
|  | @ -249,25 +331,33 @@ class Portal: | ||||||
| 
 | 
 | ||||||
|           A special namespace `self` can be used to invoke `Actor` |           A special namespace `self` can be used to invoke `Actor` | ||||||
|           instance methods in the remote runtime. Currently this |           instance methods in the remote runtime. Currently this | ||||||
|             should only be used solely for ``tractor`` runtime |           should only ever be used for `Actor` (method) runtime | ||||||
|             internals. |           internals! | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         ctx = await self.actor.start_remote_task( |         __runtimeframe__: int = 1  # noqa | ||||||
|             self.channel, |         nsf = NamespacePath( | ||||||
|             namespace_path, |             f'{namespace_path}:{function_name}' | ||||||
|             function_name, |         ) | ||||||
|             kwargs, |         ctx: Context = await self.actor.start_remote_task( | ||||||
|  |             chan=self.channel, | ||||||
|  |             nsf=nsf, | ||||||
|  |             kwargs=kwargs, | ||||||
|  |             portal=self, | ||||||
|  |         ) | ||||||
|  |         return await ctx._pld_rx.recv_pld( | ||||||
|  |             ipc=ctx, | ||||||
|  |             expect_msg=Return, | ||||||
|         ) |         ) | ||||||
|         ctx._portal = self |  | ||||||
|         msg = await self._return_once(ctx) |  | ||||||
|         return _unwrap_msg(msg, self.channel) |  | ||||||
| 
 | 
 | ||||||
|  |     # TODO: factor this out into a `.highlevel` API-wrapper that uses | ||||||
|  |     # a single `.open_context()` call underneath. | ||||||
|     async def run( |     async def run( | ||||||
|         self, |         self, | ||||||
|         func: str, |         func: str, | ||||||
|         fn_name: Optional[str] = None, |         fn_name: str|None = None, | ||||||
|         **kwargs |         **kwargs | ||||||
|  | 
 | ||||||
|     ) -> Any: |     ) -> Any: | ||||||
|         ''' |         ''' | ||||||
|         Submit a remote function to be scheduled and run by actor, in |         Submit a remote function to be scheduled and run by actor, in | ||||||
|  | @ -277,6 +367,8 @@ class Portal: | ||||||
|         remote rpc task or a local async generator instance. |         remote rpc task or a local async generator instance. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|  |         __runtimeframe__: int = 1  # noqa | ||||||
|  | 
 | ||||||
|         if isinstance(func, str): |         if isinstance(func, str): | ||||||
|             warnings.warn( |             warnings.warn( | ||||||
|                 "`Portal.run(namespace: str, funcname: str)` is now" |                 "`Portal.run(namespace: str, funcname: str)` is now" | ||||||
|  | @ -286,8 +378,9 @@ class Portal: | ||||||
|                 DeprecationWarning, |                 DeprecationWarning, | ||||||
|                 stacklevel=2, |                 stacklevel=2, | ||||||
|             ) |             ) | ||||||
|             fn_mod_path = func |             fn_mod_path: str = func | ||||||
|             assert isinstance(fn_name, str) |             assert isinstance(fn_name, str) | ||||||
|  |             nsf = NamespacePath(f'{fn_mod_path}:{fn_name}') | ||||||
| 
 | 
 | ||||||
|         else:  # function reference was passed directly |         else:  # function reference was passed directly | ||||||
|             if ( |             if ( | ||||||
|  | @ -300,27 +393,36 @@ class Portal: | ||||||
|                 raise TypeError( |                 raise TypeError( | ||||||
|                     f'{func} must be a non-streaming async function!') |                     f'{func} must be a non-streaming async function!') | ||||||
| 
 | 
 | ||||||
|             fn_mod_path, fn_name = NamespacePath.from_ref(func).to_tuple() |             nsf = NamespacePath.from_ref(func) | ||||||
| 
 | 
 | ||||||
|         ctx = await self.actor.start_remote_task( |         ctx = await self.actor.start_remote_task( | ||||||
|             self.channel, |             self.channel, | ||||||
|             fn_mod_path, |             nsf=nsf, | ||||||
|             fn_name, |             kwargs=kwargs, | ||||||
|             kwargs, |             portal=self, | ||||||
|         ) |         ) | ||||||
|         ctx._portal = self |         return await ctx._pld_rx.recv_pld( | ||||||
|         return _unwrap_msg( |             ipc=ctx, | ||||||
|             await self._return_once(ctx), |             expect_msg=Return, | ||||||
|             self.channel, |  | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     @asynccontextmanager |     # TODO: factor this out into a `.highlevel` API-wrapper that uses | ||||||
|  |     # a single `.open_context()` call underneath. | ||||||
|  |     @acm | ||||||
|     async def open_stream_from( |     async def open_stream_from( | ||||||
|         self, |         self, | ||||||
|         async_gen_func: Callable,  # typing: ignore |         async_gen_func: Callable,  # typing: ignore | ||||||
|         **kwargs, |         **kwargs, | ||||||
| 
 | 
 | ||||||
|     ) -> AsyncGenerator[MsgStream, None]: |     ) -> AsyncGenerator[MsgStream, None]: | ||||||
|  |         ''' | ||||||
|  |         Legacy one-way streaming API. | ||||||
|  | 
 | ||||||
|  |         TODO: re-impl on top `Portal.open_context()` + an async gen | ||||||
|  |         around `Context.open_stream()`. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         __runtimeframe__: int = 1  # noqa | ||||||
| 
 | 
 | ||||||
|         if not inspect.isasyncgenfunction(async_gen_func): |         if not inspect.isasyncgenfunction(async_gen_func): | ||||||
|             if not ( |             if not ( | ||||||
|  | @ -330,17 +432,12 @@ class Portal: | ||||||
|                 raise TypeError( |                 raise TypeError( | ||||||
|                     f'{async_gen_func} must be an async generator function!') |                     f'{async_gen_func} must be an async generator function!') | ||||||
| 
 | 
 | ||||||
|         fn_mod_path, fn_name = NamespacePath.from_ref( |         ctx: Context = await self.actor.start_remote_task( | ||||||
|             async_gen_func |  | ||||||
|         ).to_tuple() |  | ||||||
| 
 |  | ||||||
|         ctx = await self.actor.start_remote_task( |  | ||||||
|             self.channel, |             self.channel, | ||||||
|             fn_mod_path, |             nsf=NamespacePath.from_ref(async_gen_func), | ||||||
|             fn_name, |             kwargs=kwargs, | ||||||
|             kwargs |             portal=self, | ||||||
|         ) |         ) | ||||||
|         ctx._portal = self |  | ||||||
| 
 | 
 | ||||||
|         # ensure receive-only stream entrypoint |         # ensure receive-only stream entrypoint | ||||||
|         assert ctx._remote_func_type == 'asyncgen' |         assert ctx._remote_func_type == 'asyncgen' | ||||||
|  | @ -348,13 +445,14 @@ class Portal: | ||||||
|         try: |         try: | ||||||
|             # deliver receive only stream |             # deliver receive only stream | ||||||
|             async with MsgStream( |             async with MsgStream( | ||||||
|                 ctx, ctx._recv_chan, |                 ctx=ctx, | ||||||
|             ) as rchan: |                 rx_chan=ctx._rx_chan, | ||||||
|                 self._streams.add(rchan) |             ) as stream: | ||||||
|                 yield rchan |                 self._streams.add(stream) | ||||||
|  |                 ctx._stream = stream | ||||||
|  |                 yield stream | ||||||
| 
 | 
 | ||||||
|         finally: |         finally: | ||||||
| 
 |  | ||||||
|             # cancel the far end task on consumer close |             # cancel the far end task on consumer close | ||||||
|             # NOTE: this is a special case since we assume that if using |             # NOTE: this is a special case since we assume that if using | ||||||
|             # this ``.open_fream_from()`` api, the stream is one a one |             # this ``.open_fream_from()`` api, the stream is one a one | ||||||
|  | @ -373,205 +471,14 @@ class Portal: | ||||||
| 
 | 
 | ||||||
|             # XXX: should this always be done? |             # XXX: should this always be done? | ||||||
|             # await recv_chan.aclose() |             # await recv_chan.aclose() | ||||||
|             self._streams.remove(rchan) |             self._streams.remove(stream) | ||||||
| 
 | 
 | ||||||
|     @asynccontextmanager |     # NOTE: impl is found in `._context`` mod to make | ||||||
|     async def open_context( |     # reading/groking the details simpler code-org-wise. This | ||||||
| 
 |     # method does not have to be used over that `@acm` module func | ||||||
|         self, |     # directly, it is for conventience and from the original API | ||||||
|         func: Callable, |     # design. | ||||||
|         allow_overruns: bool = False, |     open_context = open_context_from_portal | ||||||
|         **kwargs, |  | ||||||
| 
 |  | ||||||
|     ) -> AsyncGenerator[tuple[Context, Any], None]: |  | ||||||
|         ''' |  | ||||||
|         Open an inter-actor task context. |  | ||||||
| 
 |  | ||||||
|         This is a synchronous API which allows for deterministic |  | ||||||
|         setup/teardown of a remote task. The yielded ``Context`` further |  | ||||||
|         allows for opening bidirectional streams, explicit cancellation |  | ||||||
|         and synchronized final result collection. See ``tractor.Context``. |  | ||||||
| 
 |  | ||||||
|         ''' |  | ||||||
|         # conduct target func method structural checks |  | ||||||
|         if not inspect.iscoroutinefunction(func) and ( |  | ||||||
|             getattr(func, '_tractor_contex_function', False) |  | ||||||
|         ): |  | ||||||
|             raise TypeError( |  | ||||||
|                 f'{func} must be an async generator function!') |  | ||||||
| 
 |  | ||||||
|         # TODO: i think from here onward should probably |  | ||||||
|         # just be factored into an `@acm` inside a new |  | ||||||
|         # a new `_context.py` mod. |  | ||||||
|         fn_mod_path, fn_name = NamespacePath.from_ref(func).to_tuple() |  | ||||||
| 
 |  | ||||||
|         ctx = await self.actor.start_remote_task( |  | ||||||
|             self.channel, |  | ||||||
|             fn_mod_path, |  | ||||||
|             fn_name, |  | ||||||
|             kwargs, |  | ||||||
| 
 |  | ||||||
|             # NOTE: it's imporant to expose this since you might |  | ||||||
|             # get the case where the parent who opened the context does |  | ||||||
|             # not open a stream until after some slow startup/init |  | ||||||
|             # period, in which case when the first msg is read from |  | ||||||
|             # the feeder mem chan, say when first calling |  | ||||||
|             # `Context.open_stream(allow_overruns=True)`, the overrun condition will be |  | ||||||
|             # raised before any ignoring of overflow msgs can take |  | ||||||
|             # place.. |  | ||||||
|             allow_overruns=allow_overruns, |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         assert ctx._remote_func_type == 'context' |  | ||||||
|         msg = await ctx._recv_chan.receive() |  | ||||||
| 
 |  | ||||||
|         try: |  | ||||||
|             # the "first" value here is delivered by the callee's |  | ||||||
|             # ``Context.started()`` call. |  | ||||||
|             first = msg['started'] |  | ||||||
|             ctx._started_called = True |  | ||||||
| 
 |  | ||||||
|         except KeyError: |  | ||||||
|             assert msg.get('cid'), ("Received internal error at context?") |  | ||||||
| 
 |  | ||||||
|             if msg.get('error'): |  | ||||||
|                 # raise kerr from unpack_error(msg, self.channel) |  | ||||||
|                 raise unpack_error(msg, self.channel) from None |  | ||||||
|             else: |  | ||||||
|                 raise MessagingError( |  | ||||||
|                     f'Context for {ctx.cid} was expecting a `started` message' |  | ||||||
|                     f' but received a non-error msg:\n{pformat(msg)}' |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|         _err: BaseException | None = None |  | ||||||
|         ctx._portal: Portal = self |  | ||||||
| 
 |  | ||||||
|         uid: tuple = self.channel.uid |  | ||||||
|         cid: str = ctx.cid |  | ||||||
|         etype: Type[BaseException] | None = None |  | ||||||
| 
 |  | ||||||
|         # deliver context instance and .started() msg value in enter |  | ||||||
|         # tuple. |  | ||||||
|         try: |  | ||||||
|             async with trio.open_nursery() as nurse: |  | ||||||
|                 ctx._scope_nursery = nurse |  | ||||||
|                 ctx._scope = nurse.cancel_scope |  | ||||||
| 
 |  | ||||||
|                 yield ctx, first |  | ||||||
| 
 |  | ||||||
|                 # when in allow_ovveruns mode there may be lingering |  | ||||||
|                 # overflow sender tasks remaining? |  | ||||||
|                 if nurse.child_tasks: |  | ||||||
|                     # ensure we are in overrun state with |  | ||||||
|                     # ``._allow_overruns=True`` bc otherwise |  | ||||||
|                     # there should be no tasks in this nursery! |  | ||||||
|                     if ( |  | ||||||
|                         not ctx._allow_overruns |  | ||||||
|                         or len(nurse.child_tasks) > 1 |  | ||||||
|                     ): |  | ||||||
|                         raise RuntimeError( |  | ||||||
|                             'Context has sub-tasks but is ' |  | ||||||
|                             'not in `allow_overruns=True` Mode!?' |  | ||||||
|                         ) |  | ||||||
|                     ctx._scope.cancel() |  | ||||||
| 
 |  | ||||||
|         except ContextCancelled as err: |  | ||||||
|             _err = err |  | ||||||
| 
 |  | ||||||
|             # swallow and mask cross-actor task context cancels that |  | ||||||
|             # were initiated by *this* side's task. |  | ||||||
|             if not ctx._cancel_called: |  | ||||||
|                 # XXX: this should NEVER happen! |  | ||||||
|                 # from ._debug import breakpoint |  | ||||||
|                 # await breakpoint() |  | ||||||
|                 raise |  | ||||||
| 
 |  | ||||||
|             # if the context was cancelled by client code |  | ||||||
|             # then we don't need to raise since user code |  | ||||||
|             # is expecting this and the block should exit. |  | ||||||
|             else: |  | ||||||
|                 log.debug(f'Context {ctx} cancelled gracefully') |  | ||||||
| 
 |  | ||||||
|         except ( |  | ||||||
|             BaseException, |  | ||||||
| 
 |  | ||||||
|             # more specifically, we need to handle these but not |  | ||||||
|             # sure it's worth being pedantic: |  | ||||||
|             # Exception, |  | ||||||
|             # trio.Cancelled, |  | ||||||
|             # KeyboardInterrupt, |  | ||||||
| 
 |  | ||||||
|         ) as err: |  | ||||||
|             etype = type(err) |  | ||||||
| 
 |  | ||||||
|             # cancel ourselves on any error. |  | ||||||
|             log.cancel( |  | ||||||
|                 'Context cancelled for task, sending cancel request..\n' |  | ||||||
|                 f'task:{cid}\n' |  | ||||||
|                 f'actor:{uid}' |  | ||||||
|             ) |  | ||||||
|             try: |  | ||||||
| 
 |  | ||||||
|                 await ctx.cancel() |  | ||||||
|             except trio.BrokenResourceError: |  | ||||||
|                 log.warning( |  | ||||||
|                     'IPC connection for context is broken?\n' |  | ||||||
|                     f'task:{cid}\n' |  | ||||||
|                     f'actor:{uid}' |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|             raise |  | ||||||
| 
 |  | ||||||
|         else: |  | ||||||
|             if ctx.chan.connected(): |  | ||||||
|                 log.info( |  | ||||||
|                     'Waiting on final context-task result for\n' |  | ||||||
|                     f'task: {cid}\n' |  | ||||||
|                     f'actor: {uid}' |  | ||||||
|                 ) |  | ||||||
|                 result = await ctx.result() |  | ||||||
|                 log.runtime( |  | ||||||
|                     f'Context {fn_name} returned ' |  | ||||||
|                     f'value from callee `{result}`' |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|         finally: |  | ||||||
|             # though it should be impossible for any tasks |  | ||||||
|             # operating *in* this scope to have survived |  | ||||||
|             # we tear down the runtime feeder chan last |  | ||||||
|             # to avoid premature stream clobbers. |  | ||||||
|             if ctx._recv_chan is not None: |  | ||||||
|                 # should we encapsulate this in the context api? |  | ||||||
|                 await ctx._recv_chan.aclose() |  | ||||||
| 
 |  | ||||||
|             if etype: |  | ||||||
|                 if ctx._cancel_called: |  | ||||||
|                     log.cancel( |  | ||||||
|                         f'Context {fn_name} cancelled by caller with\n{etype}' |  | ||||||
|                     ) |  | ||||||
|                 elif _err is not None: |  | ||||||
|                     log.cancel( |  | ||||||
|                         f'Context for task cancelled by callee with {etype}\n' |  | ||||||
|                         f'target: `{fn_name}`\n' |  | ||||||
|                         f'task:{cid}\n' |  | ||||||
|                         f'actor:{uid}' |  | ||||||
|                     ) |  | ||||||
|             # XXX: (MEGA IMPORTANT) if this is a root opened process we |  | ||||||
|             # wait for any immediate child in debug before popping the |  | ||||||
|             # context from the runtime msg loop otherwise inside |  | ||||||
|             # ``Actor._push_result()`` the msg will be discarded and in |  | ||||||
|             # the case where that msg is global debugger unlock (via |  | ||||||
|             # a "stop" msg for a stream), this can result in a deadlock |  | ||||||
|             # where the root is waiting on the lock to clear but the |  | ||||||
|             # child has already cleared it and clobbered IPC. |  | ||||||
|             from ._debug import maybe_wait_for_debugger |  | ||||||
|             await maybe_wait_for_debugger() |  | ||||||
| 
 |  | ||||||
|             # remove the context from runtime tracking |  | ||||||
|             self.actor._contexts.pop( |  | ||||||
|                 (self.channel.uid, ctx.cid), |  | ||||||
|                 None, |  | ||||||
|             ) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @dataclass | @dataclass | ||||||
|  | @ -586,7 +493,12 @@ class LocalPortal: | ||||||
|     actor: 'Actor'  # type: ignore # noqa |     actor: 'Actor'  # type: ignore # noqa | ||||||
|     channel: Channel |     channel: Channel | ||||||
| 
 | 
 | ||||||
|     async def run_from_ns(self, ns: str, func_name: str, **kwargs) -> Any: |     async def run_from_ns( | ||||||
|  |         self, | ||||||
|  |         ns: str, | ||||||
|  |         func_name: str, | ||||||
|  |         **kwargs, | ||||||
|  |     ) -> Any: | ||||||
|         ''' |         ''' | ||||||
|         Run a requested local function from a namespace path and |         Run a requested local function from a namespace path and | ||||||
|         return it's result. |         return it's result. | ||||||
|  | @ -597,11 +509,11 @@ class LocalPortal: | ||||||
|         return await func(**kwargs) |         return await func(**kwargs) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @asynccontextmanager | @acm | ||||||
| async def open_portal( | async def open_portal( | ||||||
| 
 | 
 | ||||||
|     channel: Channel, |     channel: Channel, | ||||||
|     nursery: Optional[trio.Nursery] = None, |     tn: trio.Nursery|None = None, | ||||||
|     start_msg_loop: bool = True, |     start_msg_loop: bool = True, | ||||||
|     shield: bool = False, |     shield: bool = False, | ||||||
| 
 | 
 | ||||||
|  | @ -609,15 +521,19 @@ async def open_portal( | ||||||
|     ''' |     ''' | ||||||
|     Open a ``Portal`` through the provided ``channel``. |     Open a ``Portal`` through the provided ``channel``. | ||||||
| 
 | 
 | ||||||
|     Spawns a background task to handle message processing (normally |     Spawns a background task to handle RPC processing, normally | ||||||
|     done by the actor-runtime implicitly). |     done by the actor-runtime implicitly via a call to | ||||||
|  |     `._rpc.process_messages()`. just after connection establishment. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     actor = current_actor() |     actor = current_actor() | ||||||
|     assert actor |     assert actor | ||||||
|     was_connected = False |     was_connected: bool = False | ||||||
| 
 | 
 | ||||||
|     async with maybe_open_nursery(nursery, shield=shield) as nursery: |     async with maybe_open_nursery( | ||||||
|  |         tn, | ||||||
|  |         shield=shield, | ||||||
|  |     ) as tn: | ||||||
| 
 | 
 | ||||||
|         if not channel.connected(): |         if not channel.connected(): | ||||||
|             await channel.connect() |             await channel.connect() | ||||||
|  | @ -626,10 +542,10 @@ async def open_portal( | ||||||
|         if channel.uid is None: |         if channel.uid is None: | ||||||
|             await actor._do_handshake(channel) |             await actor._do_handshake(channel) | ||||||
| 
 | 
 | ||||||
|         msg_loop_cs: Optional[trio.CancelScope] = None |         msg_loop_cs: trio.CancelScope|None = None | ||||||
|         if start_msg_loop: |         if start_msg_loop: | ||||||
|             from ._runtime import process_messages |             from ._runtime import process_messages | ||||||
|             msg_loop_cs = await nursery.start( |             msg_loop_cs = await tn.start( | ||||||
|                 partial( |                 partial( | ||||||
|                     process_messages, |                     process_messages, | ||||||
|                     actor, |                     actor, | ||||||
|  | @ -646,12 +562,10 @@ async def open_portal( | ||||||
|             await portal.aclose() |             await portal.aclose() | ||||||
| 
 | 
 | ||||||
|             if was_connected: |             if was_connected: | ||||||
|                 # gracefully signal remote channel-msg loop |                 await channel.aclose() | ||||||
|                 await channel.send(None) |  | ||||||
|                 # await channel.aclose() |  | ||||||
| 
 | 
 | ||||||
|             # cancel background msg loop task |             # cancel background msg loop task | ||||||
|             if msg_loop_cs: |             if msg_loop_cs is not None: | ||||||
|                 msg_loop_cs.cancel() |                 msg_loop_cs.cancel() | ||||||
| 
 | 
 | ||||||
|             nursery.cancel_scope.cancel() |             tn.cancel_scope.cancel() | ||||||
|  |  | ||||||
							
								
								
									
										340
									
								
								tractor/_root.py
								
								
								
								
							
							
						
						
									
										340
									
								
								tractor/_root.py
								
								
								
								
							|  | @ -18,26 +18,28 @@ | ||||||
| Root actor runtime ignition(s). | Root actor runtime ignition(s). | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| from contextlib import asynccontextmanager | from contextlib import asynccontextmanager as acm | ||||||
| from functools import partial | from functools import partial | ||||||
| import importlib | import importlib | ||||||
|  | import inspect | ||||||
| import logging | import logging | ||||||
|  | import os | ||||||
| import signal | import signal | ||||||
| import sys | import sys | ||||||
| import os | from typing import Callable | ||||||
| import typing |  | ||||||
| import warnings | import warnings | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| from exceptiongroup import BaseExceptionGroup |  | ||||||
| import trio | import trio | ||||||
| 
 | 
 | ||||||
| from ._runtime import ( | from ._runtime import ( | ||||||
|     Actor, |     Actor, | ||||||
|     Arbiter, |     Arbiter, | ||||||
|  |     # TODO: rename and make a non-actor subtype? | ||||||
|  |     # Arbiter as Registry, | ||||||
|     async_main, |     async_main, | ||||||
| ) | ) | ||||||
| from . import _debug | from .devx import _debug | ||||||
| from . import _spawn | from . import _spawn | ||||||
| from . import _state | from . import _state | ||||||
| from . import log | from . import log | ||||||
|  | @ -46,60 +48,131 @@ from ._exceptions import is_multi_cancelled | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # set at startup and after forks | # set at startup and after forks | ||||||
| _default_arbiter_host: str = '127.0.0.1' | _default_host: str = '127.0.0.1' | ||||||
| _default_arbiter_port: int = 1616 | _default_port: int = 1616 | ||||||
|  | 
 | ||||||
|  | # default registry always on localhost | ||||||
|  | _default_lo_addrs: list[tuple[str, int]] = [( | ||||||
|  |     _default_host, | ||||||
|  |     _default_port, | ||||||
|  | )] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| logger = log.get_logger('tractor') | logger = log.get_logger('tractor') | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @asynccontextmanager | @acm | ||||||
| async def open_root_actor( | async def open_root_actor( | ||||||
| 
 | 
 | ||||||
|     *, |     *, | ||||||
|     # defaults are above |     # defaults are above | ||||||
|     arbiter_addr: tuple[str, int] | None = None, |     registry_addrs: list[tuple[str, int]]|None = None, | ||||||
| 
 | 
 | ||||||
|     # defaults are above |     # defaults are above | ||||||
|     registry_addr: tuple[str, int] | None = None, |     arbiter_addr: tuple[str, int]|None = None, | ||||||
| 
 | 
 | ||||||
|     name: str | None = 'root', |     name: str|None = 'root', | ||||||
| 
 | 
 | ||||||
|     # either the `multiprocessing` start method: |     # either the `multiprocessing` start method: | ||||||
|     # https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods |     # https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods | ||||||
|     # OR `trio` (the new default). |     # OR `trio` (the new default). | ||||||
|     start_method: _spawn.SpawnMethodKey | None = None, |     start_method: _spawn.SpawnMethodKey|None = None, | ||||||
| 
 | 
 | ||||||
|     # enables the multi-process debugger support |     # enables the multi-process debugger support | ||||||
|     debug_mode: bool = False, |     debug_mode: bool = False, | ||||||
|  |     maybe_enable_greenback: bool = True,  # `.pause_from_sync()/breakpoint()` support | ||||||
|  |     enable_stack_on_sig: bool = False, | ||||||
| 
 | 
 | ||||||
|     # internal logging |     # internal logging | ||||||
|     loglevel: str | None = None, |     loglevel: str|None = None, | ||||||
| 
 | 
 | ||||||
|     enable_modules: list | None = None, |     enable_modules: list|None = None, | ||||||
|     rpc_module_paths: list | None = None, |     rpc_module_paths: list|None = None, | ||||||
| 
 | 
 | ||||||
| ) -> typing.Any: |     # NOTE: allow caller to ensure that only one registry exists | ||||||
|  |     # and that this call creates it. | ||||||
|  |     ensure_registry: bool = False, | ||||||
|  | 
 | ||||||
|  |     hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|  |     # XXX, proxied directly to `.devx._debug._maybe_enter_pm()` | ||||||
|  |     # for REPL-entry logic. | ||||||
|  |     debug_filter: Callable[ | ||||||
|  |         [BaseException|BaseExceptionGroup], | ||||||
|  |         bool, | ||||||
|  |     ] = lambda err: not is_multi_cancelled(err), | ||||||
|  | 
 | ||||||
|  |     # TODO, a way for actors to augment passing derived | ||||||
|  |     # read-only state to sublayers? | ||||||
|  |     # extra_rt_vars: dict|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> Actor: | ||||||
|     ''' |     ''' | ||||||
|     Runtime init entry point for ``tractor``. |     Runtime init entry point for ``tractor``. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|  |     __tracebackhide__: bool = hide_tb | ||||||
|  |     _debug.hide_runtime_frames() | ||||||
|  | 
 | ||||||
|  |     # TODO: stick this in a `@cm` defined in `devx._debug`? | ||||||
|  |     # | ||||||
|     # Override the global debugger hook to make it play nice with |     # Override the global debugger hook to make it play nice with | ||||||
|     # ``trio``, see much discussion in: |     # ``trio``, see much discussion in: | ||||||
|     # https://github.com/python-trio/trio/issues/1155#issuecomment-742964018 |     # https://github.com/python-trio/trio/issues/1155#issuecomment-742964018 | ||||||
|     builtin_bp_handler = sys.breakpointhook |     builtin_bp_handler: Callable = sys.breakpointhook | ||||||
|     orig_bp_path: str | None = os.environ.get('PYTHONBREAKPOINT', None) |     orig_bp_path: str|None = os.environ.get( | ||||||
|     os.environ['PYTHONBREAKPOINT'] = 'tractor._debug._set_trace' |         'PYTHONBREAKPOINT', | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  |     if ( | ||||||
|  |         debug_mode | ||||||
|  |         and maybe_enable_greenback | ||||||
|  |         and ( | ||||||
|  |             maybe_mod := await _debug.maybe_init_greenback( | ||||||
|  |                 raise_not_found=False, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |     ): | ||||||
|  |         logger.info( | ||||||
|  |             f'Found `greenback` installed @ {maybe_mod}\n' | ||||||
|  |             'Enabling `tractor.pause_from_sync()` support!\n' | ||||||
|  |         ) | ||||||
|  |         os.environ['PYTHONBREAKPOINT'] = ( | ||||||
|  |             'tractor.devx._debug._sync_pause_from_builtin' | ||||||
|  |         ) | ||||||
|  |         _state._runtime_vars['use_greenback'] = True | ||||||
|  | 
 | ||||||
|  |     else: | ||||||
|  |         # TODO: disable `breakpoint()` by default (without | ||||||
|  |         # `greenback`) since it will break any multi-actor | ||||||
|  |         # usage by a clobbered TTY's stdstreams! | ||||||
|  |         def block_bps(*args, **kwargs): | ||||||
|  |             raise RuntimeError( | ||||||
|  |                 'Trying to use `breakpoint()` eh?\n\n' | ||||||
|  |                 'Welp, `tractor` blocks `breakpoint()` built-in calls by default!\n' | ||||||
|  |                 'If you need to use it please install `greenback` and set ' | ||||||
|  |                 '`debug_mode=True` when opening the runtime ' | ||||||
|  |                 '(either via `.open_nursery()` or `open_root_actor()`)\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         sys.breakpointhook = block_bps | ||||||
|  |         # lol ok, | ||||||
|  |         # https://docs.python.org/3/library/sys.html#sys.breakpointhook | ||||||
|  |         os.environ['PYTHONBREAKPOINT'] = "0" | ||||||
| 
 | 
 | ||||||
|     # attempt to retreive ``trio``'s sigint handler and stash it |     # attempt to retreive ``trio``'s sigint handler and stash it | ||||||
|     # on our debugger lock state. |     # on our debugger lock state. | ||||||
|     _debug.Lock._trio_handler = signal.getsignal(signal.SIGINT) |     _debug.DebugStatus._trio_handler = signal.getsignal(signal.SIGINT) | ||||||
| 
 | 
 | ||||||
|     # mark top most level process as root actor |     # mark top most level process as root actor | ||||||
|     _state._runtime_vars['_is_root'] = True |     _state._runtime_vars['_is_root'] = True | ||||||
| 
 | 
 | ||||||
|     # caps based rpc list |     # caps based rpc list | ||||||
|     enable_modules = enable_modules or [] |     enable_modules = ( | ||||||
|  |         enable_modules | ||||||
|  |         or | ||||||
|  |         [] | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     if rpc_module_paths: |     if rpc_module_paths: | ||||||
|         warnings.warn( |         warnings.warn( | ||||||
|  | @ -115,29 +188,34 @@ async def open_root_actor( | ||||||
| 
 | 
 | ||||||
|     if arbiter_addr is not None: |     if arbiter_addr is not None: | ||||||
|         warnings.warn( |         warnings.warn( | ||||||
|             '`arbiter_addr` is now deprecated and has been renamed to' |             '`arbiter_addr` is now deprecated\n' | ||||||
|             '`registry_addr`.\nUse that instead..', |             'Use `registry_addrs: list[tuple]` instead..', | ||||||
|             DeprecationWarning, |             DeprecationWarning, | ||||||
|             stacklevel=2, |             stacklevel=2, | ||||||
|         ) |         ) | ||||||
|  |         registry_addrs = [arbiter_addr] | ||||||
| 
 | 
 | ||||||
|     registry_addr = (host, port) = ( |     registry_addrs: list[tuple[str, int]] = ( | ||||||
|         registry_addr |         registry_addrs | ||||||
|         or arbiter_addr |         or | ||||||
|         or ( |         _default_lo_addrs | ||||||
|             _default_arbiter_host, |  | ||||||
|             _default_arbiter_port, |  | ||||||
|         ) |  | ||||||
|     ) |     ) | ||||||
|  |     assert registry_addrs | ||||||
| 
 | 
 | ||||||
|     loglevel = (loglevel or log._default_loglevel).upper() |     loglevel = ( | ||||||
|  |         loglevel | ||||||
|  |         or log._default_loglevel | ||||||
|  |     ).upper() | ||||||
| 
 | 
 | ||||||
|     if debug_mode and _spawn._spawn_method == 'trio': |     if ( | ||||||
|  |         debug_mode | ||||||
|  |         and _spawn._spawn_method == 'trio' | ||||||
|  |     ): | ||||||
|         _state._runtime_vars['_debug_mode'] = True |         _state._runtime_vars['_debug_mode'] = True | ||||||
| 
 | 
 | ||||||
|         # expose internal debug module to every actor allowing |         # expose internal debug module to every actor allowing for | ||||||
|         # for use of ``await tractor.breakpoint()`` |         # use of ``await tractor.pause()`` | ||||||
|         enable_modules.append('tractor._debug') |         enable_modules.append('tractor.devx._debug') | ||||||
| 
 | 
 | ||||||
|         # if debug mode get's enabled *at least* use that level of |         # if debug mode get's enabled *at least* use that level of | ||||||
|         # logging for some informative console prompts. |         # logging for some informative console prompts. | ||||||
|  | @ -150,97 +228,187 @@ async def open_root_actor( | ||||||
|         ): |         ): | ||||||
|             loglevel = 'PDB' |             loglevel = 'PDB' | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|     elif debug_mode: |     elif debug_mode: | ||||||
|         raise RuntimeError( |         raise RuntimeError( | ||||||
|             "Debug mode is only supported for the `trio` backend!" |             "Debug mode is only supported for the `trio` backend!" | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     log.get_console_log(loglevel) |     assert loglevel | ||||||
|  |     _log = log.get_console_log(loglevel) | ||||||
|  |     assert _log | ||||||
| 
 | 
 | ||||||
|  |     # TODO: factor this into `.devx._stackscope`!! | ||||||
|  |     if ( | ||||||
|  |         debug_mode | ||||||
|  |         and | ||||||
|  |         enable_stack_on_sig | ||||||
|  |     ): | ||||||
|  |         from .devx._stackscope import enable_stack_on_sig | ||||||
|  |         enable_stack_on_sig() | ||||||
|  | 
 | ||||||
|  |     # closed into below ping task-func | ||||||
|  |     ponged_addrs: list[tuple[str, int]] = [] | ||||||
|  | 
 | ||||||
|  |     async def ping_tpt_socket( | ||||||
|  |         addr: tuple[str, int], | ||||||
|  |         timeout: float = 1, | ||||||
|  |     ) -> None: | ||||||
|  |         ''' | ||||||
|  |         Attempt temporary connection to see if a registry is | ||||||
|  |         listening at the requested address by a tranport layer | ||||||
|  |         ping. | ||||||
|  | 
 | ||||||
|  |         If a connection can't be made quickly we assume none no | ||||||
|  |         server is listening at that addr. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|         try: |         try: | ||||||
|         # make a temporary connection to see if an arbiter exists, |             # TODO: this connect-and-bail forces us to have to | ||||||
|         # if one can't be made quickly we assume none exists. |             # carefully rewrap TCP 104-connection-reset errors as | ||||||
|         arbiter_found = False |             # EOF so as to avoid propagating cancel-causing errors | ||||||
| 
 |             # to the channel-msg loop machinery. Likely it would | ||||||
|         # TODO: this connect-and-bail forces us to have to carefully |             # be better to eventually have a "discovery" protocol | ||||||
|         # rewrap TCP 104-connection-reset errors as EOF so as to avoid |             # with basic handshake instead? | ||||||
|         # propagating cancel-causing errors to the channel-msg loop |             with trio.move_on_after(timeout): | ||||||
|         # machinery.  Likely it would be better to eventually have |                 async with _connect_chan(*addr): | ||||||
|         # a "discovery" protocol with basic handshake instead. |                     ponged_addrs.append(addr) | ||||||
|         with trio.move_on_after(1): |  | ||||||
|             async with _connect_chan(host, port): |  | ||||||
|                 arbiter_found = True |  | ||||||
| 
 | 
 | ||||||
|         except OSError: |         except OSError: | ||||||
|             # TODO: make this a "discovery" log level? |             # TODO: make this a "discovery" log level? | ||||||
|         logger.warning(f"No actor registry found @ {host}:{port}") |             logger.info( | ||||||
|  |                 f'No actor registry found @ {addr}\n' | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|     # create a local actor and start up its main routine/task |     async with trio.open_nursery() as tn: | ||||||
|     if arbiter_found: |         for addr in registry_addrs: | ||||||
|  |             tn.start_soon( | ||||||
|  |                 ping_tpt_socket, | ||||||
|  |                 tuple(addr),  # TODO: just drop this requirement? | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     trans_bind_addrs: list[tuple[str, int]] = [] | ||||||
|  | 
 | ||||||
|  |     # Create a new local root-actor instance which IS NOT THE | ||||||
|  |     # REGISTRAR | ||||||
|  |     if ponged_addrs: | ||||||
|  |         if ensure_registry: | ||||||
|  |             raise RuntimeError( | ||||||
|  |                  f'Failed to open `{name}`@{ponged_addrs}: ' | ||||||
|  |                 'registry socket(s) already bound' | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|         # we were able to connect to an arbiter |         # we were able to connect to an arbiter | ||||||
|         logger.info(f"Arbiter seems to exist @ {host}:{port}") |         logger.info( | ||||||
|  |             f'Registry(s) seem(s) to exist @ {ponged_addrs}' | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         actor = Actor( |         actor = Actor( | ||||||
|             name or 'anonymous', |             name=name or 'anonymous', | ||||||
|             arbiter_addr=registry_addr, |             registry_addrs=ponged_addrs, | ||||||
|             loglevel=loglevel, |             loglevel=loglevel, | ||||||
|             enable_modules=enable_modules, |             enable_modules=enable_modules, | ||||||
|         ) |         ) | ||||||
|         host, port = (host, 0) |         # DO NOT use the registry_addrs as the transport server | ||||||
|  |         # addrs for this new non-registar, root-actor. | ||||||
|  |         for host, port in ponged_addrs: | ||||||
|  |             # NOTE: zero triggers dynamic OS port allocation | ||||||
|  |             trans_bind_addrs.append((host, 0)) | ||||||
| 
 | 
 | ||||||
|  |     # Start this local actor as the "registrar", aka a regular | ||||||
|  |     # actor who manages the local registry of "mailboxes" of | ||||||
|  |     # other process-tree-local sub-actors. | ||||||
|     else: |     else: | ||||||
|         # start this local actor as the arbiter (aka a regular actor who |  | ||||||
|         # manages the local registry of "mailboxes") |  | ||||||
| 
 | 
 | ||||||
|         # Note that if the current actor is the arbiter it is desirable |         # NOTE that if the current actor IS THE REGISTAR, the | ||||||
|         # for it to stay up indefinitely until a re-election process has |         # following init steps are taken: | ||||||
|         # taken place - which is not implemented yet FYI). |         # - the tranport layer server is bound to each (host, port) | ||||||
|  |         #   pair defined in provided registry_addrs, or the default. | ||||||
|  |         trans_bind_addrs = registry_addrs | ||||||
|  | 
 | ||||||
|  |         # - it is normally desirable for any registrar to stay up | ||||||
|  |         #   indefinitely until either all registered (child/sub) | ||||||
|  |         #   actors are terminated (via SC supervision) or, | ||||||
|  |         #   a re-election process has taken place.  | ||||||
|  |         # NOTE: all of ^ which is not implemented yet - see: | ||||||
|  |         # https://github.com/goodboy/tractor/issues/216 | ||||||
|  |         # https://github.com/goodboy/tractor/pull/348 | ||||||
|  |         # https://github.com/goodboy/tractor/issues/296 | ||||||
| 
 | 
 | ||||||
|         actor = Arbiter( |         actor = Arbiter( | ||||||
|             name or 'arbiter', |             name or 'registrar', | ||||||
|             arbiter_addr=registry_addr, |             registry_addrs=registry_addrs, | ||||||
|             loglevel=loglevel, |             loglevel=loglevel, | ||||||
|             enable_modules=enable_modules, |             enable_modules=enable_modules, | ||||||
|         ) |         ) | ||||||
|  |         # XXX, in case the root actor runtime was actually run from | ||||||
|  |         # `tractor.to_asyncio.run_as_asyncio_guest()` and NOt | ||||||
|  |         # `.trio.run()`. | ||||||
|  |         actor._infected_aio = _state._runtime_vars['_is_infected_aio'] | ||||||
| 
 | 
 | ||||||
|  |     # Start up main task set via core actor-runtime nurseries. | ||||||
|     try: |     try: | ||||||
|         # assign process-local actor |         # assign process-local actor | ||||||
|         _state._current_actor = actor |         _state._current_actor = actor | ||||||
| 
 | 
 | ||||||
|         # start local channel-server and fake the portal API |         # start local channel-server and fake the portal API | ||||||
|         # NOTE: this won't block since we provide the nursery |         # NOTE: this won't block since we provide the nursery | ||||||
|         logger.info(f"Starting local {actor} @ {host}:{port}") |         ml_addrs_str: str = '\n'.join( | ||||||
|  |             f'@{addr}' for addr in trans_bind_addrs | ||||||
|  |         ) | ||||||
|  |         logger.info( | ||||||
|  |             f'Starting local {actor.uid} on the following transport addrs:\n' | ||||||
|  |             f'{ml_addrs_str}' | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         # start the actor runtime in a new task |         # start the actor runtime in a new task | ||||||
|         async with trio.open_nursery() as nursery: |         async with trio.open_nursery() as nursery: | ||||||
| 
 | 
 | ||||||
|             # ``_runtime.async_main()`` creates an internal nursery and |             # ``_runtime.async_main()`` creates an internal nursery | ||||||
|             # thus blocks here until the entire underlying actor tree has |             # and blocks here until any underlying actor(-process) | ||||||
|             # terminated thereby conducting structured concurrency. |             # tree has terminated thereby conducting so called | ||||||
| 
 |             # "end-to-end" structured concurrency throughout an | ||||||
|  |             # entire hierarchical python sub-process set; all | ||||||
|  |             # "actor runtime" primitives are SC-compat and thus all | ||||||
|  |             # transitively spawned actors/processes must be as | ||||||
|  |             # well. | ||||||
|             await nursery.start( |             await nursery.start( | ||||||
|                 partial( |                 partial( | ||||||
|                     async_main, |                     async_main, | ||||||
|                     actor, |                     actor, | ||||||
|                     accept_addr=(host, port), |                     accept_addrs=trans_bind_addrs, | ||||||
|                     parent_addr=None |                     parent_addr=None | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|             try: |             try: | ||||||
|                 yield actor |                 yield actor | ||||||
| 
 |  | ||||||
|             except ( |             except ( | ||||||
|                 Exception, |                 Exception, | ||||||
|                 BaseExceptionGroup, |                 BaseExceptionGroup, | ||||||
|             ) as err: |             ) as err: | ||||||
| 
 | 
 | ||||||
|                 entered = await _debug._maybe_enter_pm(err) |                 # XXX NOTE XXX see equiv note inside | ||||||
|  |                 # `._runtime.Actor._stream_handler()` where in the | ||||||
|  |                 # non-root or root-that-opened-this-mahually case we | ||||||
|  |                 # wait for the local actor-nursery to exit before | ||||||
|  |                 # exiting the transport channel handler. | ||||||
|  |                 entered: bool = await _debug._maybe_enter_pm( | ||||||
|  |                     err, | ||||||
|  |                     api_frame=inspect.currentframe(), | ||||||
|  |                     debug_filter=debug_filter, | ||||||
|  |                 ) | ||||||
| 
 | 
 | ||||||
|                 if not entered and not is_multi_cancelled(err): |                 if ( | ||||||
|                     logger.exception("Root actor crashed:") |                     not entered | ||||||
|  |                     and | ||||||
|  |                     not is_multi_cancelled( | ||||||
|  |                         err, | ||||||
|  |                     ) | ||||||
|  |                 ): | ||||||
|  |                     logger.exception('Root actor crashed\n') | ||||||
| 
 | 
 | ||||||
|                 # always re-raise |                 # ALWAYS re-raise any error bubbled up from the | ||||||
|  |                 # runtime! | ||||||
|                 raise |                 raise | ||||||
| 
 | 
 | ||||||
|             finally: |             finally: | ||||||
|  | @ -253,20 +421,29 @@ async def open_root_actor( | ||||||
|                 #     for an in nurseries: |                 #     for an in nurseries: | ||||||
|                 #         tempn.start_soon(an.exited.wait) |                 #         tempn.start_soon(an.exited.wait) | ||||||
| 
 | 
 | ||||||
|                 logger.cancel("Shutting down root actor") |                 logger.info( | ||||||
|                 await actor.cancel( |                     'Closing down root actor' | ||||||
|                     requesting_uid=actor.uid, |  | ||||||
|                 ) |                 ) | ||||||
|  |                 await actor.cancel(None)  # self cancel | ||||||
|     finally: |     finally: | ||||||
|         _state._current_actor = None |         _state._current_actor = None | ||||||
|  |         _state._last_actor_terminated = actor | ||||||
| 
 | 
 | ||||||
|         # restore breakpoint hook state |         # restore built-in `breakpoint()` hook state | ||||||
|  |         if ( | ||||||
|  |             debug_mode | ||||||
|  |             and | ||||||
|  |             maybe_enable_greenback | ||||||
|  |         ): | ||||||
|  |             if builtin_bp_handler is not None: | ||||||
|                 sys.breakpointhook = builtin_bp_handler |                 sys.breakpointhook = builtin_bp_handler | ||||||
|  | 
 | ||||||
|             if orig_bp_path is not None: |             if orig_bp_path is not None: | ||||||
|                 os.environ['PYTHONBREAKPOINT'] = orig_bp_path |                 os.environ['PYTHONBREAKPOINT'] = orig_bp_path | ||||||
|  | 
 | ||||||
|             else: |             else: | ||||||
|                 # clear env back to having no entry |                 # clear env back to having no entry | ||||||
|             os.environ.pop('PYTHONBREAKPOINT') |                 os.environ.pop('PYTHONBREAKPOINT', None) | ||||||
| 
 | 
 | ||||||
|         logger.runtime("Root actor terminated") |         logger.runtime("Root actor terminated") | ||||||
| 
 | 
 | ||||||
|  | @ -276,10 +453,7 @@ def run_daemon( | ||||||
| 
 | 
 | ||||||
|     # runtime kwargs |     # runtime kwargs | ||||||
|     name: str | None = 'root', |     name: str | None = 'root', | ||||||
|     registry_addr: tuple[str, int] = ( |     registry_addrs: list[tuple[str, int]] = _default_lo_addrs, | ||||||
|         _default_arbiter_host, |  | ||||||
|         _default_arbiter_port, |  | ||||||
|     ), |  | ||||||
| 
 | 
 | ||||||
|     start_method: str | None = None, |     start_method: str | None = None, | ||||||
|     debug_mode: bool = False, |     debug_mode: bool = False, | ||||||
|  | @ -303,7 +477,7 @@ def run_daemon( | ||||||
|     async def _main(): |     async def _main(): | ||||||
| 
 | 
 | ||||||
|         async with open_root_actor( |         async with open_root_actor( | ||||||
|             registry_addr=registry_addr, |             registry_addrs=registry_addrs, | ||||||
|             name=name, |             name=name, | ||||||
|             start_method=start_method, |             start_method=start_method, | ||||||
|             debug_mode=debug_mode, |             debug_mode=debug_mode, | ||||||
|  |  | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										2420
									
								
								tractor/_runtime.py
								
								
								
								
							
							
						
						
									
										2420
									
								
								tractor/_runtime.py
								
								
								
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -31,25 +31,28 @@ from typing import ( | ||||||
|     TYPE_CHECKING, |     TYPE_CHECKING, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| from exceptiongroup import BaseExceptionGroup |  | ||||||
| import trio | import trio | ||||||
| from trio_typing import TaskStatus | from trio import TaskStatus | ||||||
| 
 | 
 | ||||||
| from ._debug import ( | from .devx._debug import ( | ||||||
|     maybe_wait_for_debugger, |     maybe_wait_for_debugger, | ||||||
|     acquire_debug_lock, |     acquire_debug_lock, | ||||||
| ) | ) | ||||||
| from ._state import ( | from tractor._state import ( | ||||||
|     current_actor, |     current_actor, | ||||||
|     is_main_process, |     is_main_process, | ||||||
|     is_root_process, |     is_root_process, | ||||||
|     debug_mode, |     debug_mode, | ||||||
|  |     _runtime_vars, | ||||||
|  | ) | ||||||
|  | from tractor.log import get_logger | ||||||
|  | from tractor._portal import Portal | ||||||
|  | from tractor._runtime import Actor | ||||||
|  | from tractor._entry import _mp_main | ||||||
|  | from tractor._exceptions import ActorFailure | ||||||
|  | from tractor.msg.types import ( | ||||||
|  |     SpawnSpec, | ||||||
| ) | ) | ||||||
| from .log import get_logger |  | ||||||
| from ._portal import Portal |  | ||||||
| from ._runtime import Actor |  | ||||||
| from ._entry import _mp_main |  | ||||||
| from ._exceptions import ActorFailure |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|  | @ -140,11 +143,13 @@ async def exhaust_portal( | ||||||
|     ''' |     ''' | ||||||
|     __tracebackhide__ = True |     __tracebackhide__ = True | ||||||
|     try: |     try: | ||||||
|         log.debug(f"Waiting on final result from {actor.uid}") |         log.debug( | ||||||
|  |             f'Waiting on final result from {actor.uid}' | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         # XXX: streams should never be reaped here since they should |         # XXX: streams should never be reaped here since they should | ||||||
|         # always be established and shutdown using a context manager api |         # always be established and shutdown using a context manager api | ||||||
|         final = await portal.result() |         final: Any = await portal.wait_for_result() | ||||||
| 
 | 
 | ||||||
|     except ( |     except ( | ||||||
|         Exception, |         Exception, | ||||||
|  | @ -152,13 +157,23 @@ async def exhaust_portal( | ||||||
|     ) as err: |     ) as err: | ||||||
|         # we reraise in the parent task via a ``BaseExceptionGroup`` |         # we reraise in the parent task via a ``BaseExceptionGroup`` | ||||||
|         return err |         return err | ||||||
|  | 
 | ||||||
|     except trio.Cancelled as err: |     except trio.Cancelled as err: | ||||||
|         # lol, of course we need this too ;P |         # lol, of course we need this too ;P | ||||||
|         # TODO: merge with above? |         # TODO: merge with above? | ||||||
|         log.warning(f"Cancelled result waiter for {portal.actor.uid}") |         log.warning( | ||||||
|  |             'Cancelled portal result waiter task:\n' | ||||||
|  |             f'uid: {portal.channel.uid}\n' | ||||||
|  |             f'error: {err}\n' | ||||||
|  |         ) | ||||||
|         return err |         return err | ||||||
|  | 
 | ||||||
|     else: |     else: | ||||||
|         log.debug(f"Returning final result: {final}") |         log.debug( | ||||||
|  |             f'Returning final result from portal:\n' | ||||||
|  |             f'uid: {portal.channel.uid}\n' | ||||||
|  |             f'result: {final}\n' | ||||||
|  |         ) | ||||||
|         return final |         return final | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -170,41 +185,79 @@ async def cancel_on_completion( | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     ''' |     ''' | ||||||
|     Cancel actor gracefully once it's "main" portal's |     Cancel actor gracefully once its "main" portal's | ||||||
|     result arrives. |     result arrives. | ||||||
| 
 | 
 | ||||||
|     Should only be called for actors spawned with `run_in_actor()`. |     Should only be called for actors spawned via the | ||||||
|  |     `Portal.run_in_actor()` API. | ||||||
|  | 
 | ||||||
|  |     => and really this API will be deprecated and should be | ||||||
|  |     re-implemented as a `.hilevel.one_shot_task_nursery()`..) | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     # if this call errors we store the exception for later |     # if this call errors we store the exception for later | ||||||
|     # in ``errors`` which will be reraised inside |     # in ``errors`` which will be reraised inside | ||||||
|     # an exception group and we still send out a cancel request |     # an exception group and we still send out a cancel request | ||||||
|     result = await exhaust_portal(portal, actor) |     result: Any|Exception = await exhaust_portal( | ||||||
|  |         portal, | ||||||
|  |         actor, | ||||||
|  |     ) | ||||||
|     if isinstance(result, Exception): |     if isinstance(result, Exception): | ||||||
|         errors[actor.uid] = result |         errors[actor.uid]: Exception = result | ||||||
|         log.warning( |         log.cancel( | ||||||
|             f"Cancelling {portal.channel.uid} after error {result}" |             'Cancelling subactor runtime due to error:\n\n' | ||||||
|  |             f'Portal.cancel_actor() => {portal.channel.uid}\n\n' | ||||||
|  |             f'error: {result}\n' | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     else: |     else: | ||||||
|         log.runtime( |         log.runtime( | ||||||
|             f"Cancelling {portal.channel.uid} gracefully " |             'Cancelling subactor gracefully:\n\n' | ||||||
|             f"after result {result}") |             f'Portal.cancel_actor() => {portal.channel.uid}\n\n' | ||||||
|  |             f'result: {result}\n' | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     # cancel the process now that we have a final result |     # cancel the process now that we have a final result | ||||||
|     await portal.cancel_actor() |     await portal.cancel_actor() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def do_hard_kill( | async def hard_kill( | ||||||
|     proc: trio.Process, |     proc: trio.Process, | ||||||
|     terminate_after: int = 3, | 
 | ||||||
|  |     terminate_after: int = 1.6, | ||||||
|  |     # NOTE: for mucking with `.pause()`-ing inside the runtime | ||||||
|  |     # whilst also hacking on it XD | ||||||
|  |     # terminate_after: int = 99999, | ||||||
|  | 
 | ||||||
|  |     # NOTE: for mucking with `.pause()`-ing inside the runtime | ||||||
|  |     # whilst also hacking on it XD | ||||||
|  |     # terminate_after: int = 99999, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     Un-gracefully terminate an OS level `trio.Process` after timeout. | ||||||
|  | 
 | ||||||
|  |     Used in 2 main cases: | ||||||
|  | 
 | ||||||
|  |     - "unknown remote runtime state": a hanging/stalled actor that | ||||||
|  |       isn't responding after sending a (graceful) runtime cancel | ||||||
|  |       request via an IPC msg. | ||||||
|  |     - "cancelled during spawn": a process who's actor runtime was | ||||||
|  |       cancelled before full startup completed (such that | ||||||
|  |       cancel-request-handling machinery was never fully | ||||||
|  |       initialized) and thus a "cancel request msg" is never going | ||||||
|  |       to be handled. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     log.cancel( | ||||||
|  |         'Terminating sub-proc\n' | ||||||
|  |         f'>x)\n' | ||||||
|  |         f' |_{proc}\n' | ||||||
|  |     ) | ||||||
|     # NOTE: this timeout used to do nothing since we were shielding |     # NOTE: this timeout used to do nothing since we were shielding | ||||||
|     # the ``.wait()`` inside ``new_proc()`` which will pretty much |     # the ``.wait()`` inside ``new_proc()`` which will pretty much | ||||||
|     # never release until the process exits, now it acts as |     # never release until the process exits, now it acts as | ||||||
|     # a hard-kill time ultimatum. |     # a hard-kill time ultimatum. | ||||||
|     log.debug(f"Terminating {proc}") |  | ||||||
|     with trio.move_on_after(terminate_after) as cs: |     with trio.move_on_after(terminate_after) as cs: | ||||||
| 
 | 
 | ||||||
|         # NOTE: code below was copied verbatim from the now deprecated |         # NOTE: code below was copied verbatim from the now deprecated | ||||||
|  | @ -215,6 +268,9 @@ async def do_hard_kill( | ||||||
|         # and wait for it to exit. If cancelled, kills the process and |         # and wait for it to exit. If cancelled, kills the process and | ||||||
|         # waits for it to finish exiting before propagating the |         # waits for it to finish exiting before propagating the | ||||||
|         # cancellation. |         # cancellation. | ||||||
|  |         # | ||||||
|  |         # This code was originally triggred by ``proc.__aexit__()`` | ||||||
|  |         # but now must be called manually. | ||||||
|         with trio.CancelScope(shield=True): |         with trio.CancelScope(shield=True): | ||||||
|             if proc.stdin is not None: |             if proc.stdin is not None: | ||||||
|                 await proc.stdin.aclose() |                 await proc.stdin.aclose() | ||||||
|  | @ -230,16 +286,25 @@ async def do_hard_kill( | ||||||
|                 with trio.CancelScope(shield=True): |                 with trio.CancelScope(shield=True): | ||||||
|                     await proc.wait() |                     await proc.wait() | ||||||
| 
 | 
 | ||||||
|  |     # XXX NOTE XXX: zombie squad dispatch: | ||||||
|  |     # (should ideally never, but) If we do get here it means | ||||||
|  |     # graceful termination of a process failed and we need to | ||||||
|  |     # resort to OS level signalling to interrupt and cancel the | ||||||
|  |     # (presumably stalled or hung) actor. Since we never allow | ||||||
|  |     # zombies (as a feature) we ask the OS to do send in the | ||||||
|  |     # removal swad as the last resort. | ||||||
|     if cs.cancelled_caught: |     if cs.cancelled_caught: | ||||||
|         # XXX: should pretty much never get here unless we have |         # TODO: toss in the skynet-logo face as ascii art? | ||||||
|         # to move the bits from ``proc.__aexit__()`` out and |         log.critical( | ||||||
|         # into here. |             # 'Well, the #ZOMBIE_LORD_IS_HERE# to collect\n' | ||||||
|         log.critical(f"#ZOMBIE_LORD_IS_HERE: {proc}") |             '#T-800 deployed to collect zombie B0\n' | ||||||
|  |             f'>x)\n' | ||||||
|  |             f' |_{proc}\n' | ||||||
|  |         ) | ||||||
|         proc.kill() |         proc.kill() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def soft_wait( | async def soft_kill( | ||||||
| 
 |  | ||||||
|     proc: ProcessType, |     proc: ProcessType, | ||||||
|     wait_func: Callable[ |     wait_func: Callable[ | ||||||
|         [ProcessType], |         [ProcessType], | ||||||
|  | @ -248,15 +313,40 @@ async def soft_wait( | ||||||
|     portal: Portal, |     portal: Portal, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     # Wait for proc termination but **dont' yet** call |     ''' | ||||||
|     # ``trio.Process.__aexit__()`` (it tears down stdio |     Wait for proc termination but **don't yet** teardown | ||||||
|     # which will kill any waiting remote pdb trace). |     std-streams since it will clobber any ongoing pdb REPL | ||||||
|     # This is a "soft" (cancellable) join/reap. |     session. | ||||||
|     uid = portal.channel.uid | 
 | ||||||
|  |     This is our "soft"/graceful, and thus itself also cancellable, | ||||||
|  |     join/reap on an actor-runtime-in-process shutdown; it is | ||||||
|  |     **not** the same as a "hard kill" via an OS signal (for that | ||||||
|  |     see `.hard_kill()`). | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     uid: tuple[str, str] = portal.channel.uid | ||||||
|     try: |     try: | ||||||
|         log.cancel(f'Soft waiting on actor:\n{uid}') |         log.cancel( | ||||||
|  |             'Soft killing sub-actor via portal request\n' | ||||||
|  |             f'c)> {portal.chan.uid}\n' | ||||||
|  |             f' |_{proc}\n' | ||||||
|  |         ) | ||||||
|  |         # wait on sub-proc to signal termination | ||||||
|         await wait_func(proc) |         await wait_func(proc) | ||||||
|  | 
 | ||||||
|     except trio.Cancelled: |     except trio.Cancelled: | ||||||
|  |         with trio.CancelScope(shield=True): | ||||||
|  |             await maybe_wait_for_debugger( | ||||||
|  |                 child_in_debug=_runtime_vars.get( | ||||||
|  |                     '_debug_mode', False | ||||||
|  |                 ), | ||||||
|  |                 header_msg=( | ||||||
|  |                     'Delaying `soft_kill()` subproc reaper while debugger locked..\n' | ||||||
|  |                 ), | ||||||
|  |                 # TODO: need a diff value then default? | ||||||
|  |                 # poll_steps=9999999, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|         # if cancelled during a soft wait, cancel the child |         # if cancelled during a soft wait, cancel the child | ||||||
|         # actor before entering the hard reap sequence |         # actor before entering the hard reap sequence | ||||||
|         # below. This means we try to do a graceful teardown |         # below. This means we try to do a graceful teardown | ||||||
|  | @ -267,22 +357,29 @@ async def soft_wait( | ||||||
| 
 | 
 | ||||||
|             async def cancel_on_proc_deth(): |             async def cancel_on_proc_deth(): | ||||||
|                 ''' |                 ''' | ||||||
|                 Cancel the actor cancel request if we detect that |                 "Cancel-the-cancel" request: if we detect that the | ||||||
|                 that the process terminated. |                 underlying sub-process exited prior to | ||||||
|  |                 a `Portal.cancel_actor()` call completing . | ||||||
| 
 | 
 | ||||||
|                 ''' |                 ''' | ||||||
|                 await wait_func(proc) |                 await wait_func(proc) | ||||||
|                 n.cancel_scope.cancel() |                 n.cancel_scope.cancel() | ||||||
| 
 | 
 | ||||||
|  |             # start a task to wait on the termination of the | ||||||
|  |             # process by itself waiting on a (caller provided) wait | ||||||
|  |             # function which should unblock when the target process | ||||||
|  |             # has terminated. | ||||||
|             n.start_soon(cancel_on_proc_deth) |             n.start_soon(cancel_on_proc_deth) | ||||||
|  | 
 | ||||||
|  |             # send the actor-runtime a cancel request. | ||||||
|             await portal.cancel_actor() |             await portal.cancel_actor() | ||||||
| 
 | 
 | ||||||
|             if proc.poll() is None:  # type: ignore |             if proc.poll() is None:  # type: ignore | ||||||
|                 log.warning( |                 log.warning( | ||||||
|                     'Actor still alive after cancel request:\n' |                     'Subactor still alive after cancel request?\n\n' | ||||||
|                     f'{uid}' |                     f'uid: {uid}\n' | ||||||
|  |                     f'|_{proc}\n' | ||||||
|                 ) |                 ) | ||||||
| 
 |  | ||||||
|                 n.cancel_scope.cancel() |                 n.cancel_scope.cancel() | ||||||
|         raise |         raise | ||||||
| 
 | 
 | ||||||
|  | @ -294,7 +391,7 @@ async def new_proc( | ||||||
|     errors: dict[tuple[str, str], Exception], |     errors: dict[tuple[str, str], Exception], | ||||||
| 
 | 
 | ||||||
|     # passed through to actor main |     # passed through to actor main | ||||||
|     bind_addr: tuple[str, int], |     bind_addrs: list[tuple[str, int]], | ||||||
|     parent_addr: tuple[str, int], |     parent_addr: tuple[str, int], | ||||||
|     _runtime_vars: dict[str, Any],  # serialized and sent to _child |     _runtime_vars: dict[str, Any],  # serialized and sent to _child | ||||||
| 
 | 
 | ||||||
|  | @ -306,7 +403,7 @@ async def new_proc( | ||||||
| ) -> None: | ) -> None: | ||||||
| 
 | 
 | ||||||
|     # lookup backend spawning target |     # lookup backend spawning target | ||||||
|     target = _methods[_spawn_method] |     target: Callable = _methods[_spawn_method] | ||||||
| 
 | 
 | ||||||
|     # mark the new actor with the global spawn method |     # mark the new actor with the global spawn method | ||||||
|     subactor._spawn_method = _spawn_method |     subactor._spawn_method = _spawn_method | ||||||
|  | @ -316,7 +413,7 @@ async def new_proc( | ||||||
|         actor_nursery, |         actor_nursery, | ||||||
|         subactor, |         subactor, | ||||||
|         errors, |         errors, | ||||||
|         bind_addr, |         bind_addrs, | ||||||
|         parent_addr, |         parent_addr, | ||||||
|         _runtime_vars,  # run time vars |         _runtime_vars,  # run time vars | ||||||
|         infect_asyncio=infect_asyncio, |         infect_asyncio=infect_asyncio, | ||||||
|  | @ -331,7 +428,7 @@ async def trio_proc( | ||||||
|     errors: dict[tuple[str, str], Exception], |     errors: dict[tuple[str, str], Exception], | ||||||
| 
 | 
 | ||||||
|     # passed through to actor main |     # passed through to actor main | ||||||
|     bind_addr: tuple[str, int], |     bind_addrs: list[tuple[str, int]], | ||||||
|     parent_addr: tuple[str, int], |     parent_addr: tuple[str, int], | ||||||
|     _runtime_vars: dict[str, Any],  # serialized and sent to _child |     _runtime_vars: dict[str, Any],  # serialized and sent to _child | ||||||
|     *, |     *, | ||||||
|  | @ -374,19 +471,21 @@ async def trio_proc( | ||||||
|         spawn_cmd.append("--asyncio") |         spawn_cmd.append("--asyncio") | ||||||
| 
 | 
 | ||||||
|     cancelled_during_spawn: bool = False |     cancelled_during_spawn: bool = False | ||||||
|     proc: trio.Process | None = None |     proc: trio.Process|None = None | ||||||
|     try: |     try: | ||||||
|         try: |         try: | ||||||
|             # TODO: needs ``trio_typing`` patch? |             proc: trio.Process = await trio.lowlevel.open_process(spawn_cmd) | ||||||
|             proc = await trio.lowlevel.open_process(spawn_cmd) |             log.runtime( | ||||||
| 
 |                 'Started new child\n' | ||||||
|             log.runtime(f"Started {proc}") |                 f'|_{proc}\n' | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|             # wait for actor to spawn and connect back to us |             # wait for actor to spawn and connect back to us | ||||||
|             # channel should have handshake completed by the |             # channel should have handshake completed by the | ||||||
|             # local actor by the time we get a ref to it |             # local actor by the time we get a ref to it | ||||||
|             event, chan = await actor_nursery._actor.wait_for_peer( |             event, chan = await actor_nursery._actor.wait_for_peer( | ||||||
|                 subactor.uid) |                 subactor.uid | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|         except trio.Cancelled: |         except trio.Cancelled: | ||||||
|             cancelled_during_spawn = True |             cancelled_during_spawn = True | ||||||
|  | @ -415,18 +514,20 @@ async def trio_proc( | ||||||
|             portal, |             portal, | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         # send additional init params |         # send a "spawning specification" which configures the | ||||||
|         await chan.send({ |         # initial runtime state of the child. | ||||||
|             "_parent_main_data": subactor._parent_main_data, |         await chan.send( | ||||||
|             "enable_modules": subactor.enable_modules, |             SpawnSpec( | ||||||
|             "_arb_addr": subactor._arb_addr, |                 _parent_main_data=subactor._parent_main_data, | ||||||
|             "bind_host": bind_addr[0], |                 enable_modules=subactor.enable_modules, | ||||||
|             "bind_port": bind_addr[1], |                 reg_addrs=subactor.reg_addrs, | ||||||
|             "_runtime_vars": _runtime_vars, |                 bind_addrs=bind_addrs, | ||||||
|         }) |                 _runtime_vars=_runtime_vars, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         # track subactor in current nursery |         # track subactor in current nursery | ||||||
|         curr_actor = current_actor() |         curr_actor: Actor = current_actor() | ||||||
|         curr_actor._actoruid2nursery[subactor.uid] = actor_nursery |         curr_actor._actoruid2nursery[subactor.uid] = actor_nursery | ||||||
| 
 | 
 | ||||||
|         # resume caller at next checkpoint now that child is up |         # resume caller at next checkpoint now that child is up | ||||||
|  | @ -448,7 +549,7 @@ async def trio_proc( | ||||||
|             # This is a "soft" (cancellable) join/reap which |             # This is a "soft" (cancellable) join/reap which | ||||||
|             # will remote cancel the actor on a ``trio.Cancelled`` |             # will remote cancel the actor on a ``trio.Cancelled`` | ||||||
|             # condition. |             # condition. | ||||||
|             await soft_wait( |             await soft_kill( | ||||||
|                 proc, |                 proc, | ||||||
|                 trio.Process.wait, |                 trio.Process.wait, | ||||||
|                 portal |                 portal | ||||||
|  | @ -457,8 +558,10 @@ async def trio_proc( | ||||||
|             # cancel result waiter that may have been spawned in |             # cancel result waiter that may have been spawned in | ||||||
|             # tandem if not done already |             # tandem if not done already | ||||||
|             log.cancel( |             log.cancel( | ||||||
|                 "Cancelling existing result waiter task for " |                 'Cancelling portal result reaper task\n' | ||||||
|                 f"{subactor.uid}") |                 f'>c)\n' | ||||||
|  |                 f' |_{subactor.uid}\n' | ||||||
|  |             ) | ||||||
|             nursery.cancel_scope.cancel() |             nursery.cancel_scope.cancel() | ||||||
| 
 | 
 | ||||||
|     finally: |     finally: | ||||||
|  | @ -466,9 +569,13 @@ async def trio_proc( | ||||||
|         # allowed! Do this **after** cancellation/teardown to avoid |         # allowed! Do this **after** cancellation/teardown to avoid | ||||||
|         # killing the process too early. |         # killing the process too early. | ||||||
|         if proc: |         if proc: | ||||||
|             log.cancel(f'Hard reap sequence starting for {subactor.uid}') |             log.cancel( | ||||||
|             with trio.CancelScope(shield=True): |                 f'Hard reap sequence starting for subactor\n' | ||||||
|  |                 f'>x)\n' | ||||||
|  |                 f' |_{subactor}@{subactor.uid}\n' | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|  |             with trio.CancelScope(shield=True): | ||||||
|                 # don't clobber an ongoing pdb |                 # don't clobber an ongoing pdb | ||||||
|                 if cancelled_during_spawn: |                 if cancelled_during_spawn: | ||||||
|                     # Try again to avoid TTY clobbering. |                     # Try again to avoid TTY clobbering. | ||||||
|  | @ -476,7 +583,17 @@ async def trio_proc( | ||||||
|                         with trio.move_on_after(0.5): |                         with trio.move_on_after(0.5): | ||||||
|                             await proc.wait() |                             await proc.wait() | ||||||
| 
 | 
 | ||||||
|                 if is_root_process(): |                 await maybe_wait_for_debugger( | ||||||
|  |                     child_in_debug=_runtime_vars.get( | ||||||
|  |                         '_debug_mode', False | ||||||
|  |                     ), | ||||||
|  |                     header_msg=( | ||||||
|  |                         'Delaying subproc reaper while debugger locked..\n' | ||||||
|  |                     ), | ||||||
|  | 
 | ||||||
|  |                     # TODO: need a diff value then default? | ||||||
|  |                     # poll_steps=9999999, | ||||||
|  |                 ) | ||||||
|                 # TODO: solve the following issue where we need |                 # TODO: solve the following issue where we need | ||||||
|                 # to do a similar wait like this but in an |                 # to do a similar wait like this but in an | ||||||
|                 # "intermediary" parent actor that itself isn't |                 # "intermediary" parent actor that itself isn't | ||||||
|  | @ -484,14 +601,22 @@ async def trio_proc( | ||||||
|                 # to hold off on relaying SIGINT until that child |                 # to hold off on relaying SIGINT until that child | ||||||
|                 # is complete. |                 # is complete. | ||||||
|                 # https://github.com/goodboy/tractor/issues/320 |                 # https://github.com/goodboy/tractor/issues/320 | ||||||
|                     await maybe_wait_for_debugger( |                 # -[ ] we need to handle non-root parent-actors specially | ||||||
|                         child_in_debug=_runtime_vars.get( |                 # by somehow determining if a child is in debug and then | ||||||
|                             '_debug_mode', False), |                 # avoiding cancel/kill of said child by this | ||||||
|                     ) |                 # (intermediary) parent until such a time as the root says | ||||||
|  |                 # the pdb lock is released and we are good to tear down | ||||||
|  |                 # (our children).. | ||||||
|  |                 # | ||||||
|  |                 # -[ ] so maybe something like this where we try to | ||||||
|  |                 #     acquire the lock and get notified of who has it, | ||||||
|  |                 #     check that uid against our known children? | ||||||
|  |                 # this_uid: tuple[str, str] = current_actor().uid | ||||||
|  |                 # await acquire_debug_lock(this_uid) | ||||||
| 
 | 
 | ||||||
|                 if proc.poll() is None: |                 if proc.poll() is None: | ||||||
|                     log.cancel(f"Attempting to hard kill {proc}") |                     log.cancel(f"Attempting to hard kill {proc}") | ||||||
|                     await do_hard_kill(proc) |                     await hard_kill(proc) | ||||||
| 
 | 
 | ||||||
|                 log.debug(f"Joined {proc}") |                 log.debug(f"Joined {proc}") | ||||||
|         else: |         else: | ||||||
|  | @ -509,7 +634,7 @@ async def mp_proc( | ||||||
|     subactor: Actor, |     subactor: Actor, | ||||||
|     errors: dict[tuple[str, str], Exception], |     errors: dict[tuple[str, str], Exception], | ||||||
|     # passed through to actor main |     # passed through to actor main | ||||||
|     bind_addr: tuple[str, int], |     bind_addrs: list[tuple[str, int]], | ||||||
|     parent_addr: tuple[str, int], |     parent_addr: tuple[str, int], | ||||||
|     _runtime_vars: dict[str, Any],  # serialized and sent to _child |     _runtime_vars: dict[str, Any],  # serialized and sent to _child | ||||||
|     *, |     *, | ||||||
|  | @ -567,7 +692,7 @@ async def mp_proc( | ||||||
|         target=_mp_main, |         target=_mp_main, | ||||||
|         args=( |         args=( | ||||||
|             subactor, |             subactor, | ||||||
|             bind_addr, |             bind_addrs, | ||||||
|             fs_info, |             fs_info, | ||||||
|             _spawn_method, |             _spawn_method, | ||||||
|             parent_addr, |             parent_addr, | ||||||
|  | @ -635,7 +760,7 @@ async def mp_proc( | ||||||
|             # This is a "soft" (cancellable) join/reap which |             # This is a "soft" (cancellable) join/reap which | ||||||
|             # will remote cancel the actor on a ``trio.Cancelled`` |             # will remote cancel the actor on a ``trio.Cancelled`` | ||||||
|             # condition. |             # condition. | ||||||
|             await soft_wait( |             await soft_kill( | ||||||
|                 proc, |                 proc, | ||||||
|                 proc_waiter, |                 proc_waiter, | ||||||
|                 portal |                 portal | ||||||
|  |  | ||||||
|  | @ -18,27 +18,83 @@ | ||||||
| Per process state | Per process state | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
|  | from __future__ import annotations | ||||||
|  | from contextvars import ( | ||||||
|  |     ContextVar, | ||||||
|  | ) | ||||||
| from typing import ( | from typing import ( | ||||||
|     Optional, |  | ||||||
|     Any, |     Any, | ||||||
|  |     TYPE_CHECKING, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| _current_actor: Optional['Actor'] = None  # type: ignore # noqa | from trio.lowlevel import current_task | ||||||
|  | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from ._runtime import Actor | ||||||
|  |     from ._context import Context | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | _current_actor: Actor|None = None  # type: ignore # noqa | ||||||
|  | _last_actor_terminated: Actor|None = None | ||||||
|  | 
 | ||||||
|  | # TODO: mk this a `msgspec.Struct`! | ||||||
| _runtime_vars: dict[str, Any] = { | _runtime_vars: dict[str, Any] = { | ||||||
|     '_debug_mode': False, |     '_debug_mode': False, | ||||||
|     '_is_root': False, |     '_is_root': False, | ||||||
|     '_root_mailbox': (None, None) |     '_root_mailbox': (None, None), | ||||||
|  |     '_registry_addrs': [], | ||||||
|  | 
 | ||||||
|  |     '_is_infected_aio': False, | ||||||
|  | 
 | ||||||
|  |     # for `tractor.pause_from_sync()` & `breakpoint()` support | ||||||
|  |     'use_greenback': False, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def current_actor(err_on_no_runtime: bool = True) -> 'Actor':  # type: ignore # noqa | def last_actor() -> Actor|None: | ||||||
|  |     ''' | ||||||
|  |     Try to return last active `Actor` singleton | ||||||
|  |     for this process. | ||||||
|  | 
 | ||||||
|  |     For case where runtime already exited but someone is asking | ||||||
|  |     about the "last" actor probably to get its `.uid: tuple`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     return _last_actor_terminated | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def current_actor( | ||||||
|  |     err_on_no_runtime: bool = True, | ||||||
|  | ) -> Actor: | ||||||
|     ''' |     ''' | ||||||
|     Get the process-local actor instance. |     Get the process-local actor instance. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|  |     if ( | ||||||
|  |         err_on_no_runtime | ||||||
|  |         and | ||||||
|  |         _current_actor is None | ||||||
|  |     ): | ||||||
|  |         msg: str = 'No local actor has been initialized yet?\n' | ||||||
|         from ._exceptions import NoRuntime |         from ._exceptions import NoRuntime | ||||||
|     if _current_actor is None and err_on_no_runtime: | 
 | ||||||
|         raise NoRuntime("No local actor has been initialized yet") |         if last := last_actor(): | ||||||
|  |             msg += ( | ||||||
|  |                 f'Apparently the lact active actor was\n' | ||||||
|  |                 f'|_{last}\n' | ||||||
|  |                 f'|_{last.uid}\n' | ||||||
|  |             ) | ||||||
|  |         # no actor runtime has (as of yet) ever been started for | ||||||
|  |         # this process. | ||||||
|  |         else: | ||||||
|  |             msg += ( | ||||||
|  |                 # 'No last actor found?\n' | ||||||
|  |                 '\nDid you forget to call one of,\n' | ||||||
|  |                 '- `tractor.open_root_actor()`\n' | ||||||
|  |                 '- `tractor.open_nursery()`\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         raise NoRuntime(msg) | ||||||
| 
 | 
 | ||||||
|     return _current_actor |     return _current_actor | ||||||
| 
 | 
 | ||||||
|  | @ -63,3 +119,26 @@ def debug_mode() -> bool: | ||||||
| 
 | 
 | ||||||
| def is_root_process() -> bool: | def is_root_process() -> bool: | ||||||
|     return _runtime_vars['_is_root'] |     return _runtime_vars['_is_root'] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | _ctxvar_Context: ContextVar[Context] = ContextVar( | ||||||
|  |     'ipc_context', | ||||||
|  |     default=None, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def current_ipc_ctx( | ||||||
|  |     error_on_not_set: bool = False, | ||||||
|  | ) -> Context|None: | ||||||
|  |     ctx: Context = _ctxvar_Context.get() | ||||||
|  | 
 | ||||||
|  |     if ( | ||||||
|  |         not ctx | ||||||
|  |         and error_on_not_set | ||||||
|  |     ): | ||||||
|  |         from ._exceptions import InternalError | ||||||
|  |         raise InternalError( | ||||||
|  |             'No IPC context has been allocated for this task yet?\n' | ||||||
|  |             f'|_{current_task()}\n' | ||||||
|  |         ) | ||||||
|  |     return ctx | ||||||
|  |  | ||||||
|  | @ -21,10 +21,12 @@ The machinery and types behind ``Context.open_stream()`` | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
| import inspect |  | ||||||
| from contextlib import asynccontextmanager as acm | from contextlib import asynccontextmanager as acm | ||||||
|  | import inspect | ||||||
|  | from pprint import pformat | ||||||
| from typing import ( | from typing import ( | ||||||
|     Any, |     Any, | ||||||
|  |     AsyncGenerator, | ||||||
|     Callable, |     Callable, | ||||||
|     AsyncIterator, |     AsyncIterator, | ||||||
|     TYPE_CHECKING, |     TYPE_CHECKING, | ||||||
|  | @ -34,16 +36,25 @@ import warnings | ||||||
| import trio | import trio | ||||||
| 
 | 
 | ||||||
| from ._exceptions import ( | from ._exceptions import ( | ||||||
|     unpack_error, |     ContextCancelled, | ||||||
|  |     RemoteActorError, | ||||||
| ) | ) | ||||||
| from .log import get_logger | from .log import get_logger | ||||||
| from .trionics import ( | from .trionics import ( | ||||||
|     broadcast_receiver, |     broadcast_receiver, | ||||||
|     BroadcastReceiver, |     BroadcastReceiver, | ||||||
| ) | ) | ||||||
|  | from tractor.msg import ( | ||||||
|  |     # Return, | ||||||
|  |     # Stop, | ||||||
|  |     MsgType, | ||||||
|  |     Yield, | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|  |     from ._runtime import Actor | ||||||
|     from ._context import Context |     from ._context import Context | ||||||
|  |     from ._ipc import Channel | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| log = get_logger(__name__) | log = get_logger(__name__) | ||||||
|  | @ -54,14 +65,13 @@ log = get_logger(__name__) | ||||||
| #   messages? class ReceiveChannel(AsyncResource, Generic[ReceiveType]): | #   messages? class ReceiveChannel(AsyncResource, Generic[ReceiveType]): | ||||||
| # - use __slots__ on ``Context``? | # - use __slots__ on ``Context``? | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| class MsgStream(trio.abc.Channel): | class MsgStream(trio.abc.Channel): | ||||||
|     ''' |     ''' | ||||||
|     A bidirectional message stream for receiving logically sequenced |     A bidirectional message stream for receiving logically sequenced | ||||||
|     values over an inter-actor IPC ``Channel``. |     values over an inter-actor IPC `Channel`. | ||||||
| 
 | 
 | ||||||
|     This is the type returned to a local task which entered either |     This is the type returned to a local task which entered either | ||||||
|     ``Portal.open_stream_from()`` or ``Context.open_stream()``. |     `Portal.open_stream_from()` or `Context.open_stream()`. | ||||||
| 
 | 
 | ||||||
|     Termination rules: |     Termination rules: | ||||||
| 
 | 
 | ||||||
|  | @ -77,7 +87,7 @@ class MsgStream(trio.abc.Channel): | ||||||
|         self, |         self, | ||||||
|         ctx: Context,  # typing: ignore # noqa |         ctx: Context,  # typing: ignore # noqa | ||||||
|         rx_chan: trio.MemoryReceiveChannel, |         rx_chan: trio.MemoryReceiveChannel, | ||||||
|         _broadcaster: BroadcastReceiver | None = None, |         _broadcaster: BroadcastReceiver|None = None, | ||||||
| 
 | 
 | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         self._ctx = ctx |         self._ctx = ctx | ||||||
|  | @ -85,122 +95,234 @@ class MsgStream(trio.abc.Channel): | ||||||
|         self._broadcaster = _broadcaster |         self._broadcaster = _broadcaster | ||||||
| 
 | 
 | ||||||
|         # flag to denote end of stream |         # flag to denote end of stream | ||||||
|         self._eoc: bool = False |         self._eoc: bool|trio.EndOfChannel = False | ||||||
|         self._closed: bool = False |         self._closed: bool|trio.ClosedResourceError = False | ||||||
| 
 | 
 | ||||||
|     # delegate directly to underlying mem channel |     @property | ||||||
|     def receive_nowait(self): |     def ctx(self) -> Context: | ||||||
|         msg = self._rx_chan.receive_nowait() |         ''' | ||||||
|         return msg['yield'] |         A read-only ref to this stream's inter-actor-task `Context`. | ||||||
| 
 |  | ||||||
|     async def receive(self): |  | ||||||
|         '''Async receive a single msg from the IPC transport, the next |  | ||||||
|         in sequence for this stream. |  | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|  |         return self._ctx | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def chan(self) -> Channel: | ||||||
|  |         ''' | ||||||
|  |         Ref to the containing `Context`'s transport `Channel`. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         return self._ctx.chan | ||||||
|  | 
 | ||||||
|  |     # TODO: could we make this a direct method bind to `PldRx`? | ||||||
|  |     # -> receive_nowait = PldRx.recv_pld | ||||||
|  |     # |_ means latter would have to accept `MsgStream`-as-`self`? | ||||||
|  |     #  => should be fine as long as, | ||||||
|  |     #  -[ ] both define `._rx_chan` | ||||||
|  |     #  -[ ] .ctx is bound into `PldRx` using a `@cm`? | ||||||
|  |     # | ||||||
|  |     # delegate directly to underlying mem channel | ||||||
|  |     def receive_nowait( | ||||||
|  |         self, | ||||||
|  |         expect_msg: MsgType = Yield, | ||||||
|  |     ): | ||||||
|  |         ctx: Context = self._ctx | ||||||
|  |         return ctx._pld_rx.recv_pld_nowait( | ||||||
|  |             ipc=self, | ||||||
|  |             expect_msg=expect_msg, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     async def receive( | ||||||
|  |         self, | ||||||
|  | 
 | ||||||
|  |         hide_tb: bool = False, | ||||||
|  |     ): | ||||||
|  |         ''' | ||||||
|  |         Receive a single msg from the IPC transport, the next in | ||||||
|  |         sequence sent by the far end task (possibly in order as | ||||||
|  |         determined by the underlying protocol). | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|  | 
 | ||||||
|  |         # NOTE FYI: `trio.ReceiveChannel` implements EOC handling as | ||||||
|  |         # follows (aka uses it to gracefully exit async for loops): | ||||||
|  |         # | ||||||
|  |         # async def __anext__(self) -> ReceiveType: | ||||||
|  |         #     try: | ||||||
|  |         #         return await self.receive() | ||||||
|  |         #     except trio.EndOfChannel: | ||||||
|  |         #         raise StopAsyncIteration | ||||||
|  |         # | ||||||
|         # see ``.aclose()`` for notes on the old behaviour prior to |         # see ``.aclose()`` for notes on the old behaviour prior to | ||||||
|         # introducing this |         # introducing this | ||||||
|         if self._eoc: |         if self._eoc: | ||||||
|             raise trio.EndOfChannel |             raise self._eoc | ||||||
| 
 | 
 | ||||||
|         if self._closed: |         if self._closed: | ||||||
|             raise trio.ClosedResourceError('This stream was closed') |             raise self._closed | ||||||
| 
 | 
 | ||||||
|  |         src_err: Exception|None = None  # orig tb | ||||||
|         try: |         try: | ||||||
|             msg = await self._rx_chan.receive() |             ctx: Context = self._ctx | ||||||
|             return msg['yield'] |             return await ctx._pld_rx.recv_pld(ipc=self) | ||||||
| 
 | 
 | ||||||
|         except KeyError as err: |         # XXX: the stream terminates on either of: | ||||||
|             # internal error should never get here |         # - `self._rx_chan.receive()` raising  after manual closure | ||||||
|             assert msg.get('cid'), ("Received internal error at portal?") |         #   by the rpc-runtime, | ||||||
|  |         #   OR | ||||||
|  |         # - via a `Stop`-msg received from remote peer task. | ||||||
|  |         #   NOTE | ||||||
|  |         #   |_ previously this was triggered by calling | ||||||
|  |         #   ``._rx_chan.aclose()`` on the send side of the channel | ||||||
|  |         #   inside `Actor._deliver_ctx_payload()`, but now the 'stop' | ||||||
|  |         #   message handling gets delegated to `PldRFx.recv_pld()` | ||||||
|  |         #   internals. | ||||||
|  |         except trio.EndOfChannel as eoc: | ||||||
|  |             # a graceful stream finished signal | ||||||
|  |             self._eoc = eoc | ||||||
|  |             src_err = eoc | ||||||
| 
 | 
 | ||||||
|             # TODO: handle 2 cases with 3.10 match syntax |         # a `ClosedResourceError` indicates that the internal feeder | ||||||
|             # - 'stop' |         # memory receive channel was closed likely by the runtime | ||||||
|             # - 'error' |         # after the associated transport-channel disconnected or | ||||||
|             # possibly just handle msg['stop'] here! |         # broke. | ||||||
| 
 |         except trio.ClosedResourceError as cre:  # by self._rx_chan.receive() | ||||||
|             if self._closed: |             src_err = cre | ||||||
|                 raise trio.ClosedResourceError('This stream was closed') |             log.warning( | ||||||
| 
 |                 '`Context._rx_chan` was already closed?' | ||||||
|             if msg.get('stop') or self._eoc: |             ) | ||||||
|                 log.debug(f"{self} was stopped at remote end") |             self._closed = cre | ||||||
| 
 |  | ||||||
|                 # XXX: important to set so that a new ``.receive()`` |  | ||||||
|                 # call (likely by another task using a broadcast receiver) |  | ||||||
|                 # doesn't accidentally pull the ``return`` message |  | ||||||
|                 # value out of the underlying feed mem chan! |  | ||||||
|                 self._eoc = True |  | ||||||
| 
 |  | ||||||
|                 # # when the send is closed we assume the stream has |  | ||||||
|                 # # terminated and signal this local iterator to stop |  | ||||||
|                 # await self.aclose() |  | ||||||
| 
 |  | ||||||
|                 # XXX: this causes ``ReceiveChannel.__anext__()`` to |  | ||||||
|                 # raise a ``StopAsyncIteration`` **and** in our catch |  | ||||||
|                 # block below it will trigger ``.aclose()``. |  | ||||||
|                 raise trio.EndOfChannel from err |  | ||||||
| 
 |  | ||||||
|             # TODO: test that shows stream raising an expected error!!! |  | ||||||
|             elif msg.get('error'): |  | ||||||
|                 # raise the error message |  | ||||||
|                 raise unpack_error(msg, self._ctx.chan) |  | ||||||
| 
 |  | ||||||
|             else: |  | ||||||
|                 raise |  | ||||||
| 
 |  | ||||||
|         except ( |  | ||||||
|             trio.ClosedResourceError,  # by self._rx_chan |  | ||||||
|             trio.EndOfChannel,  # by self._rx_chan or `stop` msg from far end |  | ||||||
|         ): |  | ||||||
|             # XXX: we close the stream on any of these error conditions: |  | ||||||
| 
 |  | ||||||
|             # a ``ClosedResourceError`` indicates that the internal |  | ||||||
|             # feeder memory receive channel was closed likely by the |  | ||||||
|             # runtime after the associated transport-channel |  | ||||||
|             # disconnected or broke. |  | ||||||
| 
 |  | ||||||
|             # an ``EndOfChannel`` indicates either the internal recv |  | ||||||
|             # memchan exhausted **or** we raisesd it just above after |  | ||||||
|             # receiving a `stop` message from the far end of the stream. |  | ||||||
| 
 |  | ||||||
|             # Previously this was triggered by calling ``.aclose()`` on |  | ||||||
|             # the send side of the channel inside |  | ||||||
|             # ``Actor._push_result()`` (should still be commented code |  | ||||||
|             # there - which should eventually get removed), but now the |  | ||||||
|             # 'stop' message handling has been put just above. |  | ||||||
| 
 |  | ||||||
|             # TODO: Locally, we want to close this stream gracefully, by |  | ||||||
|             # terminating any local consumers tasks deterministically. |  | ||||||
|             # One we have broadcast support, we **don't** want to be |  | ||||||
|             # closing this stream and not flushing a final value to |  | ||||||
|             # remaining (clone) consumers who may not have been |  | ||||||
|             # scheduled to receive it yet. |  | ||||||
| 
 | 
 | ||||||
|         # when the send is closed we assume the stream has |         # when the send is closed we assume the stream has | ||||||
|         # terminated and signal this local iterator to stop |         # terminated and signal this local iterator to stop | ||||||
|             await self.aclose() |         drained: list[Exception|dict] = await self.aclose() | ||||||
|  |         if drained: | ||||||
|  |             # ?TODO? pass these to the `._ctx._drained_msgs: deque` | ||||||
|  |             # and then iterate them as part of any `.wait_for_result()` call? | ||||||
|  |             # | ||||||
|  |             # from .devx import pause | ||||||
|  |             # await pause() | ||||||
|  |             log.warning( | ||||||
|  |                 'Drained context msgs during closure\n\n' | ||||||
|  |                 f'{drained}' | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|             raise  # propagate |         # NOTE XXX: if the context was cancelled or remote-errored | ||||||
|  |         # but we received the stream close msg first, we | ||||||
|  |         # probably want to instead raise the remote error | ||||||
|  |         # over the end-of-stream connection error since likely | ||||||
|  |         # the remote error was the source cause? | ||||||
|  |         # ctx: Context = self._ctx | ||||||
|  |         ctx.maybe_raise( | ||||||
|  |             raise_ctxc_from_self_call=True, | ||||||
|  |             from_src_exc=src_err, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     async def aclose(self): |         # propagate any error but hide low-level frame details from | ||||||
|  |         # the caller by default for console/debug-REPL noise | ||||||
|  |         # reduction. | ||||||
|  |         if ( | ||||||
|  |             hide_tb | ||||||
|  |             and ( | ||||||
|  | 
 | ||||||
|  |                 # XXX NOTE special conditions: don't reraise on | ||||||
|  |                 # certain stream-specific internal error types like, | ||||||
|  |                 # | ||||||
|  |                 # - `trio.EoC` since we want to use the exact instance | ||||||
|  |                 #   to ensure that it is the error that bubbles upward | ||||||
|  |                 #   for silent absorption by `Context.open_stream()`. | ||||||
|  |                 not self._eoc | ||||||
|  | 
 | ||||||
|  |                 # - `RemoteActorError` (or subtypes like ctxc) | ||||||
|  |                 #    since we want to present the error as though it is | ||||||
|  |                 #    "sourced" directly from this `.receive()` call and | ||||||
|  |                 #    generally NOT include the stack frames raised from | ||||||
|  |                 #    inside the `PldRx` and/or the transport stack | ||||||
|  |                 #    layers. | ||||||
|  |                 or isinstance(src_err, RemoteActorError) | ||||||
|  |             ) | ||||||
|  |         ): | ||||||
|  |             raise type(src_err)(*src_err.args) from src_err | ||||||
|  |         else: | ||||||
|  |             # for any non-graceful-EOC we want to NOT hide this frame | ||||||
|  |             if not self._eoc: | ||||||
|  |                 __tracebackhide__: bool = False | ||||||
|  | 
 | ||||||
|  |             raise src_err | ||||||
|  | 
 | ||||||
|  |     async def aclose(self) -> list[Exception|dict]: | ||||||
|         ''' |         ''' | ||||||
|         Cancel associated remote actor task and local memory channel on |         Cancel associated remote actor task and local memory channel on | ||||||
|         close. |         close. | ||||||
| 
 | 
 | ||||||
|  |         Notes:  | ||||||
|  |          - REMEMBER that this is also called by `.__aexit__()` so | ||||||
|  |            careful consideration must be made to handle whatever | ||||||
|  |            internal stsate is mutated, particuarly in terms of | ||||||
|  |            draining IPC msgs! | ||||||
|  | 
 | ||||||
|  |          - more or less we try to maintain adherance to trio's `.aclose()` semantics: | ||||||
|  |            https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose | ||||||
|         ''' |         ''' | ||||||
|         # XXX: keep proper adherance to trio's `.aclose()` semantics: |  | ||||||
|         # https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose |  | ||||||
|         rx_chan = self._rx_chan |  | ||||||
| 
 | 
 | ||||||
|         if rx_chan._closed: |         # rx_chan = self._rx_chan | ||||||
|             log.cancel(f"{self} is already closed") |  | ||||||
| 
 | 
 | ||||||
|  |         # XXX NOTE XXX | ||||||
|  |         # it's SUPER IMPORTANT that we ensure we don't DOUBLE | ||||||
|  |         # DRAIN msgs on closure so avoid getting stuck handing on | ||||||
|  |         # the `._rx_chan` since we call this method on | ||||||
|  |         # `.__aexit__()` as well!!! | ||||||
|  |         # => SO ENSURE WE CATCH ALL TERMINATION STATES in this | ||||||
|  |         # block including the EoC.. | ||||||
|  |         if self.closed: | ||||||
|             # this stream has already been closed so silently succeed as |             # this stream has already been closed so silently succeed as | ||||||
|             # per ``trio.AsyncResource`` semantics. |             # per ``trio.AsyncResource`` semantics. | ||||||
|             # https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose |             # https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose | ||||||
|             return |             return [] | ||||||
| 
 | 
 | ||||||
|         self._eoc = True |         ctx: Context = self._ctx | ||||||
|  |         drained: list[Exception|dict] = [] | ||||||
|  |         while not drained: | ||||||
|  |             try: | ||||||
|  |                 maybe_final_msg = self.receive_nowait( | ||||||
|  |                     # allow_msgs=[Yield, Return], | ||||||
|  |                     expect_msg=Yield, | ||||||
|  |                 ) | ||||||
|  |                 if maybe_final_msg: | ||||||
|  |                     log.debug( | ||||||
|  |                         'Drained un-processed stream msg:\n' | ||||||
|  |                         f'{pformat(maybe_final_msg)}' | ||||||
|  |                     ) | ||||||
|  |                     # TODO: inject into parent `Context` buf? | ||||||
|  |                     drained.append(maybe_final_msg) | ||||||
|  | 
 | ||||||
|  |             # NOTE: we only need these handlers due to the | ||||||
|  |             # `.receive_nowait()` call above which may re-raise | ||||||
|  |             # one of these errors on a msg key error! | ||||||
|  | 
 | ||||||
|  |             except trio.WouldBlock as be: | ||||||
|  |                 drained.append(be) | ||||||
|  |                 break | ||||||
|  | 
 | ||||||
|  |             except trio.EndOfChannel as eoc: | ||||||
|  |                 self._eoc: Exception = eoc | ||||||
|  |                 drained.append(eoc) | ||||||
|  |                 break | ||||||
|  | 
 | ||||||
|  |             except trio.ClosedResourceError as cre: | ||||||
|  |                 self._closed = cre | ||||||
|  |                 drained.append(cre) | ||||||
|  |                 break | ||||||
|  | 
 | ||||||
|  |             except ContextCancelled as ctxc: | ||||||
|  |                 # log.exception('GOT CTXC') | ||||||
|  |                 log.cancel( | ||||||
|  |                     'Context was cancelled during stream closure:\n' | ||||||
|  |                     f'canceller: {ctxc.canceller}\n' | ||||||
|  |                     f'{pformat(ctxc.msgdata)}' | ||||||
|  |                 ) | ||||||
|  |                 break | ||||||
| 
 | 
 | ||||||
|         # NOTE: this is super subtle IPC messaging stuff: |         # NOTE: this is super subtle IPC messaging stuff: | ||||||
|         # Relay stop iteration to far end **iff** we're |         # Relay stop iteration to far end **iff** we're | ||||||
|  | @ -231,26 +353,41 @@ class MsgStream(trio.abc.Channel): | ||||||
|         except ( |         except ( | ||||||
|             trio.BrokenResourceError, |             trio.BrokenResourceError, | ||||||
|             trio.ClosedResourceError |             trio.ClosedResourceError | ||||||
|         ): |         ) as re: | ||||||
|             # the underlying channel may already have been pulled |             # the underlying channel may already have been pulled | ||||||
|             # in which case our stop message is meaningless since |             # in which case our stop message is meaningless since | ||||||
|             # it can't traverse the transport. |             # it can't traverse the transport. | ||||||
|             ctx = self._ctx |  | ||||||
|             log.warning( |             log.warning( | ||||||
|                 f'Stream was already destroyed?\n' |                 f'Stream was already destroyed?\n' | ||||||
|                 f'actor: {ctx.chan.uid}\n' |                 f'actor: {ctx.chan.uid}\n' | ||||||
|                 f'ctx id: {ctx.cid}' |                 f'ctx id: {ctx.cid}' | ||||||
|             ) |             ) | ||||||
|  |             drained.append(re) | ||||||
|  |             self._closed = re | ||||||
| 
 | 
 | ||||||
|         self._closed = True |         # if caught_eoc: | ||||||
|  |         #     # from .devx import _debug | ||||||
|  |         #     # await _debug.pause() | ||||||
|  |         #     with trio.CancelScope(shield=True): | ||||||
|  |         #         await rx_chan.aclose() | ||||||
| 
 | 
 | ||||||
|         # Do we close the local mem chan ``self._rx_chan`` ??!? |         if not self._eoc: | ||||||
|  |             message: str = ( | ||||||
|  |                 f'Stream self-closed by {self._ctx.side!r}-side before EoC\n' | ||||||
|  |                 # } bc a stream is a "scope"/msging-phase inside an IPC | ||||||
|  |                 f'x}}>\n' | ||||||
|  |                 f'|_{self}\n' | ||||||
|  |             ) | ||||||
|  |             log.cancel(message) | ||||||
|  |             self._eoc = trio.EndOfChannel(message) | ||||||
| 
 | 
 | ||||||
|         # NO, DEFINITELY NOT if we're a bi-dir ``MsgStream``! |         # ?XXX WAIT, why do we not close the local mem chan `._rx_chan` XXX? | ||||||
|         # BECAUSE this same core-msg-loop mem recv-chan is used to deliver |         # => NO, DEFINITELY NOT! <= | ||||||
|         # the potential final result from the surrounding inter-actor |         # if we're a bi-dir ``MsgStream`` BECAUSE this same | ||||||
|         # `Context` so we don't want to close it until that context has |         # core-msg-loop mem recv-chan is used to deliver the | ||||||
|         # run to completion. |         # potential final result from the surrounding inter-actor | ||||||
|  |         # `Context` so we don't want to close it until that | ||||||
|  |         # context has run to completion. | ||||||
| 
 | 
 | ||||||
|         # XXX: Notes on old behaviour: |         # XXX: Notes on old behaviour: | ||||||
|         # await rx_chan.aclose() |         # await rx_chan.aclose() | ||||||
|  | @ -279,6 +416,26 @@ class MsgStream(trio.abc.Channel): | ||||||
|         # runtime's closure of ``rx_chan`` in the case where we may |         # runtime's closure of ``rx_chan`` in the case where we may | ||||||
|         # still need to consume msgs that are "in transit" from the far |         # still need to consume msgs that are "in transit" from the far | ||||||
|         # end (eg. for ``Context.result()``). |         # end (eg. for ``Context.result()``). | ||||||
|  |         # self._closed = True | ||||||
|  |         return drained | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def closed(self) -> bool: | ||||||
|  | 
 | ||||||
|  |         rxc: bool = self._rx_chan._closed | ||||||
|  |         _closed: bool|Exception = self._closed | ||||||
|  |         _eoc: bool|trio.EndOfChannel = self._eoc | ||||||
|  |         if rxc or _closed or _eoc: | ||||||
|  |             log.runtime( | ||||||
|  |                 f'`MsgStream` is already closed\n' | ||||||
|  |                 f'{self}\n' | ||||||
|  |                 f' |_cid: {self._ctx.cid}\n' | ||||||
|  |                 f' |_rx_chan._closed: {type(rxc)} = {rxc}\n' | ||||||
|  |                 f' |_closed: {type(_closed)} = {_closed}\n' | ||||||
|  |                 f' |_eoc: {type(_eoc)} = {_eoc}' | ||||||
|  |             ) | ||||||
|  |             return True | ||||||
|  |         return False | ||||||
| 
 | 
 | ||||||
|     @acm |     @acm | ||||||
|     async def subscribe( |     async def subscribe( | ||||||
|  | @ -308,6 +465,9 @@ class MsgStream(trio.abc.Channel): | ||||||
|                 self, |                 self, | ||||||
|                 # use memory channel size by default |                 # use memory channel size by default | ||||||
|                 self._rx_chan._state.max_buffer_size,  # type: ignore |                 self._rx_chan._state.max_buffer_size,  # type: ignore | ||||||
|  | 
 | ||||||
|  |                 # TODO: can remove this kwarg right since | ||||||
|  |                 # by default behaviour is to do this anyway? | ||||||
|                 receive_afunc=self.receive, |                 receive_afunc=self.receive, | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|  | @ -334,19 +494,260 @@ class MsgStream(trio.abc.Channel): | ||||||
| 
 | 
 | ||||||
|     async def send( |     async def send( | ||||||
|         self, |         self, | ||||||
|         data: Any |         data: Any, | ||||||
|  | 
 | ||||||
|  |         hide_tb: bool = True, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         ''' |         ''' | ||||||
|         Send a message over this stream to the far end. |         Send a message over this stream to the far end. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         if self._ctx._remote_error: |         __tracebackhide__: bool = hide_tb | ||||||
|             raise self._ctx._remote_error  # from None | 
 | ||||||
|  |         # raise any alreay known error immediately | ||||||
|  |         self._ctx.maybe_raise() | ||||||
|  |         if self._eoc: | ||||||
|  |             raise self._eoc | ||||||
| 
 | 
 | ||||||
|         if self._closed: |         if self._closed: | ||||||
|             raise trio.ClosedResourceError('This stream was already closed') |             raise self._closed | ||||||
|  | 
 | ||||||
|  |         try: | ||||||
|  |             await self._ctx.chan.send( | ||||||
|  |                 payload=Yield( | ||||||
|  |                     cid=self._ctx.cid, | ||||||
|  |                     pld=data, | ||||||
|  |                 ), | ||||||
|  |             ) | ||||||
|  |         except ( | ||||||
|  |             trio.ClosedResourceError, | ||||||
|  |             trio.BrokenResourceError, | ||||||
|  |             BrokenPipeError, | ||||||
|  |         ) as trans_err: | ||||||
|  |             if hide_tb: | ||||||
|  |                 raise type(trans_err)( | ||||||
|  |                     *trans_err.args | ||||||
|  |                 ) from trans_err | ||||||
|  |             else: | ||||||
|  |                 raise | ||||||
|  | 
 | ||||||
|  |     # TODO: msg capability context api1 | ||||||
|  |     # @acm | ||||||
|  |     # async def enable_msg_caps( | ||||||
|  |     #     self, | ||||||
|  |     #     msg_subtypes: Union[ | ||||||
|  |     #         list[list[Struct]], | ||||||
|  |     #         Protocol,   # hypothetical type that wraps a msg set | ||||||
|  |     #     ], | ||||||
|  |     # ) -> tuple[Callable, Callable]:  # payload enc, dec pair | ||||||
|  |     #     ... | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @acm | ||||||
|  | async def open_stream_from_ctx( | ||||||
|  |     ctx: Context, | ||||||
|  |     allow_overruns: bool|None = False, | ||||||
|  |     msg_buffer_size: int|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> AsyncGenerator[MsgStream, None]: | ||||||
|  |     ''' | ||||||
|  |     Open a `MsgStream`, a bi-directional msg transport dialog | ||||||
|  |     connected to the cross-actor peer task for an IPC `Context`. | ||||||
|  | 
 | ||||||
|  |     This context manager must be entered in both the "parent" (task | ||||||
|  |     which entered `Portal.open_context()`) and "child" (RPC task | ||||||
|  |     which is decorated by `@context`) tasks for the stream to | ||||||
|  |     logically be considered "open"; if one side begins sending to an | ||||||
|  |     un-opened peer, depending on policy config, msgs will either be | ||||||
|  |     queued until the other side opens and/or a `StreamOverrun` will | ||||||
|  |     (eventually) be raised. | ||||||
|  | 
 | ||||||
|  |                          ------ - ------ | ||||||
|  | 
 | ||||||
|  |     Runtime semantics design: | ||||||
|  | 
 | ||||||
|  |     A `MsgStream` session adheres to "one-shot use" semantics, | ||||||
|  |     meaning if you close the scope it **can not** be "re-opened". | ||||||
|  | 
 | ||||||
|  |     Instead you must re-establish a new surrounding RPC `Context` | ||||||
|  |     (RTC: remote task context?) using `Portal.open_context()`. | ||||||
|  | 
 | ||||||
|  |     In the future this *design choice* may need to be changed but | ||||||
|  |     currently there seems to be no obvious reason to support such | ||||||
|  |     semantics.. | ||||||
|  | 
 | ||||||
|  |     - "pausing a stream" can be supported with a message implemented | ||||||
|  |       by the `tractor` application dev. | ||||||
|  | 
 | ||||||
|  |     - any remote error will normally require a restart of the entire | ||||||
|  |       `trio.Task`'s scope due to the nature of `trio`'s cancellation | ||||||
|  |       (`CancelScope`) system and semantics (level triggered). | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     actor: Actor = ctx._actor | ||||||
|  | 
 | ||||||
|  |     # If the surrounding context has been cancelled by some | ||||||
|  |     # task with a handle to THIS, we error here immediately | ||||||
|  |     # since it likely means the surrounding lexical-scope has | ||||||
|  |     # errored, been `trio.Cancelled` or at the least | ||||||
|  |     # `Context.cancel()` was called by some task. | ||||||
|  |     if ctx._cancel_called: | ||||||
|  | 
 | ||||||
|  |         # XXX NOTE: ALWAYS RAISE any remote error here even if | ||||||
|  |         # it's an expected `ContextCancelled` due to a local | ||||||
|  |         # task having called `.cancel()`! | ||||||
|  |         # | ||||||
|  |         # WHY: we expect the error to always bubble up to the | ||||||
|  |         # surrounding `Portal.open_context()` call and be | ||||||
|  |         # absorbed there (silently) and we DO NOT want to | ||||||
|  |         # actually try to stream - a cancel msg was already | ||||||
|  |         # sent to the other side! | ||||||
|  |         ctx.maybe_raise( | ||||||
|  |             raise_ctxc_from_self_call=True, | ||||||
|  |         ) | ||||||
|  |         # NOTE: this is diff then calling | ||||||
|  |         # `._maybe_raise_remote_err()` specifically | ||||||
|  |         # because we want to raise a ctxc on any task entering this `.open_stream()` | ||||||
|  |         # AFTER cancellation was already been requested, | ||||||
|  |         # we DO NOT want to absorb any ctxc ACK silently! | ||||||
|  |         # if ctx._remote_error: | ||||||
|  |         #     raise ctx._remote_error | ||||||
|  | 
 | ||||||
|  |         # XXX NOTE: if no `ContextCancelled` has been responded | ||||||
|  |         # back from the other side (yet), we raise a different | ||||||
|  |         # runtime error indicating that this task's usage of | ||||||
|  |         # `Context.cancel()` and then `.open_stream()` is WRONG! | ||||||
|  |         task: str = trio.lowlevel.current_task().name | ||||||
|  |         raise RuntimeError( | ||||||
|  |             'Stream opened after `Context.cancel()` called..?\n' | ||||||
|  |             f'task: {actor.uid[0]}:{task}\n' | ||||||
|  |             f'{ctx}' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     if ( | ||||||
|  |         not ctx._portal | ||||||
|  |         and not ctx._started_called | ||||||
|  |     ): | ||||||
|  |         raise RuntimeError( | ||||||
|  |             'Context.started()` must be called before opening a stream' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # NOTE: in one way streaming this only happens on the | ||||||
|  |     # parent-ctx-task side (on the side that calls | ||||||
|  |     # `Actor.start_remote_task()`) so if you try to send | ||||||
|  |     # a stop from the caller to the callee in the | ||||||
|  |     # single-direction-stream case you'll get a lookup error | ||||||
|  |     # currently. | ||||||
|  |     ctx: Context = actor.get_context( | ||||||
|  |         chan=ctx.chan, | ||||||
|  |         cid=ctx.cid, | ||||||
|  |         nsf=ctx._nsf, | ||||||
|  |         # side=ctx.side, | ||||||
|  | 
 | ||||||
|  |         msg_buffer_size=msg_buffer_size, | ||||||
|  |         allow_overruns=allow_overruns, | ||||||
|  |     ) | ||||||
|  |     ctx._allow_overruns: bool = allow_overruns | ||||||
|  |     assert ctx is ctx | ||||||
|  | 
 | ||||||
|  |     # XXX: If the underlying channel feeder receive mem chan has | ||||||
|  |     # been closed then likely client code has already exited | ||||||
|  |     # a ``.open_stream()`` block prior or there was some other | ||||||
|  |     # unanticipated error or cancellation from ``trio``. | ||||||
|  | 
 | ||||||
|  |     if ctx._rx_chan._closed: | ||||||
|  |         raise trio.ClosedResourceError( | ||||||
|  |             'The underlying channel for this stream was already closed!\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # NOTE: implicitly this will call `MsgStream.aclose()` on | ||||||
|  |     # `.__aexit__()` due to stream's parent `Channel` type! | ||||||
|  |     # | ||||||
|  |     # XXX NOTE XXX: ensures the stream is "one-shot use", | ||||||
|  |     # which specifically means that on exit, | ||||||
|  |     # - signal ``trio.EndOfChannel``/``StopAsyncIteration`` to | ||||||
|  |     #   the far end indicating that the caller exited | ||||||
|  |     #   the streaming context purposefully by letting | ||||||
|  |     #   the exit block exec. | ||||||
|  |     # - this is diff from the cancel/error case where | ||||||
|  |     #   a cancel request from this side or an error | ||||||
|  |     #   should be sent to the far end indicating the | ||||||
|  |     #   stream WAS NOT just closed normally/gracefully. | ||||||
|  |     async with MsgStream( | ||||||
|  |         ctx=ctx, | ||||||
|  |         rx_chan=ctx._rx_chan, | ||||||
|  |     ) as stream: | ||||||
|  | 
 | ||||||
|  |         # NOTE: we track all existing streams per portal for | ||||||
|  |         # the purposes of attempting graceful closes on runtime | ||||||
|  |         # cancel requests. | ||||||
|  |         if ctx._portal: | ||||||
|  |             ctx._portal._streams.add(stream) | ||||||
|  | 
 | ||||||
|  |         try: | ||||||
|  |             ctx._stream_opened: bool = True | ||||||
|  |             ctx._stream = stream | ||||||
|  | 
 | ||||||
|  |             # XXX: do we need this? | ||||||
|  |             # ensure we aren't cancelled before yielding the stream | ||||||
|  |             # await trio.lowlevel.checkpoint() | ||||||
|  |             yield stream | ||||||
|  | 
 | ||||||
|  |             # XXX: (MEGA IMPORTANT) if this is a root opened process we | ||||||
|  |             # wait for any immediate child in debug before popping the | ||||||
|  |             # context from the runtime msg loop otherwise inside | ||||||
|  |             # ``Actor._deliver_ctx_payload()`` the msg will be discarded and in | ||||||
|  |             # the case where that msg is global debugger unlock (via | ||||||
|  |             # a "stop" msg for a stream), this can result in a deadlock | ||||||
|  |             # where the root is waiting on the lock to clear but the | ||||||
|  |             # child has already cleared it and clobbered IPC. | ||||||
|  |             # | ||||||
|  |             # await maybe_wait_for_debugger() | ||||||
|  | 
 | ||||||
|  |             # XXX TODO: pretty sure this isn't needed (see | ||||||
|  |             # note above this block) AND will result in | ||||||
|  |             # a double `.send_stop()` call. The only reason to | ||||||
|  |             # put it here would be to due with "order" in | ||||||
|  |             # terms of raising any remote error (as per | ||||||
|  |             # directly below) or bc the stream's | ||||||
|  |             # `.__aexit__()` block might not get run | ||||||
|  |             # (doubtful)? Either way if we did put this back | ||||||
|  |             # in we also need a state var to avoid the double | ||||||
|  |             # stop-msg send.. | ||||||
|  |             # | ||||||
|  |             # await stream.aclose() | ||||||
|  | 
 | ||||||
|  |         # NOTE: absorb and do not raise any | ||||||
|  |         # EoC received from the other side such that | ||||||
|  |         # it is not raised inside the surrounding | ||||||
|  |         # context block's scope! | ||||||
|  |         except trio.EndOfChannel as eoc: | ||||||
|  |             if ( | ||||||
|  |                 eoc | ||||||
|  |                 and | ||||||
|  |                 stream.closed | ||||||
|  |             ): | ||||||
|  |                 # sanity, can remove? | ||||||
|  |                 assert eoc is stream._eoc | ||||||
|  | 
 | ||||||
|  |                 log.warning( | ||||||
|  |                     'Stream was terminated by EoC\n\n' | ||||||
|  |                     # NOTE: won't show the error <Type> but | ||||||
|  |                     # does show txt followed by IPC msg. | ||||||
|  |                     f'{str(eoc)}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         finally: | ||||||
|  |             if ctx._portal: | ||||||
|  |                 try: | ||||||
|  |                     ctx._portal._streams.remove(stream) | ||||||
|  |                 except KeyError: | ||||||
|  |                     log.warning( | ||||||
|  |                         f'Stream was already destroyed?\n' | ||||||
|  |                         f'actor: {ctx.chan.uid}\n' | ||||||
|  |                         f'ctx id: {ctx.cid}' | ||||||
|  |                     ) | ||||||
| 
 | 
 | ||||||
|         await self._ctx.chan.send({'yield': data, 'cid': self._ctx.cid}) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def stream(func: Callable) -> Callable: | def stream(func: Callable) -> Callable: | ||||||
|  | @ -356,7 +757,7 @@ def stream(func: Callable) -> Callable: | ||||||
|     ''' |     ''' | ||||||
|     # TODO: apply whatever solution ``mypy`` ends up picking for this: |     # TODO: apply whatever solution ``mypy`` ends up picking for this: | ||||||
|     # https://github.com/python/mypy/issues/2087#issuecomment-769266912 |     # https://github.com/python/mypy/issues/2087#issuecomment-769266912 | ||||||
|     func._tractor_stream_function = True  # type: ignore |     func._tractor_stream_function: bool = True  # type: ignore | ||||||
| 
 | 
 | ||||||
|     sig = inspect.signature(func) |     sig = inspect.signature(func) | ||||||
|     params = sig.parameters |     params = sig.parameters | ||||||
|  |  | ||||||
|  | @ -21,22 +21,22 @@ | ||||||
| from contextlib import asynccontextmanager as acm | from contextlib import asynccontextmanager as acm | ||||||
| from functools import partial | from functools import partial | ||||||
| import inspect | import inspect | ||||||
| from typing import ( | from pprint import pformat | ||||||
|     Optional, | from typing import TYPE_CHECKING | ||||||
|     TYPE_CHECKING, |  | ||||||
| ) |  | ||||||
| import typing | import typing | ||||||
| import warnings | import warnings | ||||||
| 
 | 
 | ||||||
| from exceptiongroup import BaseExceptionGroup |  | ||||||
| import trio | import trio | ||||||
| 
 | 
 | ||||||
| from ._debug import maybe_wait_for_debugger | from .devx._debug import maybe_wait_for_debugger | ||||||
| from ._state import current_actor, is_main_process | from ._state import current_actor, is_main_process | ||||||
| from .log import get_logger, get_loglevel | from .log import get_logger, get_loglevel | ||||||
| from ._runtime import Actor | from ._runtime import Actor | ||||||
| from ._portal import Portal | from ._portal import Portal | ||||||
| from ._exceptions import is_multi_cancelled | from ._exceptions import ( | ||||||
|  |     is_multi_cancelled, | ||||||
|  |     ContextCancelled, | ||||||
|  | ) | ||||||
| from ._root import open_root_actor | from ._root import open_root_actor | ||||||
| from . import _state | from . import _state | ||||||
| from . import _spawn | from . import _spawn | ||||||
|  | @ -80,54 +80,85 @@ class ActorNursery: | ||||||
|     ''' |     ''' | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
|  |         # TODO: maybe def these as fields of a struct looking type? | ||||||
|         actor: Actor, |         actor: Actor, | ||||||
|         ria_nursery: trio.Nursery, |         ria_nursery: trio.Nursery, | ||||||
|         da_nursery: trio.Nursery, |         da_nursery: trio.Nursery, | ||||||
|         errors: dict[tuple[str, str], BaseException], |         errors: dict[tuple[str, str], BaseException], | ||||||
|  | 
 | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         # self.supervisor = supervisor  # TODO |         # self.supervisor = supervisor  # TODO | ||||||
|         self._actor: Actor = actor |         self._actor: Actor = actor | ||||||
|         self._ria_nursery = ria_nursery | 
 | ||||||
|  |         # TODO: rename to `._tn` for our conventional "task-nursery" | ||||||
|         self._da_nursery = da_nursery |         self._da_nursery = da_nursery | ||||||
|  | 
 | ||||||
|         self._children: dict[ |         self._children: dict[ | ||||||
|             tuple[str, str], |             tuple[str, str], | ||||||
|             tuple[ |             tuple[ | ||||||
|                 Actor, |                 Actor, | ||||||
|                 trio.Process | mp.Process, |                 trio.Process | mp.Process, | ||||||
|                 Optional[Portal], |                 Portal | None, | ||||||
|             ] |             ] | ||||||
|         ] = {} |         ] = {} | ||||||
|         # portals spawned with ``run_in_actor()`` are | 
 | ||||||
|         # cancelled when their "main" result arrives |  | ||||||
|         self._cancel_after_result_on_exit: set = set() |  | ||||||
|         self.cancelled: bool = False |         self.cancelled: bool = False | ||||||
|         self._join_procs = trio.Event() |         self._join_procs = trio.Event() | ||||||
|         self._at_least_one_child_in_debug: bool = False |         self._at_least_one_child_in_debug: bool = False | ||||||
|         self.errors = errors |         self.errors = errors | ||||||
|  |         self._scope_error: BaseException|None = None | ||||||
|         self.exited = trio.Event() |         self.exited = trio.Event() | ||||||
| 
 | 
 | ||||||
|  |         # NOTE: when no explicit call is made to | ||||||
|  |         # `.open_root_actor()` by application code, | ||||||
|  |         # `.open_nursery()` will implicitly call it to start the | ||||||
|  |         # actor-tree runtime. In this case we mark ourselves as | ||||||
|  |         # such so that runtime components can be aware for logging | ||||||
|  |         # and syncing purposes to any actor opened nurseries. | ||||||
|  |         self._implicit_runtime_started: bool = False | ||||||
|  | 
 | ||||||
|  |         # TODO: remove the `.run_in_actor()` API and thus this 2ndary | ||||||
|  |         # nursery when that API get's moved outside this primitive! | ||||||
|  |         self._ria_nursery = ria_nursery | ||||||
|  |         # portals spawned with ``run_in_actor()`` are | ||||||
|  |         # cancelled when their "main" result arrives | ||||||
|  |         self._cancel_after_result_on_exit: set = set() | ||||||
|  | 
 | ||||||
|     async def start_actor( |     async def start_actor( | ||||||
|         self, |         self, | ||||||
|         name: str, |         name: str, | ||||||
|  | 
 | ||||||
|         *, |         *, | ||||||
|         bind_addr: tuple[str, int] = _default_bind_addr, | 
 | ||||||
|         rpc_module_paths: list[str] | None = None, |         bind_addrs: list[tuple[str, int]] = [_default_bind_addr], | ||||||
|         enable_modules: list[str] | None = None, |         rpc_module_paths: list[str]|None = None, | ||||||
|         loglevel: str | None = None,  # set log level per subactor |         enable_modules: list[str]|None = None, | ||||||
|         nursery: trio.Nursery | None = None, |         loglevel: str|None = None,  # set log level per subactor | ||||||
|         debug_mode: Optional[bool] | None = None, |         debug_mode: bool|None = None, | ||||||
|         infect_asyncio: bool = False, |         infect_asyncio: bool = False, | ||||||
|  | 
 | ||||||
|  |         # TODO: ideally we can rm this once we no longer have | ||||||
|  |         # a `._ria_nursery` since the dependent APIs have been | ||||||
|  |         # removed! | ||||||
|  |         nursery: trio.Nursery|None = None, | ||||||
|  | 
 | ||||||
|     ) -> Portal: |     ) -> Portal: | ||||||
|         ''' |         ''' | ||||||
|         Start a (daemon) actor: an process that has no designated |         Start a (daemon) actor: an process that has no designated | ||||||
|         "main task" besides the runtime. |         "main task" besides the runtime. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         loglevel = loglevel or self._actor.loglevel or get_loglevel() |         __runtimeframe__: int = 1  # noqa | ||||||
|  |         loglevel: str = ( | ||||||
|  |             loglevel | ||||||
|  |             or self._actor.loglevel | ||||||
|  |             or get_loglevel() | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         # configure and pass runtime state |         # configure and pass runtime state | ||||||
|         _rtv = _state._runtime_vars.copy() |         _rtv = _state._runtime_vars.copy() | ||||||
|         _rtv['_is_root'] = False |         _rtv['_is_root'] = False | ||||||
|  |         _rtv['_is_infected_aio'] = infect_asyncio | ||||||
| 
 | 
 | ||||||
|         # allow setting debug policy per actor |         # allow setting debug policy per actor | ||||||
|         if debug_mode is not None: |         if debug_mode is not None: | ||||||
|  | @ -150,14 +181,16 @@ class ActorNursery: | ||||||
|             # modules allowed to invoked funcs from |             # modules allowed to invoked funcs from | ||||||
|             enable_modules=enable_modules, |             enable_modules=enable_modules, | ||||||
|             loglevel=loglevel, |             loglevel=loglevel, | ||||||
|             arbiter_addr=current_actor()._arb_addr, | 
 | ||||||
|  |             # verbatim relay this actor's registrar addresses | ||||||
|  |             registry_addrs=current_actor().reg_addrs, | ||||||
|         ) |         ) | ||||||
|         parent_addr = self._actor.accept_addr |         parent_addr = self._actor.accept_addr | ||||||
|         assert parent_addr |         assert parent_addr | ||||||
| 
 | 
 | ||||||
|         # start a task to spawn a process |         # start a task to spawn a process | ||||||
|         # blocks until process has been started and a portal setup |         # blocks until process has been started and a portal setup | ||||||
|         nursery = nursery or self._da_nursery |         nursery: trio.Nursery = nursery or self._da_nursery | ||||||
| 
 | 
 | ||||||
|         # XXX: the type ignore is actually due to a `mypy` bug |         # XXX: the type ignore is actually due to a `mypy` bug | ||||||
|         return await nursery.start(  # type: ignore |         return await nursery.start(  # type: ignore | ||||||
|  | @ -167,21 +200,29 @@ class ActorNursery: | ||||||
|                 self, |                 self, | ||||||
|                 subactor, |                 subactor, | ||||||
|                 self.errors, |                 self.errors, | ||||||
|                 bind_addr, |                 bind_addrs, | ||||||
|                 parent_addr, |                 parent_addr, | ||||||
|                 _rtv,  # run time vars |                 _rtv,  # run time vars | ||||||
|                 infect_asyncio=infect_asyncio, |                 infect_asyncio=infect_asyncio, | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |     # TODO: DEPRECATE THIS: | ||||||
|  |     # -[ ] impl instead as a hilevel wrapper on | ||||||
|  |     #   top of a `@context` style invocation. | ||||||
|  |     #  |_ dynamic @context decoration on child side | ||||||
|  |     #  |_ implicit `Portal.open_context() as (ctx, first):` | ||||||
|  |     #    and `return first` on parent side. | ||||||
|  |     #  |_ mention how it's similar to `trio-parallel` API? | ||||||
|  |     # -[ ] use @api_frame on the wrapper | ||||||
|     async def run_in_actor( |     async def run_in_actor( | ||||||
|         self, |         self, | ||||||
| 
 | 
 | ||||||
|         fn: typing.Callable, |         fn: typing.Callable, | ||||||
|         *, |         *, | ||||||
| 
 | 
 | ||||||
|         name: Optional[str] = None, |         name: str | None = None, | ||||||
|         bind_addr: tuple[str, int] = _default_bind_addr, |         bind_addrs: tuple[str, int] = [_default_bind_addr], | ||||||
|         rpc_module_paths: list[str] | None = None, |         rpc_module_paths: list[str] | None = None, | ||||||
|         enable_modules: list[str] | None = None, |         enable_modules: list[str] | None = None, | ||||||
|         loglevel: str | None = None,  # set log level per subactor |         loglevel: str | None = None,  # set log level per subactor | ||||||
|  | @ -190,25 +231,28 @@ class ActorNursery: | ||||||
|         **kwargs,  # explicit args to ``fn`` |         **kwargs,  # explicit args to ``fn`` | ||||||
| 
 | 
 | ||||||
|     ) -> Portal: |     ) -> Portal: | ||||||
|         """Spawn a new actor, run a lone task, then terminate the actor and |         ''' | ||||||
|  |         Spawn a new actor, run a lone task, then terminate the actor and | ||||||
|         return its result. |         return its result. | ||||||
| 
 | 
 | ||||||
|         Actors spawned using this method are kept alive at nursery teardown |         Actors spawned using this method are kept alive at nursery teardown | ||||||
|         until the task spawned by executing ``fn`` completes at which point |         until the task spawned by executing ``fn`` completes at which point | ||||||
|         the actor is terminated. |         the actor is terminated. | ||||||
|         """ | 
 | ||||||
|         mod_path = fn.__module__ |         ''' | ||||||
|  |         __runtimeframe__: int = 1  # noqa | ||||||
|  |         mod_path: str = fn.__module__ | ||||||
| 
 | 
 | ||||||
|         if name is None: |         if name is None: | ||||||
|             # use the explicit function name if not provided |             # use the explicit function name if not provided | ||||||
|             name = fn.__name__ |             name = fn.__name__ | ||||||
| 
 | 
 | ||||||
|         portal = await self.start_actor( |         portal: Portal = await self.start_actor( | ||||||
|             name, |             name, | ||||||
|             enable_modules=[mod_path] + ( |             enable_modules=[mod_path] + ( | ||||||
|                 enable_modules or rpc_module_paths or [] |                 enable_modules or rpc_module_paths or [] | ||||||
|             ), |             ), | ||||||
|             bind_addr=bind_addr, |             bind_addrs=bind_addrs, | ||||||
|             loglevel=loglevel, |             loglevel=loglevel, | ||||||
|             # use the run_in_actor nursery |             # use the run_in_actor nursery | ||||||
|             nursery=self._ria_nursery, |             nursery=self._ria_nursery, | ||||||
|  | @ -232,21 +276,42 @@ class ActorNursery: | ||||||
|         ) |         ) | ||||||
|         return portal |         return portal | ||||||
| 
 | 
 | ||||||
|     async def cancel(self, hard_kill: bool = False) -> None: |     # @api_frame | ||||||
|         """Cancel this nursery by instructing each subactor to cancel |     async def cancel( | ||||||
|         itself and wait for all subactors to terminate. |         self, | ||||||
|  |         hard_kill: bool = False, | ||||||
| 
 | 
 | ||||||
|         If ``hard_killl`` is set to ``True`` then kill the processes |     ) -> None: | ||||||
|         directly without any far end graceful ``trio`` cancellation. |         ''' | ||||||
|         """ |         Cancel this actor-nursery by instructing each subactor's | ||||||
|  |         runtime to cancel and wait for all underlying sub-processes | ||||||
|  |         to terminate. | ||||||
|  | 
 | ||||||
|  |         If `hard_kill` is set then kill the processes directly using | ||||||
|  |         the spawning-backend's API/OS-machinery without any attempt | ||||||
|  |         at (graceful) `trio`-style cancellation using our | ||||||
|  |         `Actor.cancel()`. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         __runtimeframe__: int = 1  # noqa | ||||||
|         self.cancelled = True |         self.cancelled = True | ||||||
| 
 | 
 | ||||||
|         log.cancel(f"Cancelling nursery in {self._actor.uid}") |         # TODO: impl a repr for spawn more compact | ||||||
|  |         # then `._children`.. | ||||||
|  |         children: dict = self._children | ||||||
|  |         child_count: int = len(children) | ||||||
|  |         msg: str = f'Cancelling actor nursery with {child_count} children\n' | ||||||
|         with trio.move_on_after(3) as cs: |         with trio.move_on_after(3) as cs: | ||||||
|  |             async with trio.open_nursery() as tn: | ||||||
| 
 | 
 | ||||||
|             async with trio.open_nursery() as nursery: |                 subactor: Actor | ||||||
| 
 |                 proc: trio.Process | ||||||
|                 for subactor, proc, portal in self._children.values(): |                 portal: Portal | ||||||
|  |                 for ( | ||||||
|  |                     subactor, | ||||||
|  |                     proc, | ||||||
|  |                     portal, | ||||||
|  |                 ) in children.values(): | ||||||
| 
 | 
 | ||||||
|                     # TODO: are we ever even going to use this or |                     # TODO: are we ever even going to use this or | ||||||
|                     # is the spawning backend responsible for such |                     # is the spawning backend responsible for such | ||||||
|  | @ -258,12 +323,13 @@ class ActorNursery: | ||||||
|                         if portal is None:  # actor hasn't fully spawned yet |                         if portal is None:  # actor hasn't fully spawned yet | ||||||
|                             event = self._actor._peer_connected[subactor.uid] |                             event = self._actor._peer_connected[subactor.uid] | ||||||
|                             log.warning( |                             log.warning( | ||||||
|                                 f"{subactor.uid} wasn't finished spawning?") |                                 f"{subactor.uid} never 't finished spawning?" | ||||||
|  |                             ) | ||||||
| 
 | 
 | ||||||
|                             await event.wait() |                             await event.wait() | ||||||
| 
 | 
 | ||||||
|                             # channel/portal should now be up |                             # channel/portal should now be up | ||||||
|                             _, _, portal = self._children[subactor.uid] |                             _, _, portal = children[subactor.uid] | ||||||
| 
 | 
 | ||||||
|                             # XXX should be impossible to get here |                             # XXX should be impossible to get here | ||||||
|                             # unless method was called from within |                             # unless method was called from within | ||||||
|  | @ -280,14 +346,24 @@ class ActorNursery: | ||||||
|                         # spawn cancel tasks for each sub-actor |                         # spawn cancel tasks for each sub-actor | ||||||
|                         assert portal |                         assert portal | ||||||
|                         if portal.channel.connected(): |                         if portal.channel.connected(): | ||||||
|                             nursery.start_soon(portal.cancel_actor) |                             tn.start_soon(portal.cancel_actor) | ||||||
| 
 | 
 | ||||||
|  |                 log.cancel(msg) | ||||||
|         # if we cancelled the cancel (we hung cancelling remote actors) |         # if we cancelled the cancel (we hung cancelling remote actors) | ||||||
|         # then hard kill all sub-processes |         # then hard kill all sub-processes | ||||||
|         if cs.cancelled_caught: |         if cs.cancelled_caught: | ||||||
|             log.error( |             log.error( | ||||||
|                 f"Failed to cancel {self}\nHard killing process tree!") |                 f'Failed to cancel {self}?\n' | ||||||
|             for subactor, proc, portal in self._children.values(): |                 'Hard killing underlying subprocess tree!\n' | ||||||
|  |             ) | ||||||
|  |             subactor: Actor | ||||||
|  |             proc: trio.Process | ||||||
|  |             portal: Portal | ||||||
|  |             for ( | ||||||
|  |                 subactor, | ||||||
|  |                 proc, | ||||||
|  |                 portal, | ||||||
|  |             ) in children.values(): | ||||||
|                 log.warning(f"Hard killing process {proc}") |                 log.warning(f"Hard killing process {proc}") | ||||||
|                 proc.terminate() |                 proc.terminate() | ||||||
| 
 | 
 | ||||||
|  | @ -298,11 +374,15 @@ class ActorNursery: | ||||||
| @acm | @acm | ||||||
| async def _open_and_supervise_one_cancels_all_nursery( | async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|     actor: Actor, |     actor: Actor, | ||||||
|  |     tb_hide: bool = False, | ||||||
| 
 | 
 | ||||||
| ) -> typing.AsyncGenerator[ActorNursery, None]: | ) -> typing.AsyncGenerator[ActorNursery, None]: | ||||||
| 
 | 
 | ||||||
|     # TODO: yay or nay? |     # normally don't need to show user by default | ||||||
|     __tracebackhide__ = True |     __tracebackhide__: bool = tb_hide | ||||||
|  | 
 | ||||||
|  |     outer_err: BaseException|None = None | ||||||
|  |     inner_err: BaseException|None = None | ||||||
| 
 | 
 | ||||||
|     # the collection of errors retreived from spawned sub-actors |     # the collection of errors retreived from spawned sub-actors | ||||||
|     errors: dict[tuple[str, str], BaseException] = {} |     errors: dict[tuple[str, str], BaseException] = {} | ||||||
|  | @ -312,7 +392,7 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|     # handling errors that are generated by the inner nursery in |     # handling errors that are generated by the inner nursery in | ||||||
|     # a supervisor strategy **before** blocking indefinitely to wait for |     # a supervisor strategy **before** blocking indefinitely to wait for | ||||||
|     # actors spawned in "daemon mode" (aka started using |     # actors spawned in "daemon mode" (aka started using | ||||||
|     # ``ActorNursery.start_actor()``). |     # `ActorNursery.start_actor()`). | ||||||
| 
 | 
 | ||||||
|     # errors from this daemon actor nursery bubble up to caller |     # errors from this daemon actor nursery bubble up to caller | ||||||
|     async with trio.open_nursery() as da_nursery: |     async with trio.open_nursery() as da_nursery: | ||||||
|  | @ -327,7 +407,7 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|             # the above "daemon actor" nursery will be notified. |             # the above "daemon actor" nursery will be notified. | ||||||
|             async with trio.open_nursery() as ria_nursery: |             async with trio.open_nursery() as ria_nursery: | ||||||
| 
 | 
 | ||||||
|                 anursery = ActorNursery( |                 an = ActorNursery( | ||||||
|                     actor, |                     actor, | ||||||
|                     ria_nursery, |                     ria_nursery, | ||||||
|                     da_nursery, |                     da_nursery, | ||||||
|  | @ -336,18 +416,19 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|                 try: |                 try: | ||||||
|                     # spawning of actors happens in the caller's scope |                     # spawning of actors happens in the caller's scope | ||||||
|                     # after we yield upwards |                     # after we yield upwards | ||||||
|                     yield anursery |                     yield an | ||||||
| 
 | 
 | ||||||
|                     # When we didn't error in the caller's scope, |                     # When we didn't error in the caller's scope, | ||||||
|                     # signal all process-monitor-tasks to conduct |                     # signal all process-monitor-tasks to conduct | ||||||
|                     # the "hard join phase". |                     # the "hard join phase". | ||||||
|                     log.runtime( |                     log.runtime( | ||||||
|                         f"Waiting on subactors {anursery._children} " |                         'Waiting on subactors to complete:\n' | ||||||
|                         "to complete" |                         f'{pformat(an._children)}\n' | ||||||
|                     ) |                     ) | ||||||
|                     anursery._join_procs.set() |                     an._join_procs.set() | ||||||
| 
 | 
 | ||||||
|                 except BaseException as inner_err: |                 except BaseException as _inner_err: | ||||||
|  |                     inner_err = _inner_err | ||||||
|                     errors[actor.uid] = inner_err |                     errors[actor.uid] = inner_err | ||||||
| 
 | 
 | ||||||
|                     # If we error in the root but the debugger is |                     # If we error in the root but the debugger is | ||||||
|  | @ -357,37 +438,60 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|                     # Instead try to wait for pdb to be released before |                     # Instead try to wait for pdb to be released before | ||||||
|                     # tearing down. |                     # tearing down. | ||||||
|                     await maybe_wait_for_debugger( |                     await maybe_wait_for_debugger( | ||||||
|                         child_in_debug=anursery._at_least_one_child_in_debug |                         child_in_debug=an._at_least_one_child_in_debug | ||||||
|                     ) |                     ) | ||||||
| 
 | 
 | ||||||
|                     # if the caller's scope errored then we activate our |                     # if the caller's scope errored then we activate our | ||||||
|                     # one-cancels-all supervisor strategy (don't |                     # one-cancels-all supervisor strategy (don't | ||||||
|                     # worry more are coming). |                     # worry more are coming). | ||||||
|                     anursery._join_procs.set() |                     an._join_procs.set() | ||||||
| 
 | 
 | ||||||
|                     # XXX: hypothetically an error could be |                     # XXX NOTE XXX: hypothetically an error could | ||||||
|                     # raised and then a cancel signal shows up |                     # be raised and then a cancel signal shows up | ||||||
|                     # slightly after in which case the `else:` |                     # slightly after in which case the `else:` | ||||||
|                     # block here might not complete?  For now, |                     # block here might not complete?  For now, | ||||||
|                     # shield both. |                     # shield both. | ||||||
|                     with trio.CancelScope(shield=True): |                     with trio.CancelScope(shield=True): | ||||||
|                         etype = type(inner_err) |                         etype: type = type(inner_err) | ||||||
|                         if etype in ( |                         if etype in ( | ||||||
|                             trio.Cancelled, |                             trio.Cancelled, | ||||||
|                             KeyboardInterrupt |                             KeyboardInterrupt, | ||||||
|                         ) or ( |                         ) or ( | ||||||
|                             is_multi_cancelled(inner_err) |                             is_multi_cancelled(inner_err) | ||||||
|                         ): |                         ): | ||||||
|                             log.cancel( |                             log.cancel( | ||||||
|                                 f"Nursery for {current_actor().uid} " |                                 f'Actor-nursery cancelled by {etype}\n\n' | ||||||
|                                 f"was cancelled with {etype}") | 
 | ||||||
|  |                                 f'{current_actor().uid}\n' | ||||||
|  |                                 f' |_{an}\n\n' | ||||||
|  | 
 | ||||||
|  |                                 # TODO: show tb str? | ||||||
|  |                                 # f'{tb_str}' | ||||||
|  |                             ) | ||||||
|  |                         elif etype in { | ||||||
|  |                             ContextCancelled, | ||||||
|  |                         }: | ||||||
|  |                             log.cancel( | ||||||
|  |                                 'Actor-nursery caught remote cancellation\n\n' | ||||||
|  | 
 | ||||||
|  |                                 f'{inner_err.tb_str}' | ||||||
|  |                             ) | ||||||
|                         else: |                         else: | ||||||
|                             log.exception( |                             log.exception( | ||||||
|                                 f"Nursery for {current_actor().uid} " |                                 'Nursery errored with:\n' | ||||||
|                                 f"errored with") | 
 | ||||||
|  |                                 # TODO: same thing as in | ||||||
|  |                                 # `._invoke()` to compute how to | ||||||
|  |                                 # place this div-line in the | ||||||
|  |                                 # middle of the above msg | ||||||
|  |                                 # content.. | ||||||
|  |                                 # -[ ] prolly helper-func it too | ||||||
|  |                                 #   in our `.log` module.. | ||||||
|  |                                 # '------ - ------' | ||||||
|  |                             ) | ||||||
| 
 | 
 | ||||||
|                         # cancel all subactors |                         # cancel all subactors | ||||||
|                         await anursery.cancel() |                         await an.cancel() | ||||||
| 
 | 
 | ||||||
|             # ria_nursery scope end |             # ria_nursery scope end | ||||||
| 
 | 
 | ||||||
|  | @ -402,24 +506,30 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|             Exception, |             Exception, | ||||||
|             BaseExceptionGroup, |             BaseExceptionGroup, | ||||||
|             trio.Cancelled |             trio.Cancelled | ||||||
|  |         ) as _outer_err: | ||||||
|  |             outer_err = _outer_err | ||||||
| 
 | 
 | ||||||
|         ) as err: |             an._scope_error = outer_err or inner_err | ||||||
| 
 | 
 | ||||||
|             # XXX: yet another guard before allowing the cancel |             # XXX: yet another guard before allowing the cancel | ||||||
|             # sequence in case a (single) child is in debug. |             # sequence in case a (single) child is in debug. | ||||||
|             await maybe_wait_for_debugger( |             await maybe_wait_for_debugger( | ||||||
|                 child_in_debug=anursery._at_least_one_child_in_debug |                 child_in_debug=an._at_least_one_child_in_debug | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|             # If actor-local error was raised while waiting on |             # If actor-local error was raised while waiting on | ||||||
|             # ".run_in_actor()" actors then we also want to cancel all |             # ".run_in_actor()" actors then we also want to cancel all | ||||||
|             # remaining sub-actors (due to our lone strategy: |             # remaining sub-actors (due to our lone strategy: | ||||||
|             # one-cancels-all). |             # one-cancels-all). | ||||||
|             log.cancel(f"Nursery cancelling due to {err}") |             if an._children: | ||||||
|             if anursery._children: |                 log.cancel( | ||||||
|  |                     'Actor-nursery cancelling due error type:\n' | ||||||
|  |                     f'{outer_err}\n' | ||||||
|  |                 ) | ||||||
|                 with trio.CancelScope(shield=True): |                 with trio.CancelScope(shield=True): | ||||||
|                     await anursery.cancel() |                     await an.cancel() | ||||||
|             raise |             raise | ||||||
|  | 
 | ||||||
|         finally: |         finally: | ||||||
|             # No errors were raised while awaiting ".run_in_actor()" |             # No errors were raised while awaiting ".run_in_actor()" | ||||||
|             # actors but those actors may have returned remote errors as |             # actors but those actors may have returned remote errors as | ||||||
|  | @ -428,9 +538,9 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|             # collected in ``errors`` so cancel all actors, summarize |             # collected in ``errors`` so cancel all actors, summarize | ||||||
|             # all errors and re-raise. |             # all errors and re-raise. | ||||||
|             if errors: |             if errors: | ||||||
|                 if anursery._children: |                 if an._children: | ||||||
|                     with trio.CancelScope(shield=True): |                     with trio.CancelScope(shield=True): | ||||||
|                         await anursery.cancel() |                         await an.cancel() | ||||||
| 
 | 
 | ||||||
|                 # use `BaseExceptionGroup` as needed |                 # use `BaseExceptionGroup` as needed | ||||||
|                 if len(errors) > 1: |                 if len(errors) > 1: | ||||||
|  | @ -441,11 +551,19 @@ async def _open_and_supervise_one_cancels_all_nursery( | ||||||
|                 else: |                 else: | ||||||
|                     raise list(errors.values())[0] |                     raise list(errors.values())[0] | ||||||
| 
 | 
 | ||||||
|  |             # show frame on any (likely) internal error | ||||||
|  |             if ( | ||||||
|  |                 not an.cancelled | ||||||
|  |                 and an._scope_error | ||||||
|  |             ): | ||||||
|  |                 __tracebackhide__: bool = False | ||||||
|  | 
 | ||||||
|         # da_nursery scope end - nursery checkpoint |         # da_nursery scope end - nursery checkpoint | ||||||
|     # final exit |     # final exit | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @acm | @acm | ||||||
|  | # @api_frame | ||||||
| async def open_nursery( | async def open_nursery( | ||||||
|     **kwargs, |     **kwargs, | ||||||
| 
 | 
 | ||||||
|  | @ -465,19 +583,21 @@ async def open_nursery( | ||||||
|     which cancellation scopes correspond to each spawned subactor set. |     which cancellation scopes correspond to each spawned subactor set. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     implicit_runtime = False |     __tracebackhide__: bool = True | ||||||
| 
 |     implicit_runtime: bool = False | ||||||
|     actor = current_actor(err_on_no_runtime=False) |     actor: Actor = current_actor(err_on_no_runtime=False) | ||||||
| 
 |     an: ActorNursery|None = None | ||||||
|     try: |     try: | ||||||
|         if actor is None and is_main_process(): |         if ( | ||||||
| 
 |             actor is None | ||||||
|  |             and is_main_process() | ||||||
|  |         ): | ||||||
|             # if we are the parent process start the |             # if we are the parent process start the | ||||||
|             # actor runtime implicitly |             # actor runtime implicitly | ||||||
|             log.info("Starting actor runtime!") |             log.info("Starting actor runtime!") | ||||||
| 
 | 
 | ||||||
|             # mark us for teardown on exit |             # mark us for teardown on exit | ||||||
|             implicit_runtime = True |             implicit_runtime: bool = True | ||||||
| 
 | 
 | ||||||
|             async with open_root_actor(**kwargs) as actor: |             async with open_root_actor(**kwargs) as actor: | ||||||
|                 assert actor is current_actor() |                 assert actor is current_actor() | ||||||
|  | @ -485,24 +605,54 @@ async def open_nursery( | ||||||
|                 try: |                 try: | ||||||
|                     async with _open_and_supervise_one_cancels_all_nursery( |                     async with _open_and_supervise_one_cancels_all_nursery( | ||||||
|                         actor |                         actor | ||||||
|                     ) as anursery: |                     ) as an: | ||||||
|                         yield anursery | 
 | ||||||
|  |                         # NOTE: mark this nursery as having | ||||||
|  |                         # implicitly started the root actor so | ||||||
|  |                         # that `._runtime` machinery can avoid | ||||||
|  |                         # certain teardown synchronization | ||||||
|  |                         # blocking/waits and any associated (warn) | ||||||
|  |                         # logging when it's known that this | ||||||
|  |                         # nursery shouldn't be exited before the | ||||||
|  |                         # root actor is. | ||||||
|  |                         an._implicit_runtime_started = True | ||||||
|  |                         yield an | ||||||
|                 finally: |                 finally: | ||||||
|                     anursery.exited.set() |                     # XXX: this event will be set after the root actor | ||||||
|  |                     # runtime is already torn down, so we want to | ||||||
|  |                     # avoid any blocking on it. | ||||||
|  |                     an.exited.set() | ||||||
| 
 | 
 | ||||||
|         else:  # sub-nursery case |         else:  # sub-nursery case | ||||||
| 
 | 
 | ||||||
|             try: |             try: | ||||||
|                 async with _open_and_supervise_one_cancels_all_nursery( |                 async with _open_and_supervise_one_cancels_all_nursery( | ||||||
|                     actor |                     actor | ||||||
|                 ) as anursery: |                 ) as an: | ||||||
|                     yield anursery |                     yield an | ||||||
|             finally: |             finally: | ||||||
|                 anursery.exited.set() |                 an.exited.set() | ||||||
| 
 | 
 | ||||||
|     finally: |     finally: | ||||||
|         log.debug("Nursery teardown complete") |         # show frame on any internal runtime-scope error | ||||||
|  |         if ( | ||||||
|  |             an | ||||||
|  |             and not an.cancelled | ||||||
|  |             and an._scope_error | ||||||
|  |         ): | ||||||
|  |             __tracebackhide__: bool = False | ||||||
|  | 
 | ||||||
|  |         msg: str = ( | ||||||
|  |             'Actor-nursery exited\n' | ||||||
|  |             f'|_{an}\n' | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         # shutdown runtime if it was started |  | ||||||
|         if implicit_runtime: |         if implicit_runtime: | ||||||
|             log.info("Shutting down actor tree") |             # shutdown runtime if it was started and report noisly | ||||||
|  |             # that we're did so. | ||||||
|  |             msg += '=> Shutting down actor runtime <=\n' | ||||||
|  |             log.info(msg) | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             # keep noise low during std operation. | ||||||
|  |             log.runtime(msg) | ||||||
|  |  | ||||||
|  | @ -0,0 +1,96 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Various helpers/utils for auditing your `tractor` app and/or the | ||||||
|  | core runtime. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from contextlib import asynccontextmanager as acm | ||||||
|  | import pathlib | ||||||
|  | 
 | ||||||
|  | import tractor | ||||||
|  | from .pytest import ( | ||||||
|  |     tractor_test as tractor_test | ||||||
|  | ) | ||||||
|  | from .fault_simulation import ( | ||||||
|  |     break_ipc as break_ipc, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def repodir() -> pathlib.Path: | ||||||
|  |     ''' | ||||||
|  |     Return the abspath to the repo directory. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # 2 parents up to step up through tests/<repo_dir> | ||||||
|  |     return pathlib.Path( | ||||||
|  |         __file__ | ||||||
|  | 
 | ||||||
|  |     # 3 .parents bc: | ||||||
|  |     # <._testing-pkg>.<tractor-pkg>.<git-repo-dir> | ||||||
|  |     # /$HOME/../<tractor-repo-dir>/tractor/_testing/__init__.py | ||||||
|  |     ).parent.parent.parent.absolute() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def examples_dir() -> pathlib.Path: | ||||||
|  |     ''' | ||||||
|  |     Return the abspath to the examples directory as `pathlib.Path`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     return repodir() / 'examples' | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def mk_cmd( | ||||||
|  |     ex_name: str, | ||||||
|  |     exs_subpath: str = 'debugging', | ||||||
|  | ) -> str: | ||||||
|  |     ''' | ||||||
|  |     Generate a shell command suitable to pass to ``pexpect.spawn()``. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     script_path: pathlib.Path = ( | ||||||
|  |         examples_dir() | ||||||
|  |         / exs_subpath | ||||||
|  |         / f'{ex_name}.py' | ||||||
|  |     ) | ||||||
|  |     return ' '.join([ | ||||||
|  |         'python', | ||||||
|  |         str(script_path) | ||||||
|  |     ]) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @acm | ||||||
|  | async def expect_ctxc( | ||||||
|  |     yay: bool, | ||||||
|  |     reraise: bool = False, | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     Small acm to catch `ContextCancelled` errors when expected | ||||||
|  |     below it in a `async with ()` block. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     if yay: | ||||||
|  |         try: | ||||||
|  |             yield | ||||||
|  |             raise RuntimeError('Never raised ctxc?') | ||||||
|  |         except tractor.ContextCancelled: | ||||||
|  |             if reraise: | ||||||
|  |                 raise | ||||||
|  |             else: | ||||||
|  |                 return | ||||||
|  |     else: | ||||||
|  |         yield | ||||||
|  | @ -0,0 +1,92 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | `pytest` utils helpers and plugins for testing `tractor`'s runtime | ||||||
|  | and applications. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | 
 | ||||||
|  | from tractor import ( | ||||||
|  |     MsgStream, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | async def break_ipc( | ||||||
|  |     stream: MsgStream, | ||||||
|  |     method: str|None = None, | ||||||
|  |     pre_close: bool = False, | ||||||
|  | 
 | ||||||
|  |     def_method: str = 'socket_close', | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  |     ''' | ||||||
|  |     XXX: close the channel right after an error is raised | ||||||
|  |     purposely breaking the IPC transport to make sure the parent | ||||||
|  |     doesn't get stuck in debug or hang on the connection join. | ||||||
|  |     this more or less simulates an infinite msg-receive hang on | ||||||
|  |     the other end. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # close channel via IPC prot msging before | ||||||
|  |     # any transport breakage | ||||||
|  |     if pre_close: | ||||||
|  |         await stream.aclose() | ||||||
|  | 
 | ||||||
|  |     method: str = method or def_method | ||||||
|  |     print( | ||||||
|  |         '#################################\n' | ||||||
|  |         'Simulating CHILD-side IPC BREAK!\n' | ||||||
|  |         f'method: {method}\n' | ||||||
|  |         f'pre `.aclose()`: {pre_close}\n' | ||||||
|  |         '#################################\n' | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     match method: | ||||||
|  |         case 'socket_close': | ||||||
|  |             await stream._ctx.chan.transport.stream.aclose() | ||||||
|  | 
 | ||||||
|  |         case 'socket_eof': | ||||||
|  |             # NOTE: `trio` does the following underneath this | ||||||
|  |             # call in `src/trio/_highlevel_socket.py`: | ||||||
|  |             # `Stream.socket.shutdown(tsocket.SHUT_WR)` | ||||||
|  |             await stream._ctx.chan.transport.stream.send_eof() | ||||||
|  | 
 | ||||||
|  |         # TODO: remove since now this will be invalid with our | ||||||
|  |         # new typed msg spec? | ||||||
|  |         # case 'msg': | ||||||
|  |         #     await stream._ctx.chan.send(None) | ||||||
|  | 
 | ||||||
|  |         # TODO: the actual real-world simulated cases like | ||||||
|  |         # transport layer hangs and/or lower layer 2-gens type | ||||||
|  |         # scenarios.. | ||||||
|  |         # | ||||||
|  |         # -[ ] already have some issues for this general testing | ||||||
|  |         # area: | ||||||
|  |         #  - https://github.com/goodboy/tractor/issues/97 | ||||||
|  |         #  - https://github.com/goodboy/tractor/issues/124 | ||||||
|  |         #   - PR from @guille: | ||||||
|  |         #     https://github.com/goodboy/tractor/pull/149 | ||||||
|  |         # case 'hang': | ||||||
|  |         # TODO: framework research: | ||||||
|  |         # | ||||||
|  |         # - https://github.com/GuoTengda1993/pynetem | ||||||
|  |         # - https://github.com/shopify/toxiproxy | ||||||
|  |         # - https://manpages.ubuntu.com/manpages/trusty/man1/wirefilter.1.html | ||||||
|  | 
 | ||||||
|  |         case _: | ||||||
|  |             raise RuntimeError( | ||||||
|  |                 f'IPC break method unsupported: {method}' | ||||||
|  |             ) | ||||||
|  | @ -0,0 +1,113 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | `pytest` utils helpers and plugins for testing `tractor`'s runtime | ||||||
|  | and applications. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from functools import ( | ||||||
|  |     partial, | ||||||
|  |     wraps, | ||||||
|  | ) | ||||||
|  | import inspect | ||||||
|  | import platform | ||||||
|  | 
 | ||||||
|  | import tractor | ||||||
|  | import trio | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def tractor_test(fn): | ||||||
|  |     ''' | ||||||
|  |     Decorator for async test funcs to present them as "native" | ||||||
|  |     looking sync funcs runnable by `pytest` using `trio.run()`. | ||||||
|  | 
 | ||||||
|  |     Use: | ||||||
|  | 
 | ||||||
|  |     @tractor_test | ||||||
|  |     async def test_whatever(): | ||||||
|  |         await ... | ||||||
|  | 
 | ||||||
|  |     If fixtures: | ||||||
|  | 
 | ||||||
|  |         - ``reg_addr`` (a socket addr tuple where arbiter is listening) | ||||||
|  |         - ``loglevel`` (logging level passed to tractor internals) | ||||||
|  |         - ``start_method`` (subprocess spawning backend) | ||||||
|  | 
 | ||||||
|  |     are defined in the `pytest` fixture space they will be automatically | ||||||
|  |     injected to tests declaring these funcargs. | ||||||
|  |     ''' | ||||||
|  |     @wraps(fn) | ||||||
|  |     def wrapper( | ||||||
|  |         *args, | ||||||
|  |         loglevel=None, | ||||||
|  |         reg_addr=None, | ||||||
|  |         start_method: str|None = None, | ||||||
|  |         debug_mode: bool = False, | ||||||
|  |         **kwargs | ||||||
|  |     ): | ||||||
|  |         # __tracebackhide__ = True | ||||||
|  | 
 | ||||||
|  |         # NOTE: inject ant test func declared fixture | ||||||
|  |         # names by manually checking! | ||||||
|  |         if 'reg_addr' in inspect.signature(fn).parameters: | ||||||
|  |             # injects test suite fixture value to test as well | ||||||
|  |             # as `run()` | ||||||
|  |             kwargs['reg_addr'] = reg_addr | ||||||
|  | 
 | ||||||
|  |         if 'loglevel' in inspect.signature(fn).parameters: | ||||||
|  |             # allows test suites to define a 'loglevel' fixture | ||||||
|  |             # that activates the internal logging | ||||||
|  |             kwargs['loglevel'] = loglevel | ||||||
|  | 
 | ||||||
|  |         if start_method is None: | ||||||
|  |             if platform.system() == "Windows": | ||||||
|  |                 start_method = 'trio' | ||||||
|  | 
 | ||||||
|  |         if 'start_method' in inspect.signature(fn).parameters: | ||||||
|  |             # set of subprocess spawning backends | ||||||
|  |             kwargs['start_method'] = start_method | ||||||
|  | 
 | ||||||
|  |         if 'debug_mode' in inspect.signature(fn).parameters: | ||||||
|  |             # set of subprocess spawning backends | ||||||
|  |             kwargs['debug_mode'] = debug_mode | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |         if kwargs: | ||||||
|  | 
 | ||||||
|  |             # use explicit root actor start | ||||||
|  |             async def _main(): | ||||||
|  |                 async with tractor.open_root_actor( | ||||||
|  |                     # **kwargs, | ||||||
|  |                     registry_addrs=[reg_addr] if reg_addr else None, | ||||||
|  |                     loglevel=loglevel, | ||||||
|  |                     start_method=start_method, | ||||||
|  | 
 | ||||||
|  |                     # TODO: only enable when pytest is passed --pdb | ||||||
|  |                     debug_mode=debug_mode, | ||||||
|  | 
 | ||||||
|  |                 ): | ||||||
|  |                     await fn(*args, **kwargs) | ||||||
|  | 
 | ||||||
|  |             main = _main | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             # use implicit root actor start | ||||||
|  |             main = partial(fn, *args, **kwargs) | ||||||
|  | 
 | ||||||
|  |         return trio.run(main) | ||||||
|  | 
 | ||||||
|  |     return wrapper | ||||||
|  | @ -0,0 +1,78 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | """ | ||||||
|  | Runtime "developer experience" utils and addons to aid our | ||||||
|  | (advanced) users and core devs in building distributed applications | ||||||
|  | and working with/on the actor runtime. | ||||||
|  | 
 | ||||||
|  | """ | ||||||
|  | from ._debug import ( | ||||||
|  |     maybe_wait_for_debugger as maybe_wait_for_debugger, | ||||||
|  |     acquire_debug_lock as acquire_debug_lock, | ||||||
|  |     breakpoint as breakpoint, | ||||||
|  |     pause as pause, | ||||||
|  |     pause_from_sync as pause_from_sync, | ||||||
|  |     sigint_shield as sigint_shield, | ||||||
|  |     open_crash_handler as open_crash_handler, | ||||||
|  |     maybe_open_crash_handler as maybe_open_crash_handler, | ||||||
|  |     maybe_init_greenback as maybe_init_greenback, | ||||||
|  |     post_mortem as post_mortem, | ||||||
|  |     mk_pdb as mk_pdb, | ||||||
|  | ) | ||||||
|  | from ._stackscope import ( | ||||||
|  |     enable_stack_on_sig as enable_stack_on_sig, | ||||||
|  | ) | ||||||
|  | from .pformat import ( | ||||||
|  |     add_div as add_div, | ||||||
|  |     pformat_caller_frame as pformat_caller_frame, | ||||||
|  |     pformat_boxed_tb as pformat_boxed_tb, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO, move this to a new `.devx._pdbp` mod? | ||||||
|  | def _enable_readline_feats() -> str: | ||||||
|  |     ''' | ||||||
|  |     Handle `readline` when compiled with `libedit` to avoid breaking | ||||||
|  |     tab completion in `pdbp` (and its dep `tabcompleter`) | ||||||
|  |     particularly since `uv` cpython distis are compiled this way.. | ||||||
|  | 
 | ||||||
|  |     See docs for deats, | ||||||
|  |     https://docs.python.org/3/library/readline.html#module-readline | ||||||
|  | 
 | ||||||
|  |     Originally discovered soln via SO answer, | ||||||
|  |     https://stackoverflow.com/q/49287102 | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     import readline | ||||||
|  |     if ( | ||||||
|  |         # 3.13+ attr | ||||||
|  |         # https://docs.python.org/3/library/readline.html#readline.backend | ||||||
|  |         (getattr(readline, 'backend', False) == 'libedit') | ||||||
|  |         or | ||||||
|  |         'libedit' in readline.__doc__ | ||||||
|  |     ): | ||||||
|  |         readline.parse_and_bind("python:bind -v") | ||||||
|  |         readline.parse_and_bind("python:bind ^I rl_complete") | ||||||
|  |         return 'libedit' | ||||||
|  |     else: | ||||||
|  |         readline.parse_and_bind("tab: complete") | ||||||
|  |         readline.parse_and_bind("set editing-mode vi") | ||||||
|  |         readline.parse_and_bind("set keymap vi") | ||||||
|  |         return 'readline' | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | _enable_readline_feats() | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -0,0 +1,303 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Tools for code-object annotation, introspection and mutation | ||||||
|  | as it pertains to improving the grok-ability of our runtime! | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from functools import partial | ||||||
|  | import inspect | ||||||
|  | from types import ( | ||||||
|  |     FrameType, | ||||||
|  |     FunctionType, | ||||||
|  |     MethodType, | ||||||
|  |     # CodeType, | ||||||
|  | ) | ||||||
|  | from typing import ( | ||||||
|  |     Any, | ||||||
|  |     Callable, | ||||||
|  |     Type, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from tractor.msg import ( | ||||||
|  |     pretty_struct, | ||||||
|  |     NamespacePath, | ||||||
|  | ) | ||||||
|  | import wrapt | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: yeah, i don't love this and we should prolly just | ||||||
|  | # write a decorator that actually keeps a stupid ref to the func | ||||||
|  | # obj.. | ||||||
|  | def get_class_from_frame(fr: FrameType) -> ( | ||||||
|  |     FunctionType | ||||||
|  |     |MethodType | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Attempt to get the function (or method) reference | ||||||
|  |     from a given `FrameType`. | ||||||
|  | 
 | ||||||
|  |     Verbatim from an SO: | ||||||
|  |     https://stackoverflow.com/a/2220759 | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     args, _, _, value_dict = inspect.getargvalues(fr) | ||||||
|  | 
 | ||||||
|  |     # we check the first parameter for the frame function is | ||||||
|  |     # named 'self' | ||||||
|  |     if ( | ||||||
|  |         len(args) | ||||||
|  |         and | ||||||
|  |         # TODO: other cases for `@classmethod` etc..?) | ||||||
|  |         args[0] == 'self' | ||||||
|  |     ): | ||||||
|  |         # in that case, 'self' will be referenced in value_dict | ||||||
|  |         instance: object = value_dict.get('self') | ||||||
|  |         if instance: | ||||||
|  |           # return its class | ||||||
|  |           return getattr( | ||||||
|  |               instance, | ||||||
|  |               '__class__', | ||||||
|  |               None, | ||||||
|  |           ) | ||||||
|  | 
 | ||||||
|  |     # return None otherwise | ||||||
|  |     return None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_ns_and_func_from_frame( | ||||||
|  |     frame: FrameType, | ||||||
|  | ) -> Callable: | ||||||
|  |     ''' | ||||||
|  |     Return the corresponding function object reference from | ||||||
|  |     a `FrameType`, and return it and it's parent namespace `dict`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     ns: dict[str, Any] | ||||||
|  | 
 | ||||||
|  |     # for a method, go up a frame and lookup the name in locals() | ||||||
|  |     if '.' in (qualname := frame.f_code.co_qualname): | ||||||
|  |         cls_name, _, func_name = qualname.partition('.') | ||||||
|  |         ns = frame.f_back.f_locals[cls_name].__dict__ | ||||||
|  | 
 | ||||||
|  |     else: | ||||||
|  |         func_name: str = frame.f_code.co_name | ||||||
|  |         ns = frame.f_globals | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         ns, | ||||||
|  |         ns[func_name], | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def func_ref_from_frame( | ||||||
|  |     frame: FrameType, | ||||||
|  | ) -> Callable: | ||||||
|  |     func_name: str = frame.f_code.co_name | ||||||
|  |     try: | ||||||
|  |         return frame.f_globals[func_name] | ||||||
|  |     except KeyError: | ||||||
|  |         cls: Type|None = get_class_from_frame(frame) | ||||||
|  |         if cls: | ||||||
|  |             return getattr( | ||||||
|  |                 cls, | ||||||
|  |                 func_name, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class CallerInfo(pretty_struct.Struct): | ||||||
|  |     # https://docs.python.org/dev/reference/datamodel.html#frame-objects | ||||||
|  |     # https://docs.python.org/dev/library/inspect.html#the-interpreter-stack | ||||||
|  |     _api_frame: FrameType | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def api_frame(self) -> FrameType: | ||||||
|  |         try: | ||||||
|  |             self._api_frame.clear() | ||||||
|  |         except RuntimeError: | ||||||
|  |             # log.warning( | ||||||
|  |             print( | ||||||
|  |                 f'Frame {self._api_frame} for {self.api_func} is still active!' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         return self._api_frame | ||||||
|  | 
 | ||||||
|  |     _api_func: Callable | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def api_func(self) -> Callable: | ||||||
|  |         return self._api_func | ||||||
|  | 
 | ||||||
|  |     _caller_frames_up: int|None = 1 | ||||||
|  |     _caller_frame: FrameType|None = None  # cached after first stack scan | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def api_nsp(self) -> NamespacePath|None: | ||||||
|  |         func: FunctionType = self.api_func | ||||||
|  |         if func: | ||||||
|  |             return NamespacePath.from_ref(func) | ||||||
|  | 
 | ||||||
|  |         return '<unknown>' | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def caller_frame(self) -> FrameType: | ||||||
|  | 
 | ||||||
|  |         # if not already cached, scan up stack explicitly by | ||||||
|  |         # configured count. | ||||||
|  |         if not self._caller_frame: | ||||||
|  |             if self._caller_frames_up: | ||||||
|  |                 for _ in range(self._caller_frames_up): | ||||||
|  |                     caller_frame: FrameType|None = self.api_frame.f_back | ||||||
|  | 
 | ||||||
|  |                 if not caller_frame: | ||||||
|  |                     raise ValueError( | ||||||
|  |                         'No frame exists {self._caller_frames_up} up from\n' | ||||||
|  |                         f'{self.api_frame} @ {self.api_nsp}\n' | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |             self._caller_frame = caller_frame | ||||||
|  | 
 | ||||||
|  |         return self._caller_frame | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def caller_nsp(self) -> NamespacePath|None: | ||||||
|  |         func: FunctionType = self.api_func | ||||||
|  |         if func: | ||||||
|  |             return NamespacePath.from_ref(func) | ||||||
|  | 
 | ||||||
|  |         return '<unknown>' | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def find_caller_info( | ||||||
|  |     dunder_var: str = '__runtimeframe__', | ||||||
|  |     iframes:int = 1, | ||||||
|  |     check_frame_depth: bool = True, | ||||||
|  | 
 | ||||||
|  | ) -> CallerInfo|None: | ||||||
|  |     ''' | ||||||
|  |     Scan up the callstack for a frame with a `dunder_var: str` variable | ||||||
|  |     and return the `iframes` frames above it. | ||||||
|  | 
 | ||||||
|  |     By default we scan for a `__runtimeframe__` scope var which | ||||||
|  |     denotes a `tractor` API above which (one frame up) is "user | ||||||
|  |     app code" which "called into" the `tractor` method or func. | ||||||
|  | 
 | ||||||
|  |     TODO: ex with `Portal.open_context()` | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # TODO: use this instead? | ||||||
|  |     # https://docs.python.org/3/library/inspect.html#inspect.getouterframes | ||||||
|  |     frames: list[inspect.FrameInfo] = inspect.stack() | ||||||
|  |     for fi in frames: | ||||||
|  |         assert ( | ||||||
|  |             fi.function | ||||||
|  |             == | ||||||
|  |             fi.frame.f_code.co_name | ||||||
|  |         ) | ||||||
|  |         this_frame: FrameType = fi.frame | ||||||
|  |         dunder_val: int|None = this_frame.f_locals.get(dunder_var) | ||||||
|  |         if dunder_val: | ||||||
|  |             go_up_iframes: int = ( | ||||||
|  |                 dunder_val  # could be 0 or `True` i guess? | ||||||
|  |                 or | ||||||
|  |                 iframes | ||||||
|  |             ) | ||||||
|  |             rt_frame: FrameType = fi.frame | ||||||
|  |             call_frame = rt_frame | ||||||
|  |             for i in range(go_up_iframes): | ||||||
|  |                 call_frame = call_frame.f_back | ||||||
|  | 
 | ||||||
|  |             return CallerInfo( | ||||||
|  |                 _api_frame=rt_frame, | ||||||
|  |                 _api_func=func_ref_from_frame(rt_frame), | ||||||
|  |                 _caller_frames_up=go_up_iframes, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     return None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | _frame2callerinfo_cache: dict[FrameType, CallerInfo] = {} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: -[x] move all this into new `.devx._frame_stack`! | ||||||
|  | # -[ ] consider rename to _callstack? | ||||||
|  | # -[ ] prolly create a `@runtime_api` dec? | ||||||
|  | #   |_ @api_frame seems better? | ||||||
|  | # -[ ] ^- make it capture and/or accept buncha optional | ||||||
|  | #     meta-data like a fancier version of `@pdbp.hideframe`. | ||||||
|  | # | ||||||
|  | def api_frame( | ||||||
|  |     wrapped: Callable|None = None, | ||||||
|  |     *, | ||||||
|  |     caller_frames_up: int = 1, | ||||||
|  | 
 | ||||||
|  | ) -> Callable: | ||||||
|  | 
 | ||||||
|  |     # handle the decorator called WITHOUT () case, | ||||||
|  |     # i.e. just @api_frame, NOT @api_frame(extra=<blah>) | ||||||
|  |     if wrapped is None: | ||||||
|  |         return partial( | ||||||
|  |             api_frame, | ||||||
|  |             caller_frames_up=caller_frames_up, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @wrapt.decorator | ||||||
|  |     async def wrapper( | ||||||
|  |         wrapped: Callable, | ||||||
|  |         instance: object, | ||||||
|  |         args: tuple, | ||||||
|  |         kwargs: dict, | ||||||
|  |     ): | ||||||
|  |         # maybe cache the API frame for this call | ||||||
|  |         global _frame2callerinfo_cache | ||||||
|  |         this_frame: FrameType = inspect.currentframe() | ||||||
|  |         api_frame: FrameType = this_frame.f_back | ||||||
|  | 
 | ||||||
|  |         if not _frame2callerinfo_cache.get(api_frame): | ||||||
|  |             _frame2callerinfo_cache[api_frame] = CallerInfo( | ||||||
|  |                 _api_frame=api_frame, | ||||||
|  |                 _api_func=wrapped, | ||||||
|  |                 _caller_frames_up=caller_frames_up, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         return wrapped(*args, **kwargs) | ||||||
|  | 
 | ||||||
|  |     # annotate the function as a "api function", meaning it is | ||||||
|  |     # a function for which the function above it in the call stack should be | ||||||
|  |     # non-`tractor` code aka "user code". | ||||||
|  |     # | ||||||
|  |     # in the global frame cache for easy lookup from a given | ||||||
|  |     # func-instance | ||||||
|  |     wrapped._call_infos: dict[FrameType, CallerInfo] = _frame2callerinfo_cache | ||||||
|  |     wrapped.__api_func__: bool = True | ||||||
|  |     return wrapper(wrapped) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: something like this instead of the adhoc frame-unhiding | ||||||
|  | # blocks all over the runtime!! XD | ||||||
|  | # -[ ] ideally we can expect a certain error (set) and if something | ||||||
|  | #     else is raised then all frames below the wrapped one will be | ||||||
|  | #     un-hidden via `__tracebackhide__: bool = False`. | ||||||
|  | # |_ might need to dynamically mutate the code objs like | ||||||
|  | #    `pdbp.hideframe()` does? | ||||||
|  | # -[ ] use this as a `@acm` decorator as introed in 3.10? | ||||||
|  | # @acm | ||||||
|  | # async def unhide_frame_when_not( | ||||||
|  | #     error_set: set[BaseException], | ||||||
|  | # ) -> TracebackType: | ||||||
|  | #     ... | ||||||
|  | @ -0,0 +1,239 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | The fundamental cross process SC abstraction: an inter-actor, | ||||||
|  | cancel-scope linked task "context". | ||||||
|  | 
 | ||||||
|  | A ``Context`` is very similar to the ``trio.Nursery.cancel_scope`` built | ||||||
|  | into each ``trio.Nursery`` except it links the lifetimes of memory space | ||||||
|  | disjoint, parallel executing tasks in separate actors. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | # from functools import partial | ||||||
|  | from threading import ( | ||||||
|  |     current_thread, | ||||||
|  |     Thread, | ||||||
|  |     RLock, | ||||||
|  | ) | ||||||
|  | import multiprocessing as mp | ||||||
|  | from signal import ( | ||||||
|  |     signal, | ||||||
|  |     getsignal, | ||||||
|  |     SIGUSR1, | ||||||
|  | ) | ||||||
|  | # import traceback | ||||||
|  | from types import ModuleType | ||||||
|  | from typing import ( | ||||||
|  |     Callable, | ||||||
|  |     TYPE_CHECKING, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | import trio | ||||||
|  | from tractor import ( | ||||||
|  |     _state, | ||||||
|  |     log as logmod, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | log = logmod.get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from tractor._spawn import ProcessType | ||||||
|  |     from tractor import ( | ||||||
|  |         Actor, | ||||||
|  |         ActorNursery, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @trio.lowlevel.disable_ki_protection | ||||||
|  | def dump_task_tree() -> None: | ||||||
|  |     ''' | ||||||
|  |     Do a classic `stackscope.extract()` task-tree dump to console at | ||||||
|  |     `.devx()` level. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     import stackscope | ||||||
|  |     tree_str: str = str( | ||||||
|  |         stackscope.extract( | ||||||
|  |             trio.lowlevel.current_root_task(), | ||||||
|  |             recurse_child_tasks=True | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  |     actor: Actor = _state.current_actor() | ||||||
|  |     thr: Thread = current_thread() | ||||||
|  |     log.devx( | ||||||
|  |         f'Dumping `stackscope` tree for actor\n' | ||||||
|  |         f'{actor.uid}:\n' | ||||||
|  |         f'|_{mp.current_process()}\n' | ||||||
|  |         f'  |_{thr}\n' | ||||||
|  |         f'    |_{actor}\n\n' | ||||||
|  | 
 | ||||||
|  |         # start-of-trace-tree delimiter (mostly for testing) | ||||||
|  |         '------ - ------\n' | ||||||
|  |         '\n' | ||||||
|  |         + | ||||||
|  |         f'{tree_str}\n' | ||||||
|  |         + | ||||||
|  |         # end-of-trace-tree delimiter (mostly for testing) | ||||||
|  |         f'\n' | ||||||
|  |         f'------ {actor.uid!r} ------\n' | ||||||
|  |     ) | ||||||
|  |     # TODO: can remove this right? | ||||||
|  |     # -[ ] was original code from author | ||||||
|  |     # | ||||||
|  |     # print( | ||||||
|  |     #     'DUMPING FROM PRINT\n' | ||||||
|  |     #     + | ||||||
|  |     #     content | ||||||
|  |     # ) | ||||||
|  |     # import logging | ||||||
|  |     # try: | ||||||
|  |     #     with open("/dev/tty", "w") as tty: | ||||||
|  |     #         tty.write(tree_str) | ||||||
|  |     # except BaseException: | ||||||
|  |     #     logging.getLogger( | ||||||
|  |     #         "task_tree" | ||||||
|  |     #     ).exception("Error printing task tree") | ||||||
|  | 
 | ||||||
|  | _handler_lock = RLock() | ||||||
|  | _tree_dumped: bool = False | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def dump_tree_on_sig( | ||||||
|  |     sig: int, | ||||||
|  |     frame: object, | ||||||
|  | 
 | ||||||
|  |     relay_to_subs: bool = True, | ||||||
|  | 
 | ||||||
|  | ) -> None: | ||||||
|  |     global _tree_dumped, _handler_lock | ||||||
|  |     with _handler_lock: | ||||||
|  |         if _tree_dumped: | ||||||
|  |             log.warning( | ||||||
|  |                 'Already dumped for this actor...??' | ||||||
|  |             ) | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |         _tree_dumped = True | ||||||
|  | 
 | ||||||
|  |         # actor: Actor = _state.current_actor() | ||||||
|  |         log.devx( | ||||||
|  |             'Trying to dump `stackscope` tree..\n' | ||||||
|  |         ) | ||||||
|  |         try: | ||||||
|  |             dump_task_tree() | ||||||
|  |             # await actor._service_n.start_soon( | ||||||
|  |             #     partial( | ||||||
|  |             #         trio.to_thread.run_sync, | ||||||
|  |             #         dump_task_tree, | ||||||
|  |             #     ) | ||||||
|  |             # ) | ||||||
|  |             # trio.lowlevel.current_trio_token().run_sync_soon( | ||||||
|  |             #     dump_task_tree | ||||||
|  |             # ) | ||||||
|  | 
 | ||||||
|  |         except RuntimeError: | ||||||
|  |             log.exception( | ||||||
|  |                 'Failed to dump `stackscope` tree..\n' | ||||||
|  |             ) | ||||||
|  |             # not in async context -- print a normal traceback | ||||||
|  |             # traceback.print_stack() | ||||||
|  |             raise | ||||||
|  | 
 | ||||||
|  |         except BaseException: | ||||||
|  |             log.exception( | ||||||
|  |                 'Failed to dump `stackscope` tree..\n' | ||||||
|  |             ) | ||||||
|  |             raise | ||||||
|  | 
 | ||||||
|  |         log.devx( | ||||||
|  |             'Supposedly we dumped just fine..?' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     if not relay_to_subs: | ||||||
|  |         return | ||||||
|  | 
 | ||||||
|  |     an: ActorNursery | ||||||
|  |     for an in _state.current_actor()._actoruid2nursery.values(): | ||||||
|  |         subproc: ProcessType | ||||||
|  |         subactor: Actor | ||||||
|  |         for subactor, subproc, _ in an._children.values(): | ||||||
|  |             log.warning( | ||||||
|  |                 f'Relaying `SIGUSR1`[{sig}] to sub-actor\n' | ||||||
|  |                 f'{subactor}\n' | ||||||
|  |                 f' |_{subproc}\n' | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             # bc of course stdlib can't have a std API.. XD | ||||||
|  |             match subproc: | ||||||
|  |                 case trio.Process(): | ||||||
|  |                     subproc.send_signal(sig) | ||||||
|  | 
 | ||||||
|  |                 case mp.Process(): | ||||||
|  |                     subproc._send_signal(sig) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def enable_stack_on_sig( | ||||||
|  |     sig: int = SIGUSR1, | ||||||
|  | ) -> ModuleType: | ||||||
|  |     ''' | ||||||
|  |     Enable `stackscope` tracing on reception of a signal; by | ||||||
|  |     default this is SIGUSR1. | ||||||
|  | 
 | ||||||
|  |     HOT TIP: a task/ctx-tree dump can be triggered from a shell with | ||||||
|  |     fancy cmds. | ||||||
|  | 
 | ||||||
|  |     For ex. from `bash` using `pgrep` and cmd-sustitution | ||||||
|  |     (https://www.gnu.org/software/bash/manual/bash.html#Command-Substitution) | ||||||
|  |     you could use: | ||||||
|  | 
 | ||||||
|  |     >> kill -SIGUSR1 $(pgrep -f '<cmd>') | ||||||
|  | 
 | ||||||
|  |     Or with with `xonsh` (which has diff capture-from-subproc syntax) | ||||||
|  | 
 | ||||||
|  |     >> kill -SIGUSR1 @$(pgrep -f '<cmd>') | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     try: | ||||||
|  |         import stackscope | ||||||
|  |     except ImportError: | ||||||
|  |         log.warning( | ||||||
|  |             '`stackscope` not installed for use in debug mode!' | ||||||
|  |         ) | ||||||
|  |         return None | ||||||
|  | 
 | ||||||
|  |     handler: Callable|int = getsignal(sig) | ||||||
|  |     if handler is dump_tree_on_sig: | ||||||
|  |         log.devx( | ||||||
|  |             'A `SIGUSR1` handler already exists?\n' | ||||||
|  |             f'|_ {handler!r}\n' | ||||||
|  |         ) | ||||||
|  |         return | ||||||
|  | 
 | ||||||
|  |     signal( | ||||||
|  |         sig, | ||||||
|  |         dump_tree_on_sig, | ||||||
|  |     ) | ||||||
|  |     log.devx( | ||||||
|  |         'Enabling trace-trees on `SIGUSR1` ' | ||||||
|  |         'since `stackscope` is installed @ \n' | ||||||
|  |         f'{stackscope!r}\n\n' | ||||||
|  |         f'With `SIGUSR1` handler\n' | ||||||
|  |         f'|_{dump_tree_on_sig}\n' | ||||||
|  |     ) | ||||||
|  |     return stackscope | ||||||
|  | @ -0,0 +1,129 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | """ | ||||||
|  | CLI framework extensions for hacking on the actor runtime. | ||||||
|  | 
 | ||||||
|  | Currently popular frameworks supported are: | ||||||
|  | 
 | ||||||
|  |   - `typer` via the `@callback` API | ||||||
|  | 
 | ||||||
|  | """ | ||||||
|  | from __future__ import annotations | ||||||
|  | from typing import ( | ||||||
|  |     Any, | ||||||
|  |     Callable, | ||||||
|  | ) | ||||||
|  | from typing_extensions import Annotated | ||||||
|  | 
 | ||||||
|  | import typer | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | _runtime_vars: dict[str, Any] = {} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def load_runtime_vars( | ||||||
|  |     ctx: typer.Context, | ||||||
|  |     callback: Callable, | ||||||
|  |     pdb: bool = False,  # --pdb | ||||||
|  |     ll: Annotated[ | ||||||
|  |         str, | ||||||
|  |         typer.Option( | ||||||
|  |             '--loglevel', | ||||||
|  |             '-l', | ||||||
|  |             help='BigD logging level', | ||||||
|  |         ), | ||||||
|  |     ] = 'cancel',  # -l info | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Maybe engage crash handling with `pdbp` when code inside | ||||||
|  |     a `typer` CLI endpoint cmd raises. | ||||||
|  | 
 | ||||||
|  |     To use this callback simply take your `app = typer.Typer()` instance | ||||||
|  |     and decorate this function with it like so: | ||||||
|  | 
 | ||||||
|  |     .. code:: python | ||||||
|  | 
 | ||||||
|  |         from tractor.devx import cli | ||||||
|  | 
 | ||||||
|  |         app = typer.Typer() | ||||||
|  | 
 | ||||||
|  |         # manual decoration to hook into `click`'s context system! | ||||||
|  |         cli.load_runtime_vars = app.callback( | ||||||
|  |             invoke_without_command=True, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     And then you can use the now augmented `click` CLI context as so, | ||||||
|  | 
 | ||||||
|  |     .. code:: python | ||||||
|  | 
 | ||||||
|  |         @app.command( | ||||||
|  |             context_settings={ | ||||||
|  |                 "allow_extra_args": True, | ||||||
|  |                 "ignore_unknown_options": True, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |         def my_cli_cmd( | ||||||
|  |             ctx: typer.Context, | ||||||
|  |         ): | ||||||
|  |             rtvars: dict = ctx.runtime_vars | ||||||
|  |             pdb: bool = rtvars['pdb'] | ||||||
|  | 
 | ||||||
|  |             with tractor.devx.cli.maybe_open_crash_handler(pdb=pdb): | ||||||
|  |                 trio.run( | ||||||
|  |                     partial( | ||||||
|  |                         my_tractor_main_task_func, | ||||||
|  |                         debug_mode=pdb, | ||||||
|  |                         loglevel=rtvars['ll'], | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |     which will enable log level and debug mode globally for the entire | ||||||
|  |     `tractor` + `trio` runtime thereafter! | ||||||
|  | 
 | ||||||
|  |     Bo | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     global _runtime_vars | ||||||
|  |     _runtime_vars |= { | ||||||
|  |         'pdb': pdb, | ||||||
|  |         'll': ll, | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     ctx.runtime_vars: dict[str, Any] = _runtime_vars | ||||||
|  |     print( | ||||||
|  |         f'`typer` sub-cmd: {ctx.invoked_subcommand}\n' | ||||||
|  |         f'`tractor` runtime vars: {_runtime_vars}' | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # XXX NOTE XXX: hackzone.. if no sub-cmd is specified (the | ||||||
|  |     # default if the user just invokes `bigd`) then we simply | ||||||
|  |     # invoke the sole `_bigd()` cmd passing in the "parent" | ||||||
|  |     # typer.Context directly to that call since we're treating it | ||||||
|  |     # as a "non sub-command" or wtv.. | ||||||
|  |     # TODO: ideally typer would have some kinda built-in way to get | ||||||
|  |     # this behaviour without having to construct and manually | ||||||
|  |     # invoke our own cmd.. | ||||||
|  |     if ( | ||||||
|  |         ctx.invoked_subcommand is None | ||||||
|  |         or ctx.invoked_subcommand == callback.__name__ | ||||||
|  |     ): | ||||||
|  |         cmd: typer.core.TyperCommand = typer.core.TyperCommand( | ||||||
|  |             name='bigd', | ||||||
|  |             callback=callback, | ||||||
|  |         ) | ||||||
|  |         ctx.params = {'ctx': ctx} | ||||||
|  |         cmd.invoke(ctx) | ||||||
|  | @ -0,0 +1,169 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Pretty formatters for use throughout the code base. | ||||||
|  | Mostly handy for logging and exception message content. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | import textwrap | ||||||
|  | import traceback | ||||||
|  | 
 | ||||||
|  | from trio import CancelScope | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def add_div( | ||||||
|  |     message: str, | ||||||
|  |     div_str: str = '------ - ------', | ||||||
|  | 
 | ||||||
|  | ) -> str: | ||||||
|  |     ''' | ||||||
|  |     Add a "divider string" to the input `message` with | ||||||
|  |     a little math to center it underneath. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     div_offset: int = ( | ||||||
|  |         round(len(message)/2)+1 | ||||||
|  |         - | ||||||
|  |         round(len(div_str)/2)+1 | ||||||
|  |     ) | ||||||
|  |     div_str: str = ( | ||||||
|  |         '\n' + ' '*div_offset + f'{div_str}\n' | ||||||
|  |     ) | ||||||
|  |     return div_str | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def pformat_boxed_tb( | ||||||
|  |     tb_str: str, | ||||||
|  |     fields_str: str|None = None, | ||||||
|  |     field_prefix: str = ' |_', | ||||||
|  | 
 | ||||||
|  |     tb_box_indent: int|None = None, | ||||||
|  |     tb_body_indent: int = 1, | ||||||
|  |     boxer_header: str = '-' | ||||||
|  | 
 | ||||||
|  | ) -> str: | ||||||
|  |     ''' | ||||||
|  |     Create a "boxed" looking traceback string. | ||||||
|  | 
 | ||||||
|  |     Useful for emphasizing traceback text content as being an | ||||||
|  |     embedded attribute of some other object (like | ||||||
|  |     a `RemoteActorError` or other boxing remote error shuttle | ||||||
|  |     container). | ||||||
|  | 
 | ||||||
|  |     Any other parent/container "fields" can be passed in the | ||||||
|  |     `fields_str` input along with other prefix/indent settings. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     if ( | ||||||
|  |         fields_str | ||||||
|  |         and | ||||||
|  |         field_prefix | ||||||
|  |     ): | ||||||
|  |         fields: str = textwrap.indent( | ||||||
|  |             fields_str, | ||||||
|  |             prefix=field_prefix, | ||||||
|  |         ) | ||||||
|  |     else: | ||||||
|  |         fields = fields_str or '' | ||||||
|  | 
 | ||||||
|  |     tb_body = tb_str | ||||||
|  |     if tb_body_indent: | ||||||
|  |         tb_body: str = textwrap.indent( | ||||||
|  |             tb_str, | ||||||
|  |             prefix=tb_body_indent * ' ', | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     tb_box: str = ( | ||||||
|  |         f'|\n' | ||||||
|  |         f' ------ {boxer_header} ------\n' | ||||||
|  |         f'{tb_body}' | ||||||
|  |         f' ------ {boxer_header}- ------\n' | ||||||
|  |         f'_|' | ||||||
|  |     ) | ||||||
|  |     tb_box_indent: str = ( | ||||||
|  |         tb_box_indent | ||||||
|  |         or | ||||||
|  |         1 | ||||||
|  | 
 | ||||||
|  |         # (len(field_prefix)) | ||||||
|  |         # ? ^-TODO-^ ? if you wanted another indent level | ||||||
|  |     ) | ||||||
|  |     if tb_box_indent > 0: | ||||||
|  |         tb_box: str = textwrap.indent( | ||||||
|  |             tb_box, | ||||||
|  |             prefix=tb_box_indent * ' ', | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         fields | ||||||
|  |         + | ||||||
|  |         tb_box | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def pformat_caller_frame( | ||||||
|  |     stack_limit: int = 1, | ||||||
|  |     box_tb: bool = True, | ||||||
|  | ) -> str: | ||||||
|  |     ''' | ||||||
|  |     Capture and return the traceback text content from | ||||||
|  |     `stack_limit` call frames up. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     tb_str: str = ( | ||||||
|  |         '\n'.join( | ||||||
|  |             traceback.format_stack(limit=stack_limit) | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  |     if box_tb: | ||||||
|  |         tb_str: str = pformat_boxed_tb( | ||||||
|  |             tb_str=tb_str, | ||||||
|  |             field_prefix='  ', | ||||||
|  |             indent='', | ||||||
|  |         ) | ||||||
|  |     return tb_str | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def pformat_cs( | ||||||
|  |     cs: CancelScope, | ||||||
|  |     var_name: str = 'cs', | ||||||
|  |     field_prefix: str = ' |_', | ||||||
|  | ) -> str: | ||||||
|  |     ''' | ||||||
|  |     Pretty format info about a `trio.CancelScope` including most | ||||||
|  |     of its public state and `._cancel_status`. | ||||||
|  | 
 | ||||||
|  |     The output can be modified to show a "var name" for the | ||||||
|  |     instance as a field prefix, just a simple str before each | ||||||
|  |     line more or less. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  | 
 | ||||||
|  |     fields: str = textwrap.indent( | ||||||
|  |         ( | ||||||
|  |             f'cancel_called = {cs.cancel_called}\n' | ||||||
|  |             f'cancelled_caught = {cs.cancelled_caught}\n' | ||||||
|  |             f'_cancel_status = {cs._cancel_status}\n' | ||||||
|  |             f'shield = {cs.shield}\n' | ||||||
|  |         ), | ||||||
|  |         prefix=field_prefix, | ||||||
|  |     ) | ||||||
|  |     return ( | ||||||
|  |         f'{var_name}: {cs}\n' | ||||||
|  |         + | ||||||
|  |         fields | ||||||
|  |     ) | ||||||
|  | @ -31,7 +31,7 @@ from typing import ( | ||||||
|     Callable, |     Callable, | ||||||
| ) | ) | ||||||
| from functools import partial | from functools import partial | ||||||
| from async_generator import aclosing | from contextlib import aclosing | ||||||
| 
 | 
 | ||||||
| import trio | import trio | ||||||
| import wrapt | import wrapt | ||||||
|  |  | ||||||
							
								
								
									
										233
									
								
								tractor/log.py
								
								
								
								
							
							
						
						
									
										233
									
								
								tractor/log.py
								
								
								
								
							|  | @ -21,6 +21,11 @@ Log like a forester! | ||||||
| from collections.abc import Mapping | from collections.abc import Mapping | ||||||
| import sys | import sys | ||||||
| import logging | import logging | ||||||
|  | from logging import ( | ||||||
|  |     LoggerAdapter, | ||||||
|  |     Logger, | ||||||
|  |     StreamHandler, | ||||||
|  | ) | ||||||
| import colorlog  # type: ignore | import colorlog  # type: ignore | ||||||
| 
 | 
 | ||||||
| import trio | import trio | ||||||
|  | @ -48,17 +53,20 @@ LOG_FORMAT = ( | ||||||
| 
 | 
 | ||||||
| DATE_FORMAT = '%b %d %H:%M:%S' | DATE_FORMAT = '%b %d %H:%M:%S' | ||||||
| 
 | 
 | ||||||
| LEVELS = { | # FYI, ERROR is 40 | ||||||
|  | # TODO: use a `bidict` to avoid the :155 check? | ||||||
|  | CUSTOM_LEVELS: dict[str, int] = { | ||||||
|     'TRANSPORT': 5, |     'TRANSPORT': 5, | ||||||
|     'RUNTIME': 15, |     'RUNTIME': 15, | ||||||
|     'CANCEL': 16, |     'DEVX': 17, | ||||||
|  |     'CANCEL': 22, | ||||||
|     'PDB': 500, |     'PDB': 500, | ||||||
| } | } | ||||||
| 
 |  | ||||||
| STD_PALETTE = { | STD_PALETTE = { | ||||||
|     'CRITICAL': 'red', |     'CRITICAL': 'red', | ||||||
|     'ERROR': 'red', |     'ERROR': 'red', | ||||||
|     'PDB': 'white', |     'PDB': 'white', | ||||||
|  |     'DEVX': 'cyan', | ||||||
|     'WARNING': 'yellow', |     'WARNING': 'yellow', | ||||||
|     'INFO': 'green', |     'INFO': 'green', | ||||||
|     'CANCEL': 'yellow', |     'CANCEL': 'yellow', | ||||||
|  | @ -75,7 +83,7 @@ BOLD_PALETTE = { | ||||||
| 
 | 
 | ||||||
| # TODO: this isn't showing the correct '{filename}' | # TODO: this isn't showing the correct '{filename}' | ||||||
| # as it did before.. | # as it did before.. | ||||||
| class StackLevelAdapter(logging.LoggerAdapter): | class StackLevelAdapter(LoggerAdapter): | ||||||
| 
 | 
 | ||||||
|     def transport( |     def transport( | ||||||
|         self, |         self, | ||||||
|  | @ -83,7 +91,8 @@ class StackLevelAdapter(logging.LoggerAdapter): | ||||||
| 
 | 
 | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         ''' |         ''' | ||||||
|         IPC level msg-ing. |         IPC transport level msg IO; generally anything below | ||||||
|  |         `._ipc.Channel` and friends. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         return self.log(5, msg) |         return self.log(5, msg) | ||||||
|  | @ -99,29 +108,67 @@ class StackLevelAdapter(logging.LoggerAdapter): | ||||||
|         msg: str, |         msg: str, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         ''' |         ''' | ||||||
|         Cancellation logging, mostly for runtime reporting. |         Cancellation sequencing, mostly for runtime reporting. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         return self.log(16, msg) |         return self.log( | ||||||
|  |             level=22, | ||||||
|  |             msg=msg, | ||||||
|  |             # stacklevel=4, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     def pdb( |     def pdb( | ||||||
|         self, |         self, | ||||||
|         msg: str, |         msg: str, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         ''' |         ''' | ||||||
|         Debugger logging. |         `pdb`-REPL (debugger) related statuses. | ||||||
| 
 | 
 | ||||||
|         ''' |         ''' | ||||||
|         return self.log(500, msg) |         return self.log(500, msg) | ||||||
| 
 | 
 | ||||||
|     def log(self, level, msg, *args, **kwargs): |     def devx( | ||||||
|         """ |         self, | ||||||
|  |         msg: str, | ||||||
|  |     ) -> None: | ||||||
|  |         ''' | ||||||
|  |         "Developer experience" sub-sys statuses. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         return self.log(17, msg) | ||||||
|  | 
 | ||||||
|  |     def log( | ||||||
|  |         self, | ||||||
|  |         level, | ||||||
|  |         msg, | ||||||
|  |         *args, | ||||||
|  |         **kwargs, | ||||||
|  |     ): | ||||||
|  |         ''' | ||||||
|         Delegate a log call to the underlying logger, after adding |         Delegate a log call to the underlying logger, after adding | ||||||
|         contextual information from this adapter instance. |         contextual information from this adapter instance. | ||||||
|         """ | 
 | ||||||
|  |         NOTE: all custom level methods (above) delegate to this! | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|         if self.isEnabledFor(level): |         if self.isEnabledFor(level): | ||||||
|  |             stacklevel: int = 3 | ||||||
|  |             if ( | ||||||
|  |                 level in CUSTOM_LEVELS.values() | ||||||
|  |             ): | ||||||
|  |                 stacklevel: int = 4 | ||||||
|  | 
 | ||||||
|             # msg, kwargs = self.process(msg, kwargs) |             # msg, kwargs = self.process(msg, kwargs) | ||||||
|             self._log(level, msg, args, **kwargs) |             self._log( | ||||||
|  |                 level=level, | ||||||
|  |                 msg=msg, | ||||||
|  |                 args=args, | ||||||
|  |                 # NOTE: not sure how this worked before but, it | ||||||
|  |                 # seems with our custom level methods defined above | ||||||
|  |                 # we do indeed (now) require another stack level?? | ||||||
|  |                 stacklevel=stacklevel, | ||||||
|  |                 **kwargs, | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|     # LOL, the stdlib doesn't allow passing through ``stacklevel``.. |     # LOL, the stdlib doesn't allow passing through ``stacklevel``.. | ||||||
|     def _log( |     def _log( | ||||||
|  | @ -134,12 +181,15 @@ class StackLevelAdapter(logging.LoggerAdapter): | ||||||
|         stack_info=False, |         stack_info=False, | ||||||
| 
 | 
 | ||||||
|         # XXX: bit we added to show fileinfo from actual caller. |         # XXX: bit we added to show fileinfo from actual caller. | ||||||
|         # this level then ``.log()`` then finally the caller's level.. |         # - this level | ||||||
|         stacklevel=3, |         # - then ``.log()`` | ||||||
|  |         # - then finally the caller's level.. | ||||||
|  |         stacklevel=4, | ||||||
|     ): |     ): | ||||||
|         """ |         ''' | ||||||
|         Low-level log implementation, proxied to allow nested logger adapters. |         Low-level log implementation, proxied to allow nested logger adapters. | ||||||
|         """ | 
 | ||||||
|  |         ''' | ||||||
|         return self.logger._log( |         return self.logger._log( | ||||||
|             level, |             level, | ||||||
|             msg, |             msg, | ||||||
|  | @ -151,8 +201,30 @@ class StackLevelAdapter(logging.LoggerAdapter): | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | # TODO IDEAs: | ||||||
|  | # -[ ] move to `.devx.pformat`? | ||||||
|  | # -[ ] do per task-name and actor-name color coding | ||||||
|  | # -[ ] unique color per task-id and actor-uuid | ||||||
|  | def pformat_task_uid( | ||||||
|  |     id_part: str = 'tail' | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Return `str`-ified unique for a `trio.Task` via a combo of its | ||||||
|  |     `.name: str` and `id()` truncated output. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     task: trio.Task = trio.lowlevel.current_task() | ||||||
|  |     tid: str = str(id(task)) | ||||||
|  |     if id_part == 'tail': | ||||||
|  |         tid_part: str = tid[-6:] | ||||||
|  |     else: | ||||||
|  |         tid_part: str = tid[:6] | ||||||
|  | 
 | ||||||
|  |     return f'{task.name}[{tid_part}]' | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| _conc_name_getters = { | _conc_name_getters = { | ||||||
|     'task': lambda: trio.lowlevel.current_task().name, |     'task': pformat_task_uid, | ||||||
|     'actor': lambda: current_actor(), |     'actor': lambda: current_actor(), | ||||||
|     'actor_name': lambda: current_actor().name, |     'actor_name': lambda: current_actor().name, | ||||||
|     'actor_uid': lambda: current_actor().uid[1][:6], |     'actor_uid': lambda: current_actor().uid[1][:6], | ||||||
|  | @ -160,7 +232,10 @@ _conc_name_getters = { | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ActorContextInfo(Mapping): | class ActorContextInfo(Mapping): | ||||||
|     "Dyanmic lookup for local actor and task names" |     ''' | ||||||
|  |     Dyanmic lookup for local actor and task names. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|     _context_keys = ( |     _context_keys = ( | ||||||
|         'task', |         'task', | ||||||
|         'actor', |         'actor', | ||||||
|  | @ -183,33 +258,69 @@ class ActorContextInfo(Mapping): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_logger( | def get_logger( | ||||||
| 
 |     name: str|None = None, | ||||||
|     name: str | None = None, |  | ||||||
|     _root_name: str = _proj_name, |     _root_name: str = _proj_name, | ||||||
| 
 | 
 | ||||||
|  |     logger: Logger|None = None, | ||||||
|  | 
 | ||||||
|  |     # TODO, using `.config.dictConfig()` api? | ||||||
|  |     # -[ ] SO answer with docs links | ||||||
|  |     #  |_https://stackoverflow.com/questions/7507825/where-is-a-complete-example-of-logging-config-dictconfig | ||||||
|  |     #  |_https://docs.python.org/3/library/logging.config.html#configuration-dictionary-schema | ||||||
|  |     subsys_spec: str|None = None, | ||||||
|  | 
 | ||||||
| ) -> StackLevelAdapter: | ) -> StackLevelAdapter: | ||||||
|     '''Return the package log or a sub-logger for ``name`` if provided. |     '''Return the package log or a sub-logger for ``name`` if provided. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     log = rlog = logging.getLogger(_root_name) |     log: Logger | ||||||
|  |     log = rlog = logger or logging.getLogger(_root_name) | ||||||
| 
 | 
 | ||||||
|     if name and name != _proj_name: |     if ( | ||||||
|  |         name | ||||||
|  |         and | ||||||
|  |         name != _proj_name | ||||||
|  |     ): | ||||||
| 
 | 
 | ||||||
|         # handling for modules that use ``get_logger(__name__)`` to |         # NOTE: for handling for modules that use ``get_logger(__name__)`` | ||||||
|         # avoid duplicate project-package token in msg output |         # we make the following stylistic choice: | ||||||
|         rname, _, tail = name.partition('.') |         # - always avoid duplicate project-package token | ||||||
|         if rname == _root_name: |         #   in msg output: i.e. tractor.tractor _ipc.py in header | ||||||
|             name = tail |         #   looks ridiculous XD | ||||||
|  |         # - never show the leaf module name in the {name} part | ||||||
|  |         #   since in python the {filename} is always this same | ||||||
|  |         #   module-file. | ||||||
|  | 
 | ||||||
|  |         sub_name: None|str = None | ||||||
|  |         rname, _, sub_name = name.partition('.') | ||||||
|  |         pkgpath, _, modfilename = sub_name.rpartition('.') | ||||||
|  | 
 | ||||||
|  |         # NOTE: for tractor itself never include the last level | ||||||
|  |         # module key in the name such that something like: eg. | ||||||
|  |         # 'tractor.trionics._broadcast` only includes the first | ||||||
|  |         # 2 tokens in the (coloured) name part. | ||||||
|  |         if rname == 'tractor': | ||||||
|  |             sub_name = pkgpath | ||||||
|  | 
 | ||||||
|  |         if _root_name in sub_name: | ||||||
|  |             duplicate, _, sub_name = sub_name.partition('.') | ||||||
|  | 
 | ||||||
|  |         if not sub_name: | ||||||
|  |             log = rlog | ||||||
|  |         else: | ||||||
|  |             log = rlog.getChild(sub_name) | ||||||
| 
 | 
 | ||||||
|         log = rlog.getChild(name) |  | ||||||
|         log.level = rlog.level |         log.level = rlog.level | ||||||
| 
 | 
 | ||||||
|     # add our actor-task aware adapter which will dynamically look up |     # add our actor-task aware adapter which will dynamically look up | ||||||
|     # the actor and task names at each log emit |     # the actor and task names at each log emit | ||||||
|     logger = StackLevelAdapter(log, ActorContextInfo()) |     logger = StackLevelAdapter( | ||||||
|  |         log, | ||||||
|  |         ActorContextInfo(), | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     # additional levels |     # additional levels | ||||||
|     for name, val in LEVELS.items(): |     for name, val in CUSTOM_LEVELS.items(): | ||||||
|         logging.addLevelName(val, name) |         logging.addLevelName(val, name) | ||||||
| 
 | 
 | ||||||
|         # ensure customs levels exist as methods |         # ensure customs levels exist as methods | ||||||
|  | @ -219,28 +330,50 @@ def get_logger( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_console_log( | def get_console_log( | ||||||
|     level: str | None = None, |     level: str|None = None, | ||||||
|  |     logger: Logger|None = None, | ||||||
|     **kwargs, |     **kwargs, | ||||||
| ) -> logging.LoggerAdapter: |  | ||||||
|     '''Get the package logger and enable a handler which writes to stderr. |  | ||||||
| 
 | 
 | ||||||
|     Yeah yeah, i know we can use ``DictConfig``. You do it. | ) -> LoggerAdapter: | ||||||
|     ''' |     ''' | ||||||
|     log = get_logger(**kwargs)  # our root logger |     Get a `tractor`-style logging instance: a `Logger` wrapped in | ||||||
|     logger = log.logger |     a `StackLevelAdapter` which injects various concurrency-primitive | ||||||
|  |     (process, thread, task) fields and enables a `StreamHandler` that | ||||||
|  |     writes on stderr using `colorlog` formatting. | ||||||
|  | 
 | ||||||
|  |     Yeah yeah, i know we can use `logging.config.dictConfig()`. You do it. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     log = get_logger( | ||||||
|  |         logger=logger, | ||||||
|  |         **kwargs | ||||||
|  |     )  # set a root logger | ||||||
|  |     logger: Logger = log.logger | ||||||
| 
 | 
 | ||||||
|     if not level: |     if not level: | ||||||
|         return log |         return log | ||||||
| 
 | 
 | ||||||
|     log.setLevel(level.upper() if not isinstance(level, int) else level) |     log.setLevel( | ||||||
|  |         level.upper() | ||||||
|  |         if not isinstance(level, int) | ||||||
|  |         else level | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     if not any( |     if not any( | ||||||
|         handler.stream == sys.stderr  # type: ignore |         handler.stream == sys.stderr  # type: ignore | ||||||
|         for handler in logger.handlers if getattr(handler, 'stream', None) |         for handler in logger.handlers if getattr( | ||||||
|  |             handler, | ||||||
|  |             'stream', | ||||||
|  |             None, | ||||||
|  |         ) | ||||||
|     ): |     ): | ||||||
|         handler = logging.StreamHandler() |         fmt = LOG_FORMAT | ||||||
|  |         # if logger: | ||||||
|  |         #     fmt = None | ||||||
|  | 
 | ||||||
|  |         handler = StreamHandler() | ||||||
|         formatter = colorlog.ColoredFormatter( |         formatter = colorlog.ColoredFormatter( | ||||||
|             LOG_FORMAT, |             fmt=fmt, | ||||||
|             datefmt=DATE_FORMAT, |             datefmt=DATE_FORMAT, | ||||||
|             log_colors=STD_PALETTE, |             log_colors=STD_PALETTE, | ||||||
|             secondary_log_colors=BOLD_PALETTE, |             secondary_log_colors=BOLD_PALETTE, | ||||||
|  | @ -254,3 +387,23 @@ def get_console_log( | ||||||
| 
 | 
 | ||||||
| def get_loglevel() -> str: | def get_loglevel() -> str: | ||||||
|     return _default_loglevel |     return _default_loglevel | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # global module logger for tractor itself | ||||||
|  | log: StackLevelAdapter = get_logger('tractor') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def at_least_level( | ||||||
|  |     log: Logger|LoggerAdapter, | ||||||
|  |     level: int|str, | ||||||
|  | ) -> bool: | ||||||
|  |     ''' | ||||||
|  |     Predicate to test if a given level is active. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     if isinstance(level, str): | ||||||
|  |         level: int = CUSTOM_LEVELS[level.upper()] | ||||||
|  | 
 | ||||||
|  |     if log.getEffectiveLevel() <= level: | ||||||
|  |         return True | ||||||
|  |     return False | ||||||
|  |  | ||||||
|  | @ -1,80 +0,0 @@ | ||||||
| # tractor: structured concurrent "actors". |  | ||||||
| # Copyright 2018-eternity Tyler Goodlet. |  | ||||||
| 
 |  | ||||||
| # This program is free software: you can redistribute it and/or modify |  | ||||||
| # it under the terms of the GNU Affero General Public License as published by |  | ||||||
| # the Free Software Foundation, either version 3 of the License, or |  | ||||||
| # (at your option) any later version. |  | ||||||
| 
 |  | ||||||
| # This program is distributed in the hope that it will be useful, |  | ||||||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the |  | ||||||
| # GNU Affero General Public License for more details. |  | ||||||
| 
 |  | ||||||
| # You should have received a copy of the GNU Affero General Public License |  | ||||||
| # along with this program.  If not, see <https://www.gnu.org/licenses/>. |  | ||||||
| 
 |  | ||||||
| ''' |  | ||||||
| Built-in messaging patterns, types, APIs and helpers. |  | ||||||
| 
 |  | ||||||
| ''' |  | ||||||
| 
 |  | ||||||
| # TODO: integration with our ``enable_modules: list[str]`` caps sys. |  | ||||||
| 
 |  | ||||||
| # ``pkgutil.resolve_name()`` internally uses |  | ||||||
| # ``importlib.import_module()`` which can be filtered by inserting |  | ||||||
| # a ``MetaPathFinder`` into ``sys.meta_path`` (which we could do before |  | ||||||
| # entering the ``_runtime.process_messages()`` loop). |  | ||||||
| # - https://github.com/python/cpython/blob/main/Lib/pkgutil.py#L645 |  | ||||||
| # - https://stackoverflow.com/questions/1350466/preventing-python-code-from-importing-certain-modules |  | ||||||
| #   - https://stackoverflow.com/a/63320902 |  | ||||||
| #   - https://docs.python.org/3/library/sys.html#sys.meta_path |  | ||||||
| 
 |  | ||||||
| # the new "Implicit Namespace Packages" might be relevant? |  | ||||||
| # - https://www.python.org/dev/peps/pep-0420/ |  | ||||||
| 
 |  | ||||||
| # add implicit serialized message type support so that paths can be |  | ||||||
| # handed directly to IPC primitives such as streams and `Portal.run()` |  | ||||||
| # calls: |  | ||||||
| # - via ``msgspec``: |  | ||||||
| #   - https://jcristharif.com/msgspec/api.html#struct |  | ||||||
| #   - https://jcristharif.com/msgspec/extending.html |  | ||||||
| # via ``msgpack-python``: |  | ||||||
| # - https://github.com/msgpack/msgpack-python#packingunpacking-of-custom-data-type |  | ||||||
| 
 |  | ||||||
| from __future__ import annotations |  | ||||||
| from pkgutil import resolve_name |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class NamespacePath(str): |  | ||||||
|     ''' |  | ||||||
|     A serializeable description of a (function) Python object location |  | ||||||
|     described by the target's module path and namespace key meant as |  | ||||||
|     a message-native "packet" to allows actors to point-and-load objects |  | ||||||
|     by absolute reference. |  | ||||||
| 
 |  | ||||||
|     ''' |  | ||||||
|     _ref: object = None |  | ||||||
| 
 |  | ||||||
|     def load_ref(self) -> object: |  | ||||||
|         if self._ref is None: |  | ||||||
|             self._ref = resolve_name(self) |  | ||||||
|         return self._ref |  | ||||||
| 
 |  | ||||||
|     def to_tuple( |  | ||||||
|         self, |  | ||||||
| 
 |  | ||||||
|     ) -> tuple[str, str]: |  | ||||||
|         ref = self.load_ref() |  | ||||||
|         return ref.__module__, getattr(ref, '__name__', '') |  | ||||||
| 
 |  | ||||||
|     @classmethod |  | ||||||
|     def from_ref( |  | ||||||
|         cls, |  | ||||||
|         ref, |  | ||||||
| 
 |  | ||||||
|     ) -> NamespacePath: |  | ||||||
|         return cls(':'.join( |  | ||||||
|             (ref.__module__, |  | ||||||
|              getattr(ref, '__name__', '')) |  | ||||||
|         )) |  | ||||||
|  | @ -0,0 +1,73 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Built-in messaging patterns, types, APIs and helpers. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from typing import ( | ||||||
|  |     TypeAlias, | ||||||
|  | ) | ||||||
|  | from .ptr import ( | ||||||
|  |     NamespacePath as NamespacePath, | ||||||
|  | ) | ||||||
|  | from .pretty_struct import ( | ||||||
|  |     Struct as Struct, | ||||||
|  | ) | ||||||
|  | from ._codec import ( | ||||||
|  |     _def_msgspec_codec as _def_msgspec_codec, | ||||||
|  |     _ctxvar_MsgCodec as _ctxvar_MsgCodec, | ||||||
|  | 
 | ||||||
|  |     apply_codec as apply_codec, | ||||||
|  |     mk_codec as mk_codec, | ||||||
|  |     MsgCodec as MsgCodec, | ||||||
|  |     MsgDec as MsgDec, | ||||||
|  |     current_codec as current_codec, | ||||||
|  | ) | ||||||
|  | # currently can't bc circular with `._context` | ||||||
|  | # from ._ops import ( | ||||||
|  | #     PldRx as PldRx, | ||||||
|  | #     _drain_to_final_msg as _drain_to_final_msg, | ||||||
|  | # ) | ||||||
|  | 
 | ||||||
|  | from .types import ( | ||||||
|  |     PayloadMsg as PayloadMsg, | ||||||
|  | 
 | ||||||
|  |     Aid as Aid, | ||||||
|  |     SpawnSpec as SpawnSpec, | ||||||
|  | 
 | ||||||
|  |     Start as Start, | ||||||
|  |     StartAck as StartAck, | ||||||
|  | 
 | ||||||
|  |     Started as Started, | ||||||
|  |     Yield as Yield, | ||||||
|  |     Stop as Stop, | ||||||
|  |     Return as Return, | ||||||
|  |     CancelAck as CancelAck, | ||||||
|  | 
 | ||||||
|  |     Error as Error, | ||||||
|  | 
 | ||||||
|  |     # type-var for `.pld` field | ||||||
|  |     PayloadT as PayloadT, | ||||||
|  | 
 | ||||||
|  |     # full msg class set from above as list | ||||||
|  |     __msg_types__ as __msg_types__, | ||||||
|  | 
 | ||||||
|  |     # type-alias for union of all msgs | ||||||
|  |     MsgType as MsgType, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | __msg_spec__: TypeAlias = MsgType | ||||||
|  | @ -0,0 +1,699 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | IPC msg interchange codec management. | ||||||
|  | 
 | ||||||
|  | Supported backend libs: | ||||||
|  | - `msgspec.msgpack` | ||||||
|  | 
 | ||||||
|  | ToDo: backends we prolly should offer: | ||||||
|  | 
 | ||||||
|  | - see project/lib list throughout GH issue discussion comments: | ||||||
|  |   https://github.com/goodboy/tractor/issues/196 | ||||||
|  | 
 | ||||||
|  | - `capnproto`: https://capnproto.org/rpc.html | ||||||
|  |    - https://capnproto.org/language.html#language-reference | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from contextlib import ( | ||||||
|  |     contextmanager as cm, | ||||||
|  | ) | ||||||
|  | from contextvars import ( | ||||||
|  |     ContextVar, | ||||||
|  |     Token, | ||||||
|  | ) | ||||||
|  | import textwrap | ||||||
|  | from typing import ( | ||||||
|  |     Any, | ||||||
|  |     Callable, | ||||||
|  |     Protocol, | ||||||
|  |     Type, | ||||||
|  |     TYPE_CHECKING, | ||||||
|  |     TypeVar, | ||||||
|  |     Union, | ||||||
|  | ) | ||||||
|  | from types import ModuleType | ||||||
|  | 
 | ||||||
|  | import msgspec | ||||||
|  | from msgspec import ( | ||||||
|  |     msgpack, | ||||||
|  |     Raw, | ||||||
|  | ) | ||||||
|  | # TODO: see notes below from @mikenerone.. | ||||||
|  | # from tricycle import TreeVar | ||||||
|  | 
 | ||||||
|  | from tractor.msg.pretty_struct import Struct | ||||||
|  | from tractor.msg.types import ( | ||||||
|  |     mk_msg_spec, | ||||||
|  |     MsgType, | ||||||
|  | ) | ||||||
|  | from tractor.log import get_logger | ||||||
|  | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from tractor._context import Context | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: unify with `MsgCodec` by making `._dec` part this? | ||||||
|  | class MsgDec(Struct): | ||||||
|  |     ''' | ||||||
|  |     An IPC msg (payload) decoder. | ||||||
|  | 
 | ||||||
|  |     Normally used to decode only a payload: `MsgType.pld: | ||||||
|  |     PayloadT` field before delivery to IPC consumer code. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     _dec: msgpack.Decoder | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def dec(self) -> msgpack.Decoder: | ||||||
|  |         return self._dec | ||||||
|  | 
 | ||||||
|  |     def __repr__(self) -> str: | ||||||
|  | 
 | ||||||
|  |         speclines: str = self.spec_str | ||||||
|  | 
 | ||||||
|  |         # in multi-typed spec case we stick the list | ||||||
|  |         # all on newlines after the |__pld_spec__:, | ||||||
|  |         # OW it's prolly single type spec-value | ||||||
|  |         # so just leave it on same line. | ||||||
|  |         if '\n' in speclines: | ||||||
|  |             speclines: str = '\n' + textwrap.indent( | ||||||
|  |                 speclines, | ||||||
|  |                 prefix=' '*3, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         body: str = textwrap.indent( | ||||||
|  |             f'|_dec_hook: {self.dec.dec_hook}\n' | ||||||
|  |             f'|__pld_spec__: {speclines}\n', | ||||||
|  |             prefix=' '*2, | ||||||
|  |         ) | ||||||
|  |         return ( | ||||||
|  |             f'<{type(self).__name__}(\n' | ||||||
|  |             f'{body}' | ||||||
|  |             ')>' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     # struct type unions | ||||||
|  |     # https://jcristharif.com/msgspec/structs.html#tagged-unions | ||||||
|  |     # | ||||||
|  |     # ^-TODO-^: make a wrapper type for this such that alt | ||||||
|  |     # backends can be represented easily without a `Union` needed, | ||||||
|  |     # AND so that we have better support for wire transport. | ||||||
|  |     # | ||||||
|  |     # -[ ] maybe `FieldSpec` is a good name since msg-spec | ||||||
|  |     #   better applies to a `MsgType[FieldSpec]`? | ||||||
|  |     # | ||||||
|  |     # -[ ] both as part of the `.open_context()` call AND as part of the | ||||||
|  |     #     immediate ack-reponse (see similar below) | ||||||
|  |     #     we should do spec matching and fail if anything is awry? | ||||||
|  |     # | ||||||
|  |     # -[ ] eventually spec should be generated/parsed from the | ||||||
|  |     #     type-annots as # desired in GH issue: | ||||||
|  |     #     https://github.com/goodboy/tractor/issues/365 | ||||||
|  |     # | ||||||
|  |     # -[ ] semantics of the mismatch case | ||||||
|  |     #   - when caller-callee specs we should raise | ||||||
|  |     #    a `MsgTypeError` or `MsgSpecError` or similar? | ||||||
|  |     # | ||||||
|  |     # -[ ] wrapper types for both spec types such that we can easily | ||||||
|  |     #     IPC transport them? | ||||||
|  |     #     - `TypeSpec: Union[Type]` | ||||||
|  |     #      * also a `.__contains__()` for doing `None in | ||||||
|  |     #      TypeSpec[None|int]` since rn you need to do it on | ||||||
|  |     #      `.__args__` for unions.. | ||||||
|  |     #     - `MsgSpec: Union[MsgType] | ||||||
|  |     # | ||||||
|  |     # -[ ] auto-genning this from new (in 3.12) type parameter lists Bo | ||||||
|  |     # |_ https://docs.python.org/3/reference/compound_stmts.html#type-params | ||||||
|  |     # |_ historical pep 695: https://peps.python.org/pep-0695/ | ||||||
|  |     # |_ full lang spec: https://typing.readthedocs.io/en/latest/spec/ | ||||||
|  |     # |_ on annotation scopes: | ||||||
|  |     #    https://docs.python.org/3/reference/executionmodel.html#annotation-scopes | ||||||
|  |     # |_ 3.13 will have subscriptable funcs Bo | ||||||
|  |     #    https://peps.python.org/pep-0718/ | ||||||
|  |     @property | ||||||
|  |     def spec(self) -> Union[Type[Struct]]: | ||||||
|  |         # NOTE: defined and applied inside `mk_codec()` | ||||||
|  |         return self._dec.type | ||||||
|  | 
 | ||||||
|  |     # no difference, as compared to a `MsgCodec` which defines the | ||||||
|  |     # `MsgType.pld: PayloadT` part of its spec separately | ||||||
|  |     pld_spec = spec | ||||||
|  | 
 | ||||||
|  |     # TODO: would get moved into `FieldSpec.__str__()` right? | ||||||
|  |     @property | ||||||
|  |     def spec_str(self) -> str: | ||||||
|  |         return pformat_msgspec( | ||||||
|  |             codec=self, | ||||||
|  |             join_char='|', | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     pld_spec_str = spec_str | ||||||
|  | 
 | ||||||
|  |     def decode( | ||||||
|  |         self, | ||||||
|  |         raw: Raw|bytes, | ||||||
|  |     ) -> Any: | ||||||
|  |         return self._dec.decode(raw) | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def hook(self) -> Callable|None: | ||||||
|  |         return self._dec.dec_hook | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def mk_dec( | ||||||
|  |     spec: Union[Type[Struct]]|Any = Any, | ||||||
|  |     dec_hook: Callable|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> MsgDec: | ||||||
|  |     ''' | ||||||
|  |     Create an IPC msg decoder, normally used as the | ||||||
|  |     `PayloadMsg.pld: PayloadT` field decoder inside a `PldRx`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     return MsgDec( | ||||||
|  |         _dec=msgpack.Decoder( | ||||||
|  |             type=spec,  # like `MsgType[Any]` | ||||||
|  |             dec_hook=dec_hook, | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def mk_msgspec_table( | ||||||
|  |     dec: msgpack.Decoder, | ||||||
|  |     msg: MsgType|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> dict[str, MsgType]|str: | ||||||
|  |     ''' | ||||||
|  |     Fill out a `dict` of `MsgType`s keyed by name | ||||||
|  |     for a given input `msgspec.msgpack.Decoder` | ||||||
|  |     as defined by its `.type: Union[Type]` setting. | ||||||
|  | 
 | ||||||
|  |     If `msg` is provided, only deliver a `dict` with a single | ||||||
|  |     entry for that type. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     msgspec: Union[Type]|Type = dec.type | ||||||
|  | 
 | ||||||
|  |     if not (msgtypes := getattr(msgspec, '__args__', False)): | ||||||
|  |         msgtypes = [msgspec] | ||||||
|  | 
 | ||||||
|  |     msgt_table: dict[str, MsgType] = { | ||||||
|  |         msgt: str(msgt.__name__) | ||||||
|  |         for msgt in msgtypes | ||||||
|  |     } | ||||||
|  |     if msg: | ||||||
|  |         msgt: MsgType = type(msg) | ||||||
|  |         str_repr: str = msgt_table[msgt] | ||||||
|  |         return {msgt: str_repr} | ||||||
|  | 
 | ||||||
|  |     return msgt_table | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def pformat_msgspec( | ||||||
|  |     codec: MsgCodec|MsgDec, | ||||||
|  |     msg: MsgType|None = None, | ||||||
|  |     join_char: str = '\n', | ||||||
|  | 
 | ||||||
|  | ) -> str: | ||||||
|  |     ''' | ||||||
|  |     Pretty `str` format the `msgspec.msgpack.Decoder.type` attribute | ||||||
|  |     for display in (console) log messages as a nice (maybe multiline) | ||||||
|  |     presentation of all supported `Struct`s (subtypes) available for | ||||||
|  |     typed decoding. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     dec: msgpack.Decoder = getattr(codec, 'dec', codec) | ||||||
|  |     return join_char.join( | ||||||
|  |         mk_msgspec_table( | ||||||
|  |             dec=dec, | ||||||
|  |             msg=msg, | ||||||
|  |         ).values() | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | # TODO: overall IPC msg-spec features (i.e. in this mod)! | ||||||
|  | # | ||||||
|  | # -[ ] API changes towards being interchange lib agnostic! | ||||||
|  | #   -[ ] capnproto has pre-compiled schema for eg.. | ||||||
|  | #    * https://capnproto.org/language.html | ||||||
|  | #    * http://capnproto.github.io/pycapnp/quickstart.html | ||||||
|  | #     * https://github.com/capnproto/pycapnp/blob/master/examples/addressbook.capnp | ||||||
|  | # | ||||||
|  | # -[ ] struct aware messaging coders as per: | ||||||
|  | #   -[x] https://github.com/goodboy/tractor/issues/36 | ||||||
|  | #   -[ ] https://github.com/goodboy/tractor/issues/196 | ||||||
|  | #   -[ ] https://github.com/goodboy/tractor/issues/365 | ||||||
|  | # | ||||||
|  | class MsgCodec(Struct): | ||||||
|  |     ''' | ||||||
|  |     A IPC msg interchange format lib's encoder + decoder pair. | ||||||
|  | 
 | ||||||
|  |     Pretty much nothing more then delegation to underlying | ||||||
|  |     `msgspec.<interchange-protocol>.Encoder/Decoder`s for now. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     _enc: msgpack.Encoder | ||||||
|  |     _dec: msgpack.Decoder | ||||||
|  |     _pld_spec: Type[Struct]|Raw|Any | ||||||
|  | 
 | ||||||
|  |     def __repr__(self) -> str: | ||||||
|  |         speclines: str = textwrap.indent( | ||||||
|  |             pformat_msgspec(codec=self), | ||||||
|  |             prefix=' '*3, | ||||||
|  |         ) | ||||||
|  |         body: str = textwrap.indent( | ||||||
|  |             f'|_lib = {self.lib.__name__!r}\n' | ||||||
|  |             f'|_enc_hook: {self.enc.enc_hook}\n' | ||||||
|  |             f'|_dec_hook: {self.dec.dec_hook}\n' | ||||||
|  |             f'|_pld_spec: {self.pld_spec_str}\n' | ||||||
|  |             # f'|\n' | ||||||
|  |             f'|__msg_spec__:\n' | ||||||
|  |             f'{speclines}\n', | ||||||
|  |             prefix=' '*2, | ||||||
|  |         ) | ||||||
|  |         return ( | ||||||
|  |             f'<{type(self).__name__}(\n' | ||||||
|  |             f'{body}' | ||||||
|  |             ')>' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def pld_spec(self) -> Type[Struct]|Raw|Any: | ||||||
|  |         return self._pld_spec | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def pld_spec_str(self) -> str: | ||||||
|  | 
 | ||||||
|  |         # TODO: could also use match: instead? | ||||||
|  |         spec: Union[Type]|Type = self.pld_spec | ||||||
|  | 
 | ||||||
|  |         # `typing.Union` case | ||||||
|  |         if getattr(spec, '__args__', False): | ||||||
|  |             return str(spec) | ||||||
|  | 
 | ||||||
|  |         # just a single type | ||||||
|  |         else: | ||||||
|  |             return spec.__name__ | ||||||
|  | 
 | ||||||
|  |     # struct type unions | ||||||
|  |     # https://jcristharif.com/msgspec/structs.html#tagged-unions | ||||||
|  |     @property | ||||||
|  |     def msg_spec(self) -> Union[Type[Struct]]: | ||||||
|  |         # NOTE: defined and applied inside `mk_codec()` | ||||||
|  |         return self._dec.type | ||||||
|  | 
 | ||||||
|  |     # TODO: some way to make `pretty_struct.Struct` use this | ||||||
|  |     # wrapped field over the `.msg_spec` one? | ||||||
|  |     @property | ||||||
|  |     def msg_spec_str(self) -> str: | ||||||
|  |         return pformat_msgspec(self.msg_spec) | ||||||
|  | 
 | ||||||
|  |     lib: ModuleType = msgspec | ||||||
|  | 
 | ||||||
|  |     # TODO: use `functools.cached_property` for these ? | ||||||
|  |     # https://docs.python.org/3/library/functools.html#functools.cached_property | ||||||
|  |     @property | ||||||
|  |     def enc(self) -> msgpack.Encoder: | ||||||
|  |         return self._enc | ||||||
|  | 
 | ||||||
|  |     # TODO: reusing encode buffer for perf? | ||||||
|  |     # https://jcristharif.com/msgspec/perf-tips.html#reusing-an-output-buffer | ||||||
|  |     _buf: bytearray = bytearray() | ||||||
|  | 
 | ||||||
|  |     def encode( | ||||||
|  |         self, | ||||||
|  |         py_obj: Any, | ||||||
|  | 
 | ||||||
|  |         use_buf: bool = False, | ||||||
|  |         # ^-XXX-^ uhh why am i getting this? | ||||||
|  |         # |_BufferError: Existing exports of data: object cannot be re-sized | ||||||
|  | 
 | ||||||
|  |     ) -> bytes: | ||||||
|  |         ''' | ||||||
|  |         Encode input python objects to `msgpack` bytes for | ||||||
|  |         transfer on a tranport protocol connection. | ||||||
|  | 
 | ||||||
|  |         When `use_buf == True` use the output buffer optimization: | ||||||
|  |         https://jcristharif.com/msgspec/perf-tips.html#reusing-an-output-buffer | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         if use_buf: | ||||||
|  |             self._enc.encode_into(py_obj, self._buf) | ||||||
|  |             return self._buf | ||||||
|  |         else: | ||||||
|  |             return self._enc.encode(py_obj) | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def dec(self) -> msgpack.Decoder: | ||||||
|  |         return self._dec | ||||||
|  | 
 | ||||||
|  |     def decode( | ||||||
|  |         self, | ||||||
|  |         msg: bytes, | ||||||
|  |     ) -> Any: | ||||||
|  |         ''' | ||||||
|  |         Decode received `msgpack` bytes into a local python object | ||||||
|  |         with special `msgspec.Struct` (or other type) handling | ||||||
|  |         determined by the  | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         # https://jcristharif.com/msgspec/usage.html#typed-decoding | ||||||
|  |         return self._dec.decode(msg) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # [x] TODO: a sub-decoder system as well? => No! | ||||||
|  | # | ||||||
|  | # -[x] do we still want to try and support the sub-decoder with | ||||||
|  | # `.Raw` technique in the case that the `Generic` approach gives | ||||||
|  | # future grief? | ||||||
|  | # => NO, since we went with the `PldRx` approach instead B) | ||||||
|  | # | ||||||
|  | # IF however you want to see the code that was staged for this | ||||||
|  | # from wayyy back, see the pure removal commit. | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def mk_codec( | ||||||
|  |     # struct type unions set for `Decoder` | ||||||
|  |     # https://jcristharif.com/msgspec/structs.html#tagged-unions | ||||||
|  |     ipc_pld_spec: Union[Type[Struct]]|Any = Any, | ||||||
|  | 
 | ||||||
|  |     # TODO: offering a per-msg(-field) type-spec such that | ||||||
|  |     # the fields can be dynamically NOT decoded and left as `Raw` | ||||||
|  |     # values which are later loaded by a sub-decoder specified | ||||||
|  |     # by `tag_field: str` value key? | ||||||
|  |     # payload_msg_specs: dict[ | ||||||
|  |     #     str,  # tag_field value as sub-decoder key | ||||||
|  |     #     Union[Type[Struct]]  # `MsgType.pld` type spec | ||||||
|  |     # ]|None = None, | ||||||
|  | 
 | ||||||
|  |     libname: str = 'msgspec', | ||||||
|  | 
 | ||||||
|  |     # proxy as `Struct(**kwargs)` for ad-hoc type extensions | ||||||
|  |     # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types | ||||||
|  |     # ------ - ------ | ||||||
|  |     dec_hook: Callable|None = None, | ||||||
|  |     enc_hook: Callable|None = None, | ||||||
|  |     # ------ - ------ | ||||||
|  |     # | ||||||
|  |     # Encoder: | ||||||
|  |     # write_buffer_size=write_buffer_size, | ||||||
|  |     # | ||||||
|  |     # Decoder: | ||||||
|  |     # ext_hook: ext_hook_sig | ||||||
|  | 
 | ||||||
|  | ) -> MsgCodec: | ||||||
|  |     ''' | ||||||
|  |     Convenience factory for creating codecs eventually meant | ||||||
|  |     to be interchange lib agnostic (i.e. once we support more then just | ||||||
|  |     `msgspec` ;). | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # (manually) generate a msg-payload-spec for all relevant | ||||||
|  |     # god-boxing-msg subtypes, parameterizing the `PayloadMsg.pld: PayloadT` | ||||||
|  |     # for the decoder such that all sub-type msgs in our SCIPP | ||||||
|  |     # will automatically decode to a type-"limited" payload (`Struct`) | ||||||
|  |     # object (set). | ||||||
|  |     ( | ||||||
|  |         ipc_msg_spec, | ||||||
|  |         msg_types, | ||||||
|  |     ) = mk_msg_spec( | ||||||
|  |         payload_type_union=ipc_pld_spec, | ||||||
|  |     ) | ||||||
|  |     assert len(ipc_msg_spec.__args__) == len(msg_types) | ||||||
|  |     assert ipc_msg_spec | ||||||
|  | 
 | ||||||
|  |     # TODO: use this shim instead? | ||||||
|  |     # bc.. unification, err somethin? | ||||||
|  |     # dec: MsgDec = mk_dec( | ||||||
|  |     #     spec=ipc_msg_spec, | ||||||
|  |     #     dec_hook=dec_hook, | ||||||
|  |     # ) | ||||||
|  | 
 | ||||||
|  |     dec = msgpack.Decoder( | ||||||
|  |         type=ipc_msg_spec, | ||||||
|  |         dec_hook=dec_hook, | ||||||
|  |     ) | ||||||
|  |     enc = msgpack.Encoder( | ||||||
|  |        enc_hook=enc_hook, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     codec = MsgCodec( | ||||||
|  |         _enc=enc, | ||||||
|  |         _dec=dec, | ||||||
|  |         _pld_spec=ipc_pld_spec, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # sanity on expected backend support | ||||||
|  |     assert codec.lib.__name__ == libname | ||||||
|  | 
 | ||||||
|  |     return codec | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # instance of the default `msgspec.msgpack` codec settings, i.e. | ||||||
|  | # no custom structs, hooks or other special types. | ||||||
|  | _def_msgspec_codec: MsgCodec = mk_codec(ipc_pld_spec=Any) | ||||||
|  | 
 | ||||||
|  | # The built-in IPC `Msg` spec. | ||||||
|  | # Our composing "shuttle" protocol which allows `tractor`-app code | ||||||
|  | # to use any `msgspec` supported type as the `PayloadMsg.pld` payload, | ||||||
|  | # https://jcristharif.com/msgspec/supported-types.html | ||||||
|  | # | ||||||
|  | _def_tractor_codec: MsgCodec = mk_codec( | ||||||
|  |     # TODO: use this for debug mode locking prot? | ||||||
|  |     # ipc_pld_spec=Any, | ||||||
|  |     ipc_pld_spec=Raw, | ||||||
|  | ) | ||||||
|  | # TODO: IDEALLY provides for per-`trio.Task` specificity of the | ||||||
|  | # IPC msging codec used by the transport layer when doing | ||||||
|  | # `Channel.send()/.recv()` of wire data. | ||||||
|  | 
 | ||||||
|  | # ContextVar-TODO: DIDN'T WORK, kept resetting in every new task to default!? | ||||||
|  | # _ctxvar_MsgCodec: ContextVar[MsgCodec] = ContextVar( | ||||||
|  | 
 | ||||||
|  | # TreeVar-TODO: DIDN'T WORK, kept resetting in every new embedded nursery | ||||||
|  | # even though it's supposed to inherit from a parent context ??? | ||||||
|  | # | ||||||
|  | # _ctxvar_MsgCodec: TreeVar[MsgCodec] = TreeVar( | ||||||
|  | # | ||||||
|  | # ^-NOTE-^: for this to work see the mods by @mikenerone from `trio` gitter: | ||||||
|  | # | ||||||
|  | # 22:02:54 <mikenerone> even for regular contextvars, all you have to do is: | ||||||
|  | #    `task: Task = trio.lowlevel.current_task()` | ||||||
|  | #    `task.parent_nursery.parent_task.context.run(my_ctx_var.set, new_value)` | ||||||
|  | # | ||||||
|  | # From a comment in his prop code he couldn't share outright: | ||||||
|  | # 1. For every TreeVar set in the current task (which covers what | ||||||
|  | #    we need from SynchronizerFacade), walk up the tree until the | ||||||
|  | #    root or finding one where the TreeVar is already set, setting | ||||||
|  | #    it in all of the contexts along the way. | ||||||
|  | # 2. For each of those, we also forcibly set the values that are | ||||||
|  | #    pending for child nurseries that have not yet accessed the | ||||||
|  | #    TreeVar. | ||||||
|  | # 3. We similarly set the pending values for the child nurseries | ||||||
|  | #    of the *current* task. | ||||||
|  | # | ||||||
|  | _ctxvar_MsgCodec: ContextVar[MsgCodec] = ContextVar( | ||||||
|  |     'msgspec_codec', | ||||||
|  |     default=_def_tractor_codec, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @cm | ||||||
|  | def apply_codec( | ||||||
|  |     codec: MsgCodec, | ||||||
|  | 
 | ||||||
|  |     ctx: Context|None = None, | ||||||
|  | 
 | ||||||
|  | ) -> MsgCodec: | ||||||
|  |     ''' | ||||||
|  |     Dynamically apply a `MsgCodec` to the current task's runtime | ||||||
|  |     context such that all (of a certain class of payload | ||||||
|  |     containing i.e. `MsgType.pld: PayloadT`) IPC msgs are | ||||||
|  |     processed with it for that task. | ||||||
|  | 
 | ||||||
|  |     Uses a `contextvars.ContextVar` to ensure the scope of any | ||||||
|  |     codec setting matches the current `Context` or | ||||||
|  |     `._rpc.process_messages()` feeder task's prior setting without | ||||||
|  |     mutating any surrounding scope. | ||||||
|  | 
 | ||||||
|  |     When a `ctx` is supplied, only mod its `Context.pld_codec`. | ||||||
|  | 
 | ||||||
|  |     matches the `@cm` block and DOES NOT change to the original | ||||||
|  |     (default) value in new tasks (as it does for `ContextVar`). | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     __tracebackhide__: bool = True | ||||||
|  | 
 | ||||||
|  |     if ctx is not None: | ||||||
|  |         var: ContextVar = ctx._var_pld_codec | ||||||
|  |     else: | ||||||
|  |         # use IPC channel-connection "global" codec | ||||||
|  |         var: ContextVar = _ctxvar_MsgCodec | ||||||
|  | 
 | ||||||
|  |     orig: MsgCodec = var.get() | ||||||
|  | 
 | ||||||
|  |     assert orig is not codec | ||||||
|  |     if codec.pld_spec is None: | ||||||
|  |         breakpoint() | ||||||
|  | 
 | ||||||
|  |     log.info( | ||||||
|  |         'Applying new msg-spec codec\n\n' | ||||||
|  |         f'{codec}\n' | ||||||
|  |     ) | ||||||
|  |     token: Token = var.set(codec) | ||||||
|  | 
 | ||||||
|  |     # ?TODO? for TreeVar approach which copies from the | ||||||
|  |     # cancel-scope of the prior value, NOT the prior task | ||||||
|  |     # See the docs: | ||||||
|  |     # - https://tricycle.readthedocs.io/en/latest/reference.html#tree-variables | ||||||
|  |     # - https://github.com/oremanj/tricycle/blob/master/tricycle/_tests/test_tree_var.py | ||||||
|  |     #   ^- see docs for @cm `.being()` API | ||||||
|  |     # with _ctxvar_MsgCodec.being(codec): | ||||||
|  |     #     new = _ctxvar_MsgCodec.get() | ||||||
|  |     #     assert new is codec | ||||||
|  |     #     yield codec | ||||||
|  | 
 | ||||||
|  |     try: | ||||||
|  |         yield var.get() | ||||||
|  |     finally: | ||||||
|  |         var.reset(token) | ||||||
|  |         log.info( | ||||||
|  |             'Reverted to last msg-spec codec\n\n' | ||||||
|  |             f'{orig}\n' | ||||||
|  |         ) | ||||||
|  |         assert var.get() is orig | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def current_codec() -> MsgCodec: | ||||||
|  |     ''' | ||||||
|  |     Return the current `trio.Task.context`'s value | ||||||
|  |     for `msgspec_codec` used by `Channel.send/.recv()` | ||||||
|  |     for wire serialization. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     return _ctxvar_MsgCodec.get() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @cm | ||||||
|  | def limit_msg_spec( | ||||||
|  |     payload_spec: Union[Type[Struct]], | ||||||
|  | 
 | ||||||
|  |     # TODO: don't need this approach right? | ||||||
|  |     # -> related to the `MsgCodec._payload_decs` stuff above.. | ||||||
|  |     # tagged_structs: list[Struct]|None = None, | ||||||
|  | 
 | ||||||
|  |     **codec_kwargs, | ||||||
|  | 
 | ||||||
|  | ) -> MsgCodec: | ||||||
|  |     ''' | ||||||
|  |     Apply a `MsgCodec` that will natively decode the SC-msg set's | ||||||
|  |     `PayloadMsg.pld: Union[Type[Struct]]` payload fields using | ||||||
|  |     tagged-unions of `msgspec.Struct`s from the `payload_types` | ||||||
|  |     for all IPC contexts in use by the current `trio.Task`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     __tracebackhide__: bool = True | ||||||
|  |     curr_codec: MsgCodec = current_codec() | ||||||
|  |     msgspec_codec: MsgCodec = mk_codec( | ||||||
|  |         ipc_pld_spec=payload_spec, | ||||||
|  |         **codec_kwargs, | ||||||
|  |     ) | ||||||
|  |     with apply_codec(msgspec_codec) as applied_codec: | ||||||
|  |         assert applied_codec is msgspec_codec | ||||||
|  |         yield msgspec_codec | ||||||
|  | 
 | ||||||
|  |     assert curr_codec is current_codec() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # XXX: msgspec won't allow this with non-struct custom types | ||||||
|  | # like `NamespacePath`!@! | ||||||
|  | # @cm | ||||||
|  | # def extend_msg_spec( | ||||||
|  | #     payload_spec: Union[Type[Struct]], | ||||||
|  | 
 | ||||||
|  | # ) -> MsgCodec: | ||||||
|  | #     ''' | ||||||
|  | #     Extend the current `MsgCodec.pld_spec` (type set) by extending | ||||||
|  | #     the payload spec to **include** the types specified by | ||||||
|  | #     `payload_spec`. | ||||||
|  | 
 | ||||||
|  | #     ''' | ||||||
|  | #     codec: MsgCodec = current_codec() | ||||||
|  | #     pld_spec: Union[Type] = codec.pld_spec | ||||||
|  | #     extended_spec: Union[Type] = pld_spec|payload_spec | ||||||
|  | 
 | ||||||
|  | #     with limit_msg_spec(payload_types=extended_spec) as ext_codec: | ||||||
|  | #         # import pdbp; pdbp.set_trace() | ||||||
|  | #         assert ext_codec.pld_spec == extended_spec | ||||||
|  | #         yield ext_codec | ||||||
|  | # | ||||||
|  | # ^-TODO-^ is it impossible to make something like this orr!? | ||||||
|  | 
 | ||||||
|  | # TODO: make an auto-custom hook generator from a set of input custom | ||||||
|  | # types? | ||||||
|  | # -[ ] below is a proto design using a `TypeCodec` idea? | ||||||
|  | # | ||||||
|  | # type var for the expected interchange-lib's | ||||||
|  | # IPC-transport type when not available as a built-in | ||||||
|  | # serialization output. | ||||||
|  | WireT = TypeVar('WireT') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: some kinda (decorator) API for built-in subtypes | ||||||
|  | # that builds this implicitly by inspecting the `mro()`? | ||||||
|  | class TypeCodec(Protocol): | ||||||
|  |     ''' | ||||||
|  |     A per-custom-type wire-transport serialization translator | ||||||
|  |     description type. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     src_type: Type | ||||||
|  |     wire_type: WireT | ||||||
|  | 
 | ||||||
|  |     def encode(obj: Type) -> WireT: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  |     def decode( | ||||||
|  |         obj_type: Type[WireT], | ||||||
|  |         obj: WireT, | ||||||
|  |     ) -> Type: | ||||||
|  |         ... | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class MsgpackTypeCodec(TypeCodec): | ||||||
|  |     ... | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def mk_codec_hooks( | ||||||
|  |     type_codecs: list[TypeCodec], | ||||||
|  | 
 | ||||||
|  | ) -> tuple[Callable, Callable]: | ||||||
|  |     ''' | ||||||
|  |     Deliver a `enc_hook()`/`dec_hook()` pair which handle | ||||||
|  |     manual convertion from an input `Type` set such that whenever | ||||||
|  |     the `TypeCodec.filter()` predicate matches the | ||||||
|  |     `TypeCodec.decode()` is called on the input native object by | ||||||
|  |     the `dec_hook()` and whenever the | ||||||
|  |     `isiinstance(obj, TypeCodec.type)` matches against an | ||||||
|  |     `enc_hook(obj=obj)` the return value is taken from a | ||||||
|  |     `TypeCodec.encode(obj)` callback. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     ... | ||||||
|  | @ -0,0 +1,842 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Near-application abstractions for `MsgType.pld: PayloadT|Raw` | ||||||
|  | delivery, filtering and type checking as well as generic | ||||||
|  | operational helpers for processing transaction flows. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  |     contextmanager as cm, | ||||||
|  | ) | ||||||
|  | from typing import ( | ||||||
|  |     Any, | ||||||
|  |     Callable, | ||||||
|  |     Type, | ||||||
|  |     TYPE_CHECKING, | ||||||
|  |     Union, | ||||||
|  | ) | ||||||
|  | # ------ - ------ | ||||||
|  | from msgspec import ( | ||||||
|  |     msgpack, | ||||||
|  |     Raw, | ||||||
|  |     Struct, | ||||||
|  |     ValidationError, | ||||||
|  | ) | ||||||
|  | import trio | ||||||
|  | # ------ - ------ | ||||||
|  | from tractor.log import get_logger | ||||||
|  | from tractor._exceptions import ( | ||||||
|  |     MessagingError, | ||||||
|  |     InternalError, | ||||||
|  |     _raise_from_unexpected_msg, | ||||||
|  |     MsgTypeError, | ||||||
|  |     _mk_recv_mte, | ||||||
|  |     pack_error, | ||||||
|  | ) | ||||||
|  | from tractor._state import current_ipc_ctx | ||||||
|  | from ._codec import ( | ||||||
|  |     mk_dec, | ||||||
|  |     MsgDec, | ||||||
|  |     MsgCodec, | ||||||
|  |     current_codec, | ||||||
|  | ) | ||||||
|  | from .types import ( | ||||||
|  |     CancelAck, | ||||||
|  |     Error, | ||||||
|  |     MsgType, | ||||||
|  |     PayloadT, | ||||||
|  |     Return, | ||||||
|  |     Started, | ||||||
|  |     Stop, | ||||||
|  |     Yield, | ||||||
|  |     pretty_struct, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from tractor._context import Context | ||||||
|  |     from tractor._streaming import MsgStream | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | log = get_logger(__name__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | _def_any_pldec: MsgDec[Any] = mk_dec() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class PldRx(Struct): | ||||||
|  |     ''' | ||||||
|  |     A "msg payload receiver". | ||||||
|  | 
 | ||||||
|  |     The pairing of a "feeder" `trio.abc.ReceiveChannel` and an | ||||||
|  |     interchange-specific (eg. msgpack) payload field decoder. The | ||||||
|  |     validation/type-filtering rules are runtime mutable and allow | ||||||
|  |     type constraining the set of `MsgType.pld: Raw|PayloadT` | ||||||
|  |     values at runtime, per IPC task-context. | ||||||
|  | 
 | ||||||
|  |     This abstraction, being just below "user application code", | ||||||
|  |     allows for the equivalent of our `MsgCodec` (used for | ||||||
|  |     typer-filtering IPC dialog protocol msgs against a msg-spec) | ||||||
|  |     but with granular control around payload delivery (i.e. the | ||||||
|  |     data-values user code actually sees and uses (the blobs that | ||||||
|  |     are "shuttled" by the wrapping dialog prot) such that invalid | ||||||
|  |     `.pld: Raw` can be decoded and handled by IPC-primitive user | ||||||
|  |     code (i.e. that operates on `Context` and `Msgstream` APIs) | ||||||
|  |     without knowledge of the lower level `Channel`/`MsgTransport` | ||||||
|  |     primitives nor the `MsgCodec` in use. Further, lazily decoding | ||||||
|  |     payload blobs allows for topical (and maybe intentionally | ||||||
|  |     "partial") encryption of msg field subsets. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # TODO: better to bind it here? | ||||||
|  |     # _rx_mc: trio.MemoryReceiveChannel | ||||||
|  |     _pld_dec: MsgDec | ||||||
|  |     _ctx: Context|None = None | ||||||
|  |     _ipc: Context|MsgStream|None = None | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def pld_dec(self) -> MsgDec: | ||||||
|  |         return self._pld_dec | ||||||
|  | 
 | ||||||
|  |     # TODO: a better name? | ||||||
|  |     # -[ ] when would this be used as it avoids needingn to pass the | ||||||
|  |     #   ipc prim to every method | ||||||
|  |     @cm | ||||||
|  |     def wraps_ipc( | ||||||
|  |         self, | ||||||
|  |         ipc_prim: Context|MsgStream, | ||||||
|  | 
 | ||||||
|  |     ) -> PldRx: | ||||||
|  |         ''' | ||||||
|  |         Apply this payload receiver to an IPC primitive type, one | ||||||
|  |         of `Context` or `MsgStream`. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         self._ipc = ipc_prim | ||||||
|  |         try: | ||||||
|  |             yield self | ||||||
|  |         finally: | ||||||
|  |             self._ipc = None | ||||||
|  | 
 | ||||||
|  |     @cm | ||||||
|  |     def limit_plds( | ||||||
|  |         self, | ||||||
|  |         spec: Union[Type[Struct]], | ||||||
|  |         **dec_kwargs, | ||||||
|  | 
 | ||||||
|  |     ) -> MsgDec: | ||||||
|  |         ''' | ||||||
|  |         Type-limit the loadable msg payloads via an applied | ||||||
|  |         `MsgDec` given an input spec, revert to prior decoder on | ||||||
|  |         exit. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         orig_dec: MsgDec = self._pld_dec | ||||||
|  |         limit_dec: MsgDec = mk_dec( | ||||||
|  |             spec=spec, | ||||||
|  |             **dec_kwargs, | ||||||
|  |         ) | ||||||
|  |         try: | ||||||
|  |             self._pld_dec = limit_dec | ||||||
|  |             yield limit_dec | ||||||
|  |         finally: | ||||||
|  |             self._pld_dec = orig_dec | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def dec(self) -> msgpack.Decoder: | ||||||
|  |         return self._pld_dec.dec | ||||||
|  | 
 | ||||||
|  |     def recv_pld_nowait( | ||||||
|  |         self, | ||||||
|  |         # TODO: make this `MsgStream` compat as well, see above^ | ||||||
|  |         # ipc_prim: Context|MsgStream, | ||||||
|  |         ipc: Context|MsgStream, | ||||||
|  | 
 | ||||||
|  |         ipc_msg: MsgType|None = None, | ||||||
|  |         expect_msg: Type[MsgType]|None = None, | ||||||
|  |         hide_tb: bool = False, | ||||||
|  |         **dec_pld_kwargs, | ||||||
|  | 
 | ||||||
|  |     ) -> Any|Raw: | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|  | 
 | ||||||
|  |         msg: MsgType = ( | ||||||
|  |             ipc_msg | ||||||
|  |             or | ||||||
|  | 
 | ||||||
|  |             # sync-rx msg from underlying IPC feeder (mem-)chan | ||||||
|  |             ipc._rx_chan.receive_nowait() | ||||||
|  |         ) | ||||||
|  |         return self.decode_pld( | ||||||
|  |             msg, | ||||||
|  |             ipc=ipc, | ||||||
|  |             expect_msg=expect_msg, | ||||||
|  |             hide_tb=hide_tb, | ||||||
|  |             **dec_pld_kwargs, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     async def recv_pld( | ||||||
|  |         self, | ||||||
|  |         ipc: Context|MsgStream, | ||||||
|  |         ipc_msg: MsgType|None = None, | ||||||
|  |         expect_msg: Type[MsgType]|None = None, | ||||||
|  |         hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|  |         **dec_pld_kwargs, | ||||||
|  | 
 | ||||||
|  |     ) -> Any|Raw: | ||||||
|  |         ''' | ||||||
|  |         Receive a `MsgType`, then decode and return its `.pld` field. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|  |         msg: MsgType = ( | ||||||
|  |             ipc_msg | ||||||
|  |             or | ||||||
|  |             # async-rx msg from underlying IPC feeder (mem-)chan | ||||||
|  |             await ipc._rx_chan.receive() | ||||||
|  |         ) | ||||||
|  |         return self.decode_pld( | ||||||
|  |             msg=msg, | ||||||
|  |             ipc=ipc, | ||||||
|  |             expect_msg=expect_msg, | ||||||
|  |             **dec_pld_kwargs, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     def decode_pld( | ||||||
|  |         self, | ||||||
|  |         msg: MsgType, | ||||||
|  |         ipc: Context|MsgStream, | ||||||
|  |         expect_msg: Type[MsgType]|None, | ||||||
|  | 
 | ||||||
|  |         raise_error: bool = True, | ||||||
|  |         hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|  |         # XXX for special (default?) case of send side call with | ||||||
|  |         # `Context.started(validate_pld_spec=True)` | ||||||
|  |         is_started_send_side: bool = False, | ||||||
|  | 
 | ||||||
|  |     ) -> PayloadT|Raw: | ||||||
|  |         ''' | ||||||
|  |         Decode a msg's payload field: `MsgType.pld: PayloadT|Raw` and | ||||||
|  |         return the value or raise an appropriate error. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|  |         src_err: BaseException|None = None | ||||||
|  |         match msg: | ||||||
|  |             # payload-data shuttle msg; deliver the `.pld` value | ||||||
|  |             # directly to IPC (primitive) client-consumer code. | ||||||
|  |             case ( | ||||||
|  |                 Started(pld=pld)  # sync phase | ||||||
|  |                 |Yield(pld=pld)  # streaming phase | ||||||
|  |                 |Return(pld=pld)  # termination phase | ||||||
|  |             ): | ||||||
|  |                 try: | ||||||
|  |                     pld: PayloadT = self._pld_dec.decode(pld) | ||||||
|  |                     log.runtime( | ||||||
|  |                         'Decoded msg payload\n\n' | ||||||
|  |                         f'{msg}\n' | ||||||
|  |                         f'where payload decoded as\n' | ||||||
|  |                         f'|_pld={pld!r}\n' | ||||||
|  |                     ) | ||||||
|  |                     return pld | ||||||
|  | 
 | ||||||
|  |                 # XXX pld-value type failure | ||||||
|  |                 except ValidationError as valerr: | ||||||
|  |                     # pack mgterr into error-msg for | ||||||
|  |                     # reraise below; ensure remote-actor-err | ||||||
|  |                     # info is displayed nicely? | ||||||
|  |                     mte: MsgTypeError = _mk_recv_mte( | ||||||
|  |                         msg=msg, | ||||||
|  |                         codec=self.pld_dec, | ||||||
|  |                         src_validation_error=valerr, | ||||||
|  |                         is_invalid_payload=True, | ||||||
|  |                         expected_msg=expect_msg, | ||||||
|  |                     ) | ||||||
|  |                     # NOTE: just raise the MTE inline instead of all | ||||||
|  |                     # the pack-unpack-repack non-sense when this is | ||||||
|  |                     # a "send side" validation error. | ||||||
|  |                     if is_started_send_side: | ||||||
|  |                         raise mte | ||||||
|  | 
 | ||||||
|  |                     # NOTE: the `.message` is automatically | ||||||
|  |                     # transferred into the message as long as we | ||||||
|  |                     # define it as a `Error.message` field. | ||||||
|  |                     err_msg: Error = pack_error( | ||||||
|  |                         exc=mte, | ||||||
|  |                         cid=msg.cid, | ||||||
|  |                         src_uid=( | ||||||
|  |                             ipc.chan.uid | ||||||
|  |                             if not is_started_send_side | ||||||
|  |                             else ipc._actor.uid | ||||||
|  |                         ), | ||||||
|  |                     ) | ||||||
|  |                     mte._ipc_msg = err_msg | ||||||
|  | 
 | ||||||
|  |                     # XXX override the `msg` passed to | ||||||
|  |                     # `_raise_from_unexpected_msg()` (below) so so | ||||||
|  |                     # that we're effectively able to use that same | ||||||
|  |                     # func to unpack and raise an "emulated remote | ||||||
|  |                     # `Error`" of this local MTE. | ||||||
|  |                     msg = err_msg | ||||||
|  |                     # XXX NOTE: so when the `_raise_from_unexpected_msg()` | ||||||
|  |                     # raises the boxed `err_msg` from above it raises | ||||||
|  |                     # it from the above caught interchange-lib | ||||||
|  |                     # validation error. | ||||||
|  |                     src_err = valerr | ||||||
|  | 
 | ||||||
|  |             # a runtime-internal RPC endpoint response. | ||||||
|  |             # always passthrough since (internal) runtime | ||||||
|  |             # responses are generally never exposed to consumer | ||||||
|  |             # code. | ||||||
|  |             case CancelAck( | ||||||
|  |                 pld=bool(cancelled) | ||||||
|  |             ): | ||||||
|  |                 return cancelled | ||||||
|  | 
 | ||||||
|  |             case Error(): | ||||||
|  |                 src_err = MessagingError( | ||||||
|  |                     'IPC ctx dialog terminated without `Return`-ing a result\n' | ||||||
|  |                     f'Instead it raised {msg.boxed_type_str!r}!' | ||||||
|  |                 ) | ||||||
|  |                 # XXX NOTE XXX another super subtle runtime-y thing.. | ||||||
|  |                 # | ||||||
|  |                 # - when user code (transitively) calls into this | ||||||
|  |                 #   func (usually via a `Context/MsgStream` API) we | ||||||
|  |                 #   generally want errors to propagate immediately | ||||||
|  |                 #   and directly so that the user can define how it | ||||||
|  |                 #   wants to handle them. | ||||||
|  |                 # | ||||||
|  |                 #  HOWEVER, | ||||||
|  |                 # | ||||||
|  |                 # - for certain runtime calling cases, we don't want to | ||||||
|  |                 #   directly raise since the calling code might have | ||||||
|  |                 #   special logic around whether to raise the error | ||||||
|  |                 #   or supress it silently (eg. a `ContextCancelled` | ||||||
|  |                 #   received from the far end which was requested by | ||||||
|  |                 #   this side, aka a self-cancel). | ||||||
|  |                 # | ||||||
|  |                 # SO, we offer a flag to control this. | ||||||
|  |                 if not raise_error: | ||||||
|  |                     return src_err | ||||||
|  | 
 | ||||||
|  |             case Stop(cid=cid): | ||||||
|  |                 ctx: Context = getattr(ipc, 'ctx', ipc) | ||||||
|  |                 message: str = ( | ||||||
|  |                     f'{ctx.side!r}-side of ctx received stream-`Stop` from ' | ||||||
|  |                     f'{ctx.peer_side!r} peer ?\n' | ||||||
|  |                     f'|_cid: {cid}\n\n' | ||||||
|  | 
 | ||||||
|  |                     f'{pretty_struct.pformat(msg)}\n' | ||||||
|  |                 ) | ||||||
|  |                 if ctx._stream is None: | ||||||
|  |                     explain: str = ( | ||||||
|  |                         f'BUT, no `MsgStream` (was) open(ed) on this ' | ||||||
|  |                         f'{ctx.side!r}-side of the IPC ctx?\n' | ||||||
|  |                         f'Maybe check your code for streaming phase race conditions?\n' | ||||||
|  |                     ) | ||||||
|  |                     log.warning( | ||||||
|  |                         message | ||||||
|  |                         + | ||||||
|  |                         explain | ||||||
|  |                     ) | ||||||
|  |                     # let caller decide what to do when only one | ||||||
|  |                     # side opened a stream, don't raise. | ||||||
|  |                     return msg | ||||||
|  | 
 | ||||||
|  |                 else: | ||||||
|  |                     explain: str = ( | ||||||
|  |                         'Received a `Stop` when it should NEVER be possible!?!?\n' | ||||||
|  |                     ) | ||||||
|  |                     # TODO: this is constructed inside | ||||||
|  |                     # `_raise_from_unexpected_msg()` but maybe we | ||||||
|  |                     # should pass it in? | ||||||
|  |                     # src_err = trio.EndOfChannel(explain) | ||||||
|  |                     src_err = None | ||||||
|  | 
 | ||||||
|  |             case _: | ||||||
|  |                 src_err = InternalError( | ||||||
|  |                     'Invalid IPC msg ??\n\n' | ||||||
|  |                     f'{msg}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |         # TODO: maybe use the new `.add_note()` from 3.11? | ||||||
|  |         # |_https://docs.python.org/3.11/library/exceptions.html#BaseException.add_note | ||||||
|  |         # | ||||||
|  |         # fallthrough and raise from `src_err` | ||||||
|  |         try: | ||||||
|  |             _raise_from_unexpected_msg( | ||||||
|  |                 ctx=getattr(ipc, 'ctx', ipc), | ||||||
|  |                 msg=msg, | ||||||
|  |                 src_err=src_err, | ||||||
|  |                 log=log, | ||||||
|  |                 expect_msg=expect_msg, | ||||||
|  |                 hide_tb=hide_tb, | ||||||
|  |             ) | ||||||
|  |         except UnboundLocalError: | ||||||
|  |             # XXX if there's an internal lookup error in the above | ||||||
|  |             # code (prolly on `src_err`) we want to show this frame | ||||||
|  |             # in the tb! | ||||||
|  |             __tracebackhide__: bool = False | ||||||
|  |             raise | ||||||
|  | 
 | ||||||
|  |     dec_msg = decode_pld | ||||||
|  | 
 | ||||||
|  |     async def recv_msg_w_pld( | ||||||
|  |         self, | ||||||
|  |         ipc: Context|MsgStream, | ||||||
|  |         expect_msg: MsgType, | ||||||
|  | 
 | ||||||
|  |         # NOTE: generally speaking only for handling `Stop`-msgs that | ||||||
|  |         # arrive during a call to `drain_to_final_msg()` above! | ||||||
|  |         passthrough_non_pld_msgs: bool = True, | ||||||
|  |         hide_tb: bool = True, | ||||||
|  |         **kwargs, | ||||||
|  | 
 | ||||||
|  |     ) -> tuple[MsgType, PayloadT]: | ||||||
|  |         ''' | ||||||
|  |         Retrieve the next avail IPC msg, decode it's payload, and return | ||||||
|  |         the pair of refs. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         __tracebackhide__: bool = hide_tb | ||||||
|  |         msg: MsgType = await ipc._rx_chan.receive() | ||||||
|  | 
 | ||||||
|  |         if passthrough_non_pld_msgs: | ||||||
|  |             match msg: | ||||||
|  |                 case Stop(): | ||||||
|  |                     return msg, None | ||||||
|  | 
 | ||||||
|  |         # TODO: is there some way we can inject the decoded | ||||||
|  |         # payload into an existing output buffer for the original | ||||||
|  |         # msg instance? | ||||||
|  |         pld: PayloadT = self.decode_pld( | ||||||
|  |             msg, | ||||||
|  |             ipc=ipc, | ||||||
|  |             expect_msg=expect_msg, | ||||||
|  |             hide_tb=hide_tb, | ||||||
|  |             **kwargs, | ||||||
|  |         ) | ||||||
|  |         return msg, pld | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @cm | ||||||
|  | def limit_plds( | ||||||
|  |     spec: Union[Type[Struct]], | ||||||
|  |     **dec_kwargs, | ||||||
|  | 
 | ||||||
|  | ) -> MsgDec: | ||||||
|  |     ''' | ||||||
|  |     Apply a `MsgCodec` that will natively decode the SC-msg set's | ||||||
|  |     `PayloadMsg.pld: Union[Type[Struct]]` payload fields using | ||||||
|  |     tagged-unions of `msgspec.Struct`s from the `payload_types` | ||||||
|  |     for all IPC contexts in use by the current `trio.Task`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     __tracebackhide__: bool = True | ||||||
|  |     try: | ||||||
|  |         curr_ctx: Context = current_ipc_ctx() | ||||||
|  |         rx: PldRx = curr_ctx._pld_rx | ||||||
|  |         orig_pldec: MsgDec = rx.pld_dec | ||||||
|  | 
 | ||||||
|  |         with rx.limit_plds( | ||||||
|  |             spec=spec, | ||||||
|  |             **dec_kwargs, | ||||||
|  |         ) as pldec: | ||||||
|  |             log.runtime( | ||||||
|  |                 'Applying payload-decoder\n\n' | ||||||
|  |                 f'{pldec}\n' | ||||||
|  |             ) | ||||||
|  |             yield pldec | ||||||
|  |     finally: | ||||||
|  |         log.runtime( | ||||||
|  |             'Reverted to previous payload-decoder\n\n' | ||||||
|  |             f'{orig_pldec}\n' | ||||||
|  |         ) | ||||||
|  |         # sanity on orig settings | ||||||
|  |         assert rx.pld_dec is orig_pldec | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @acm | ||||||
|  | async def maybe_limit_plds( | ||||||
|  |     ctx: Context, | ||||||
|  |     spec: Union[Type[Struct]]|None = None, | ||||||
|  |     dec_hook: Callable|None = None, | ||||||
|  |     **kwargs, | ||||||
|  | 
 | ||||||
|  | ) -> MsgDec|None: | ||||||
|  |     ''' | ||||||
|  |     Async compat maybe-payload type limiter. | ||||||
|  | 
 | ||||||
|  |     Mostly for use inside other internal `@acm`s such that a separate | ||||||
|  |     indent block isn't needed when an async one is already being | ||||||
|  |     used. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     if ( | ||||||
|  |         spec is None | ||||||
|  |         and | ||||||
|  |         dec_hook is None | ||||||
|  |     ): | ||||||
|  |         yield None | ||||||
|  |         return | ||||||
|  | 
 | ||||||
|  |     # sanity check on IPC scoping | ||||||
|  |     curr_ctx: Context = current_ipc_ctx() | ||||||
|  |     assert ctx is curr_ctx | ||||||
|  | 
 | ||||||
|  |     with ctx._pld_rx.limit_plds( | ||||||
|  |         spec=spec, | ||||||
|  |         dec_hook=dec_hook, | ||||||
|  |         **kwargs, | ||||||
|  |     ) as msgdec: | ||||||
|  |         yield msgdec | ||||||
|  | 
 | ||||||
|  |     # when the applied spec is unwound/removed, the same IPC-ctx | ||||||
|  |     # should still be in scope. | ||||||
|  |     curr_ctx: Context = current_ipc_ctx() | ||||||
|  |     assert ctx is curr_ctx | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async def drain_to_final_msg( | ||||||
|  |     ctx: Context, | ||||||
|  | 
 | ||||||
|  |     hide_tb: bool = True, | ||||||
|  |     msg_limit: int = 6, | ||||||
|  | 
 | ||||||
|  | ) -> tuple[ | ||||||
|  |     Return|None, | ||||||
|  |     list[MsgType] | ||||||
|  | ]: | ||||||
|  |     ''' | ||||||
|  |     Drain IPC msgs delivered to the underlying IPC context's | ||||||
|  |     rx-mem-chan (i.e. from `Context._rx_chan`) in search for a final | ||||||
|  |     `Return` or `Error` msg. | ||||||
|  | 
 | ||||||
|  |     Deliver the `Return` + preceding drained msgs (`list[MsgType]`) | ||||||
|  |     as a pair unless an `Error` is found, in which unpack and raise | ||||||
|  |     it. | ||||||
|  | 
 | ||||||
|  |     The motivation here is to always capture any remote error relayed | ||||||
|  |     by the remote peer task during a ctxc condition. | ||||||
|  | 
 | ||||||
|  |     For eg. a ctxc-request may be sent to the peer as part of the | ||||||
|  |     local task's (request for) cancellation but then that same task | ||||||
|  |     **also errors** before executing the teardown in the | ||||||
|  |     `Portal.open_context().__aexit__()` block. In such error-on-exit | ||||||
|  |     cases we want to always capture and raise any delivered remote | ||||||
|  |     error (like an expected ctxc-ACK) as part of the final | ||||||
|  |     `ctx.wait_for_result()` teardown sequence such that the | ||||||
|  |     `Context.outcome` related state always reflect what transpired | ||||||
|  |     even after ctx closure and the `.open_context()` block exit. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     __tracebackhide__: bool = hide_tb | ||||||
|  |     raise_overrun: bool = not ctx._allow_overruns | ||||||
|  | 
 | ||||||
|  |     # wait for a final context result by collecting (but | ||||||
|  |     # basically ignoring) any bi-dir-stream msgs still in transit | ||||||
|  |     # from the far end. | ||||||
|  |     pre_result_drained: list[MsgType] = [] | ||||||
|  |     result_msg: Return|Error|None = None | ||||||
|  |     while not ( | ||||||
|  |         ctx.maybe_error | ||||||
|  |         and not ctx._final_result_is_set() | ||||||
|  |     ): | ||||||
|  |         try: | ||||||
|  |             # receive all msgs, scanning for either a final result | ||||||
|  |             # or error; the underlying call should never raise any | ||||||
|  |             # remote error directly! | ||||||
|  |             msg, pld = await ctx._pld_rx.recv_msg_w_pld( | ||||||
|  |                 ipc=ctx, | ||||||
|  |                 expect_msg=Return, | ||||||
|  |                 raise_error=False, | ||||||
|  |                 hide_tb=hide_tb, | ||||||
|  |             ) | ||||||
|  |             # ^-TODO-^ some bad ideas? | ||||||
|  |             # -[ ] wrap final outcome .receive() in a scope so | ||||||
|  |             #     it can be cancelled out of band if needed? | ||||||
|  |             # |_with trio.CancelScope() as res_cs: | ||||||
|  |             #       ctx._res_scope = res_cs | ||||||
|  |             #       msg: dict = await ctx._rx_chan.receive() | ||||||
|  |             #   if res_cs.cancelled_caught: | ||||||
|  |             # | ||||||
|  |             # -[ ] make sure pause points work here for REPLing | ||||||
|  |             #   the runtime itself; i.e. ensure there's no hangs! | ||||||
|  |             # |_from tractor.devx._debug import pause | ||||||
|  |             #   await pause() | ||||||
|  | 
 | ||||||
|  |         # NOTE: we get here if the far end was | ||||||
|  |         # `ContextCancelled` in 2 cases: | ||||||
|  |         # 1. we requested the cancellation and thus | ||||||
|  |         #    SHOULD NOT raise that far end error, | ||||||
|  |         # 2. WE DID NOT REQUEST that cancel and thus | ||||||
|  |         #    SHOULD RAISE HERE! | ||||||
|  |         except trio.Cancelled as _taskc: | ||||||
|  |             taskc: trio.Cancelled = _taskc | ||||||
|  | 
 | ||||||
|  |             # report when the cancellation wasn't (ostensibly) due to | ||||||
|  |             # RPC operation, some surrounding parent cancel-scope. | ||||||
|  |             if not ctx._scope.cancel_called: | ||||||
|  |                 task: trio.lowlevel.Task = trio.lowlevel.current_task() | ||||||
|  |                 rent_n: trio.Nursery = task.parent_nursery | ||||||
|  |                 if ( | ||||||
|  |                     (local_cs := rent_n.cancel_scope).cancel_called | ||||||
|  |                 ): | ||||||
|  |                     log.cancel( | ||||||
|  |                         'RPC-ctx cancelled by local-parent scope during drain!\n\n' | ||||||
|  |                         f'c}}>\n' | ||||||
|  |                         f' |_{rent_n}\n' | ||||||
|  |                         f'   |_.cancel_scope = {local_cs}\n' | ||||||
|  |                         f'   |_>c}}\n' | ||||||
|  |                         f'      |_{ctx.pformat(indent=" "*9)}' | ||||||
|  |                         # ^TODO, some (other) simpler repr here? | ||||||
|  |                     ) | ||||||
|  |                     __tracebackhide__: bool = False | ||||||
|  | 
 | ||||||
|  |             # CASE 2: mask the local cancelled-error(s) | ||||||
|  |             # only when we are sure the remote error is | ||||||
|  |             # the source cause of this local task's | ||||||
|  |             # cancellation. | ||||||
|  |             ctx.maybe_raise( | ||||||
|  |                 hide_tb=hide_tb, | ||||||
|  |                 from_src_exc=taskc, | ||||||
|  |                 # ?TODO? when *should* we use this? | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             # CASE 1: we DID request the cancel we simply | ||||||
|  |             # continue to bubble up as normal. | ||||||
|  |             raise taskc | ||||||
|  | 
 | ||||||
|  |         match msg: | ||||||
|  | 
 | ||||||
|  |             # final result arrived! | ||||||
|  |             case Return(): | ||||||
|  |                 log.runtime( | ||||||
|  |                     'Context delivered final draining msg:\n' | ||||||
|  |                     f'{pretty_struct.pformat(msg)}' | ||||||
|  |                 ) | ||||||
|  |                 ctx._result: Any = pld | ||||||
|  |                 result_msg = msg | ||||||
|  |                 break | ||||||
|  | 
 | ||||||
|  |             # far end task is still streaming to us so discard | ||||||
|  |             # and report depending on local ctx state. | ||||||
|  |             case Yield(): | ||||||
|  |                 pre_result_drained.append(msg) | ||||||
|  |                 if ( | ||||||
|  |                     (ctx._stream.closed | ||||||
|  |                      and (reason := 'stream was already closed') | ||||||
|  |                     ) | ||||||
|  |                     or (ctx.cancel_acked | ||||||
|  |                         and (reason := 'ctx cancelled other side') | ||||||
|  |                     ) | ||||||
|  |                     or (ctx._cancel_called | ||||||
|  |                         and (reason := 'ctx called `.cancel()`') | ||||||
|  |                     ) | ||||||
|  |                     or (len(pre_result_drained) > msg_limit | ||||||
|  |                         and (reason := f'"yield" limit={msg_limit}') | ||||||
|  |                     ) | ||||||
|  |                 ): | ||||||
|  |                     log.cancel( | ||||||
|  |                         'Cancelling `MsgStream` drain since ' | ||||||
|  |                         f'{reason}\n\n' | ||||||
|  |                         f'<= {ctx.chan.uid}\n' | ||||||
|  |                         f'  |_{ctx._nsf}()\n\n' | ||||||
|  |                         f'=> {ctx._task}\n' | ||||||
|  |                         f'  |_{ctx._stream}\n\n' | ||||||
|  | 
 | ||||||
|  |                         f'{pretty_struct.pformat(msg)}\n' | ||||||
|  |                     ) | ||||||
|  |                     break | ||||||
|  | 
 | ||||||
|  |                 # drain up to the `msg_limit` hoping to get | ||||||
|  |                 # a final result or error/ctxc. | ||||||
|  |                 else: | ||||||
|  |                     log.warning( | ||||||
|  |                         'Ignoring "yield" msg during `ctx.result()` drain..\n' | ||||||
|  |                         f'<= {ctx.chan.uid}\n' | ||||||
|  |                         f'  |_{ctx._nsf}()\n\n' | ||||||
|  |                         f'=> {ctx._task}\n' | ||||||
|  |                         f'  |_{ctx._stream}\n\n' | ||||||
|  | 
 | ||||||
|  |                         f'{pretty_struct.pformat(msg)}\n' | ||||||
|  |                     ) | ||||||
|  |                     continue | ||||||
|  | 
 | ||||||
|  |             # stream terminated, but no result yet.. | ||||||
|  |             # | ||||||
|  |             # TODO: work out edge cases here where | ||||||
|  |             # a stream is open but the task also calls | ||||||
|  |             # this? | ||||||
|  |             # -[ ] should be a runtime error if a stream is open right? | ||||||
|  |             # Stop() | ||||||
|  |             case Stop(): | ||||||
|  |                 pre_result_drained.append(msg) | ||||||
|  |                 log.runtime(  # normal/expected shutdown transaction | ||||||
|  |                     'Remote stream terminated due to "stop" msg:\n\n' | ||||||
|  |                     f'{pretty_struct.pformat(msg)}\n' | ||||||
|  |                 ) | ||||||
|  |                 continue | ||||||
|  | 
 | ||||||
|  |             # remote error msg, likely already handled inside | ||||||
|  |             # `Context._deliver_msg()` | ||||||
|  |             case Error(): | ||||||
|  |                 # TODO: can we replace this with `ctx.maybe_raise()`? | ||||||
|  |                 # -[ ]  would this be handier for this case maybe? | ||||||
|  |                 # |_async with maybe_raise_on_exit() as raises: | ||||||
|  |                 #       if raises: | ||||||
|  |                 #           log.error('some msg about raising..') | ||||||
|  |                 # | ||||||
|  |                 re: Exception|None = ctx._remote_error | ||||||
|  |                 if re: | ||||||
|  |                     assert msg is ctx._cancel_msg | ||||||
|  |                     # NOTE: this solved a super duper edge case XD | ||||||
|  |                     # this was THE super duper edge case of: | ||||||
|  |                     # - local task opens a remote task, | ||||||
|  |                     # - requests remote cancellation of far end | ||||||
|  |                     #   ctx/tasks, | ||||||
|  |                     # - needs to wait for the cancel ack msg | ||||||
|  |                     #   (ctxc) or some result in the race case | ||||||
|  |                     #   where the other side's task returns | ||||||
|  |                     #   before the cancel request msg is ever | ||||||
|  |                     #   rxed and processed, | ||||||
|  |                     # - here this surrounding drain loop (which | ||||||
|  |                     #   iterates all ipc msgs until the ack or | ||||||
|  |                     #   an early result arrives) was NOT exiting | ||||||
|  |                     #   since we are the edge case: local task | ||||||
|  |                     #   does not re-raise any ctxc it receives | ||||||
|  |                     #   IFF **it** was the cancellation | ||||||
|  |                     #   requester.. | ||||||
|  |                     # | ||||||
|  |                     # XXX will raise if necessary but ow break | ||||||
|  |                     # from loop presuming any supressed error | ||||||
|  |                     # (ctxc) should terminate the context! | ||||||
|  |                     ctx._maybe_raise_remote_err( | ||||||
|  |                         re, | ||||||
|  |                         # NOTE: obvi we don't care if we | ||||||
|  |                         # overran the far end if we're already | ||||||
|  |                         # waiting on a final result (msg). | ||||||
|  |                         # raise_overrun_from_self=False, | ||||||
|  |                         raise_overrun_from_self=raise_overrun, | ||||||
|  |                     ) | ||||||
|  |                     result_msg = msg | ||||||
|  |                     break  # OOOOOF, yeah obvi we need this.. | ||||||
|  | 
 | ||||||
|  |                 else: | ||||||
|  |                     # bubble the original src key error | ||||||
|  |                     raise | ||||||
|  | 
 | ||||||
|  |             # XXX should pretty much never get here unless someone | ||||||
|  |             # overrides the default `MsgType` spec. | ||||||
|  |             case _: | ||||||
|  |                 pre_result_drained.append(msg) | ||||||
|  |                 # It's definitely an internal error if any other | ||||||
|  |                 # msg type without a`'cid'` field arrives here! | ||||||
|  |                 report: str = ( | ||||||
|  |                     f'Invalid or unknown msg type {type(msg)!r}!?\n' | ||||||
|  |                 ) | ||||||
|  |                 if not msg.cid: | ||||||
|  |                     report += ( | ||||||
|  |                         '\nWhich also has no `.cid` field?\n' | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                 raise MessagingError( | ||||||
|  |                     report | ||||||
|  |                     + | ||||||
|  |                     f'\n{msg}\n' | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |     else: | ||||||
|  |         log.cancel( | ||||||
|  |             'Skipping `MsgStream` drain since final outcome is set\n\n' | ||||||
|  |             f'{ctx.outcome}\n' | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         result_msg, | ||||||
|  |         pre_result_drained, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def validate_payload_msg( | ||||||
|  |     pld_msg: Started|Yield|Return, | ||||||
|  |     pld_value: PayloadT, | ||||||
|  |     ipc: Context|MsgStream, | ||||||
|  | 
 | ||||||
|  |     raise_mte: bool = True, | ||||||
|  |     strict_pld_parity: bool = False, | ||||||
|  |     hide_tb: bool = True, | ||||||
|  | 
 | ||||||
|  | ) -> MsgTypeError|None: | ||||||
|  |     ''' | ||||||
|  |     Validate a `PayloadMsg.pld` value with the current | ||||||
|  |     IPC ctx's `PldRx` and raise an appropriate `MsgTypeError` | ||||||
|  |     on failure. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     __tracebackhide__: bool = hide_tb | ||||||
|  |     codec: MsgCodec = current_codec() | ||||||
|  |     msg_bytes: bytes = codec.encode(pld_msg) | ||||||
|  |     try: | ||||||
|  |         roundtripped: Started = codec.decode(msg_bytes) | ||||||
|  |         ctx: Context = getattr(ipc, 'ctx', ipc) | ||||||
|  |         pld: PayloadT = ctx.pld_rx.decode_pld( | ||||||
|  |             msg=roundtripped, | ||||||
|  |             ipc=ipc, | ||||||
|  |             expect_msg=Started, | ||||||
|  |             hide_tb=hide_tb, | ||||||
|  |             is_started_send_side=True, | ||||||
|  |         ) | ||||||
|  |         if ( | ||||||
|  |             strict_pld_parity | ||||||
|  |             and | ||||||
|  |             pld != pld_value | ||||||
|  |         ): | ||||||
|  |             # TODO: make that one a mod func too.. | ||||||
|  |             diff = pretty_struct.Struct.__sub__( | ||||||
|  |                 roundtripped, | ||||||
|  |                 pld_msg, | ||||||
|  |             ) | ||||||
|  |             complaint: str = ( | ||||||
|  |                 'Started value does not match after roundtrip?\n\n' | ||||||
|  |                 f'{diff}' | ||||||
|  |             ) | ||||||
|  |             raise ValidationError(complaint) | ||||||
|  | 
 | ||||||
|  |     # raise any msg type error NO MATTER WHAT! | ||||||
|  |     except ValidationError as verr: | ||||||
|  |         try: | ||||||
|  |             mte: MsgTypeError = _mk_recv_mte( | ||||||
|  |                 msg=roundtripped, | ||||||
|  |                 codec=codec, | ||||||
|  |                 src_validation_error=verr, | ||||||
|  |                 verb_header='Trying to send ', | ||||||
|  |                 is_invalid_payload=True, | ||||||
|  |             ) | ||||||
|  |         except BaseException: | ||||||
|  |             __tracebackhide__: bool = False | ||||||
|  |             raise | ||||||
|  | 
 | ||||||
|  |         if not raise_mte: | ||||||
|  |             return mte | ||||||
|  | 
 | ||||||
|  |         raise mte from verr | ||||||
|  | @ -0,0 +1,342 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Prettified version of `msgspec.Struct` for easier console grokin. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | from collections import UserList | ||||||
|  | from typing import ( | ||||||
|  |     Any, | ||||||
|  |     Iterator, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from msgspec import ( | ||||||
|  |     msgpack, | ||||||
|  |     Struct as _Struct, | ||||||
|  |     structs, | ||||||
|  | ) | ||||||
|  | # from pprint import ( | ||||||
|  | #     saferepr, | ||||||
|  | # ) | ||||||
|  | 
 | ||||||
|  | from tractor.log import get_logger | ||||||
|  | 
 | ||||||
|  | log = get_logger() | ||||||
|  | # TODO: auto-gen type sig for input func both for | ||||||
|  | # type-msgs and logging of RPC tasks? | ||||||
|  | # taken and modified from: | ||||||
|  | # https://stackoverflow.com/a/57110117 | ||||||
|  | # import inspect | ||||||
|  | # from typing import List | ||||||
|  | 
 | ||||||
|  | # def my_function(input_1: str, input_2: int) -> list[int]: | ||||||
|  | #     pass | ||||||
|  | 
 | ||||||
|  | # def types_of(func): | ||||||
|  | #     specs = inspect.getfullargspec(func) | ||||||
|  | #     return_type = specs.annotations['return'] | ||||||
|  | #     input_types = [t.__name__ for s, t in specs.annotations.items() if s != 'return'] | ||||||
|  | #     return f'{func.__name__}({": ".join(input_types)}) -> {return_type}' | ||||||
|  | 
 | ||||||
|  | # types_of(my_function) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class DiffDump(UserList): | ||||||
|  |     ''' | ||||||
|  |     Very simple list delegator that repr() dumps (presumed) tuple | ||||||
|  |     elements of the form `tuple[str, Any, Any]` in a nice | ||||||
|  |     multi-line readable form for analyzing `Struct` diffs. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     def __repr__(self) -> str: | ||||||
|  |         if not len(self): | ||||||
|  |             return super().__repr__() | ||||||
|  | 
 | ||||||
|  |         # format by displaying item pair's ``repr()`` on multiple, | ||||||
|  |         # indented lines such that they are more easily visually | ||||||
|  |         # comparable when printed to console when printed to | ||||||
|  |         # console. | ||||||
|  |         repstr: str = '[\n' | ||||||
|  |         for k, left, right in self: | ||||||
|  |             repstr += ( | ||||||
|  |                 f'({k},\n' | ||||||
|  |                 f' |_{repr(left)},\n' | ||||||
|  |                 f' |_{repr(right)},\n' | ||||||
|  |                 ')\n' | ||||||
|  |             ) | ||||||
|  |         repstr += ']\n' | ||||||
|  |         return repstr | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def iter_fields(struct: Struct) -> Iterator[ | ||||||
|  |     tuple[ | ||||||
|  |         structs.FieldIinfo, | ||||||
|  |         str, | ||||||
|  |         Any, | ||||||
|  |     ] | ||||||
|  | ]: | ||||||
|  |     ''' | ||||||
|  |     Iterate over all non-@property fields of this struct. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     fi: structs.FieldInfo | ||||||
|  |     for fi in structs.fields(struct): | ||||||
|  |         key: str = fi.name | ||||||
|  |         val: Any = getattr(struct, key) | ||||||
|  |         yield ( | ||||||
|  |             fi, | ||||||
|  |             key, | ||||||
|  |             val, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def pformat( | ||||||
|  |     struct: Struct, | ||||||
|  |     field_indent: int = 2, | ||||||
|  |     indent: int = 0, | ||||||
|  | 
 | ||||||
|  | ) -> str: | ||||||
|  |     ''' | ||||||
|  |     Recursion-safe `pprint.pformat()` style formatting of | ||||||
|  |     a `msgspec.Struct` for sane reading by a human using a REPL. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # global whitespace indent | ||||||
|  |     ws: str = ' '*indent | ||||||
|  | 
 | ||||||
|  |     # field whitespace indent | ||||||
|  |     field_ws: str = ' '*(field_indent + indent) | ||||||
|  | 
 | ||||||
|  |     # qtn: str = ws + struct.__class__.__qualname__ | ||||||
|  |     qtn: str = struct.__class__.__qualname__ | ||||||
|  | 
 | ||||||
|  |     obj_str: str = ''  # accumulator | ||||||
|  |     fi: structs.FieldInfo | ||||||
|  |     k: str | ||||||
|  |     v: Any | ||||||
|  |     for fi, k, v in iter_fields(struct): | ||||||
|  | 
 | ||||||
|  |         # TODO: how can we prefer `Literal['option1',  'option2, | ||||||
|  |         # ..]` over .__name__ == `Literal` but still get only the | ||||||
|  |         # latter for simple types like `str | int | None` etc..? | ||||||
|  |         ft: type = fi.type | ||||||
|  |         typ_name: str = getattr(ft, '__name__', str(ft)) | ||||||
|  | 
 | ||||||
|  |         # recurse to get sub-struct's `.pformat()` output Bo | ||||||
|  |         if isinstance(v, Struct): | ||||||
|  |             val_str: str =  v.pformat( | ||||||
|  |                 indent=field_indent + indent, | ||||||
|  |                 field_indent=indent + field_indent, | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             val_str: str = repr(v) | ||||||
|  | 
 | ||||||
|  |             # XXX LOL, below just seems to be f#$%in causing | ||||||
|  |             # recursion errs.. | ||||||
|  |             # | ||||||
|  |             # the `pprint` recursion-safe format: | ||||||
|  |             # https://docs.python.org/3.11/library/pprint.html#pprint.saferepr | ||||||
|  |             # try: | ||||||
|  |             #     val_str: str = saferepr(v) | ||||||
|  |             # except Exception: | ||||||
|  |             #     log.exception( | ||||||
|  |             #         'Failed to `saferepr({type(struct)})` !?\n' | ||||||
|  |             #     ) | ||||||
|  |                 # raise | ||||||
|  |                 # return _Struct.__repr__(struct) | ||||||
|  | 
 | ||||||
|  |         # TODO: LOLOL use `textwrap.indent()` instead dawwwwwg! | ||||||
|  |         obj_str += (field_ws + f'{k}: {typ_name} = {val_str},\n') | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         f'{qtn}(\n' | ||||||
|  |         f'{obj_str}' | ||||||
|  |         f'{ws})' | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Struct( | ||||||
|  |     _Struct, | ||||||
|  | 
 | ||||||
|  |     # https://jcristharif.com/msgspec/structs.html#tagged-unions | ||||||
|  |     # tag='pikerstruct', | ||||||
|  |     # tag=True, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     A "human friendlier" (aka repl buddy) struct subtype. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     def to_dict( | ||||||
|  |         self, | ||||||
|  |         include_non_members: bool = True, | ||||||
|  | 
 | ||||||
|  |     ) -> dict: | ||||||
|  |         ''' | ||||||
|  |         Like it sounds.. direct delegation to: | ||||||
|  |         https://jcristharif.com/msgspec/api.html#msgspec.structs.asdict | ||||||
|  | 
 | ||||||
|  |         BUT, by default we pop all non-member (aka not defined as | ||||||
|  |         struct fields) fields by default. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         asdict: dict = structs.asdict(self) | ||||||
|  |         if include_non_members: | ||||||
|  |             return asdict | ||||||
|  | 
 | ||||||
|  |         # only return a dict of the struct members | ||||||
|  |         # which were provided as input, NOT anything | ||||||
|  |         # added as type-defined `@property` methods! | ||||||
|  |         sin_props: dict = {} | ||||||
|  |         fi: structs.FieldInfo | ||||||
|  |         for fi, k, v in iter_fields(self): | ||||||
|  |             sin_props[k] = asdict[k] | ||||||
|  | 
 | ||||||
|  |         return sin_props | ||||||
|  | 
 | ||||||
|  |     pformat = pformat | ||||||
|  | 
 | ||||||
|  |     def __repr__(self) -> str: | ||||||
|  |         try: | ||||||
|  |             return pformat(self) | ||||||
|  |         except Exception: | ||||||
|  |             log.exception( | ||||||
|  |                 f'Failed to `pformat({type(self)})` !?\n' | ||||||
|  |             ) | ||||||
|  |             return _Struct.__repr__(self) | ||||||
|  | 
 | ||||||
|  |     # __repr__ = pformat | ||||||
|  |     # __str__ = __repr__ = pformat | ||||||
|  |     # TODO: use a pprint.PrettyPrinter instance around ONLY rendering | ||||||
|  |     # inside a known tty? | ||||||
|  |     # def __repr__(self) -> str: | ||||||
|  |     #     ... | ||||||
|  | 
 | ||||||
|  |     def copy( | ||||||
|  |         self, | ||||||
|  |         update: dict | None = None, | ||||||
|  | 
 | ||||||
|  |     ) -> Struct: | ||||||
|  |         ''' | ||||||
|  |         Validate-typecast all self defined fields, return a copy of | ||||||
|  |         us with all such fields. | ||||||
|  | 
 | ||||||
|  |         NOTE: This is kinda like the default behaviour in | ||||||
|  |         `pydantic.BaseModel` except a copy of the object is | ||||||
|  |         returned making it compat with `frozen=True`. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         if update: | ||||||
|  |             for k, v in update.items(): | ||||||
|  |                 setattr(self, k, v) | ||||||
|  | 
 | ||||||
|  |         # NOTE: roundtrip serialize to validate | ||||||
|  |         # - enode to msgpack binary format, | ||||||
|  |         # - decode that back to a struct. | ||||||
|  |         return msgpack.Decoder(type=type(self)).decode( | ||||||
|  |             msgpack.Encoder().encode(self) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     def typecast( | ||||||
|  |         self, | ||||||
|  | 
 | ||||||
|  |         # TODO: allow only casting a named subset? | ||||||
|  |         # fields: set[str] | None = None, | ||||||
|  | 
 | ||||||
|  |     ) -> None: | ||||||
|  |         ''' | ||||||
|  |         Cast all fields using their declared type annotations | ||||||
|  |         (kinda like what `pydantic` does by default). | ||||||
|  | 
 | ||||||
|  |         NOTE: this of course won't work on frozen types, use | ||||||
|  |         ``.copy()`` above in such cases. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         # https://jcristharif.com/msgspec/api.html#msgspec.structs.fields | ||||||
|  |         fi: structs.FieldInfo | ||||||
|  |         for fi in structs.fields(self): | ||||||
|  |             setattr( | ||||||
|  |                 self, | ||||||
|  |                 fi.name, | ||||||
|  |                 fi.type(getattr(self, fi.name)), | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     # TODO: make a mod func instead and just point to it here for | ||||||
|  |     # method impl? | ||||||
|  |     def __sub__( | ||||||
|  |         self, | ||||||
|  |         other: Struct, | ||||||
|  | 
 | ||||||
|  |     ) -> DiffDump[tuple[str, Any, Any]]: | ||||||
|  |         ''' | ||||||
|  |         Compare fields/items key-wise and return a `DiffDump` | ||||||
|  |         for easy visual REPL comparison B) | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         diffs: DiffDump[tuple[str, Any, Any]] = DiffDump() | ||||||
|  |         for fi in structs.fields(self): | ||||||
|  |             attr_name: str = fi.name | ||||||
|  |             ours: Any = getattr(self, attr_name) | ||||||
|  |             theirs: Any = getattr(other, attr_name) | ||||||
|  |             if ours != theirs: | ||||||
|  |                 diffs.append(( | ||||||
|  |                     attr_name, | ||||||
|  |                     ours, | ||||||
|  |                     theirs, | ||||||
|  |                 )) | ||||||
|  | 
 | ||||||
|  |         return diffs | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def fields_diff( | ||||||
|  |         cls, | ||||||
|  |         other: dict|Struct, | ||||||
|  | 
 | ||||||
|  |     ) -> DiffDump[tuple[str, Any, Any]]: | ||||||
|  |         ''' | ||||||
|  |         Very similar to `PrettyStruct.__sub__()` except accepts an | ||||||
|  |         input `other: dict` (presumably that would normally be called | ||||||
|  |         like `Struct(**other)`) which returns a `DiffDump` of the | ||||||
|  |         fields of the struct and the `dict`'s fields. | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         nullish = object() | ||||||
|  |         consumed: dict = other.copy() | ||||||
|  |         diffs: DiffDump[tuple[str, Any, Any]] = DiffDump() | ||||||
|  |         for fi in structs.fields(cls): | ||||||
|  |             field_name: str = fi.name | ||||||
|  |             # ours: Any = getattr(self, field_name) | ||||||
|  |             theirs: Any = consumed.pop(field_name, nullish) | ||||||
|  |             if theirs is nullish: | ||||||
|  |                 diffs.append(( | ||||||
|  |                     field_name, | ||||||
|  |                     f'{fi.type!r}', | ||||||
|  |                     'NOT-DEFINED in `other: dict`', | ||||||
|  |                 )) | ||||||
|  | 
 | ||||||
|  |         # when there are lingering fields in `other` that this struct | ||||||
|  |         # DOES NOT define we also append those. | ||||||
|  |         if consumed: | ||||||
|  |             for k, v in consumed.items(): | ||||||
|  |                 diffs.append(( | ||||||
|  |                     k, | ||||||
|  |                     f'NOT-DEFINED for `{cls.__name__}`', | ||||||
|  |                     f'`other: dict` has value = {v!r}', | ||||||
|  |                 )) | ||||||
|  | 
 | ||||||
|  |         return diffs | ||||||
|  | @ -0,0 +1,139 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | IPC-compat cross-mem-boundary object pointer. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | 
 | ||||||
|  | # TODO: integration with our ``enable_modules: list[str]`` caps sys. | ||||||
|  | 
 | ||||||
|  | # ``pkgutil.resolve_name()`` internally uses | ||||||
|  | # ``importlib.import_module()`` which can be filtered by inserting | ||||||
|  | # a ``MetaPathFinder`` into ``sys.meta_path`` (which we could do before | ||||||
|  | # entering the ``_runtime.process_messages()`` loop). | ||||||
|  | # - https://github.com/python/cpython/blob/main/Lib/pkgutil.py#L645 | ||||||
|  | # - https://stackoverflow.com/questions/1350466/preventing-python-code-from-importing-certain-modules | ||||||
|  | #   - https://stackoverflow.com/a/63320902 | ||||||
|  | #   - https://docs.python.org/3/library/sys.html#sys.meta_path | ||||||
|  | 
 | ||||||
|  | # the new "Implicit Namespace Packages" might be relevant? | ||||||
|  | # - https://www.python.org/dev/peps/pep-0420/ | ||||||
|  | 
 | ||||||
|  | # add implicit serialized message type support so that paths can be | ||||||
|  | # handed directly to IPC primitives such as streams and `Portal.run()` | ||||||
|  | # calls: | ||||||
|  | # - via ``msgspec``: | ||||||
|  | #   - https://jcristharif.com/msgspec/api.html#struct | ||||||
|  | #   - https://jcristharif.com/msgspec/extending.html | ||||||
|  | # via ``msgpack-python``: | ||||||
|  | # - https://github.com/msgpack/msgpack-python#packingunpacking-of-custom-data-type | ||||||
|  | 
 | ||||||
|  | from __future__ import annotations | ||||||
|  | from inspect import ( | ||||||
|  |     isfunction, | ||||||
|  |     ismethod, | ||||||
|  | ) | ||||||
|  | from pkgutil import resolve_name | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class NamespacePath(str): | ||||||
|  |     ''' | ||||||
|  |     A serializeable `str`-subtype implementing a "namespace | ||||||
|  |     pointer" to any Python object reference (like a function) | ||||||
|  |     using the same format as the built-in `pkgutil.resolve_name()` | ||||||
|  |     system. | ||||||
|  | 
 | ||||||
|  |     A value describes a target's module-path and namespace-key | ||||||
|  |     separated by a ':' and thus can be easily used as | ||||||
|  |     a IPC-message-native reference-type allowing memory isolated | ||||||
|  |     actors to point-and-load objects via a minimal `str` value. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     _ref: object | type | None = None | ||||||
|  | 
 | ||||||
|  |     # TODO: support providing the ns instance in | ||||||
|  |     # order to support 'self.<meth>` style to make | ||||||
|  |     # `Portal.run_from_ns()` work! | ||||||
|  |     # _ns: ModuleType|type|None = None | ||||||
|  | 
 | ||||||
|  |     def load_ref(self) -> object | type: | ||||||
|  |         if self._ref is None: | ||||||
|  |             self._ref = resolve_name(self) | ||||||
|  |         return self._ref | ||||||
|  | 
 | ||||||
|  |     @staticmethod | ||||||
|  |     def _mk_fqnp( | ||||||
|  |         ref: type|object, | ||||||
|  |     ) -> tuple[str, str]: | ||||||
|  |         ''' | ||||||
|  |         Generate a minial `str` pair which describes a python | ||||||
|  |         object's namespace path and object/type name. | ||||||
|  | 
 | ||||||
|  |         In more precise terms something like: | ||||||
|  |           - 'py.namespace.path:object_name', | ||||||
|  |           - eg.'tractor.msg:NamespacePath' will be the ``str`` form | ||||||
|  |             of THIS type XD | ||||||
|  | 
 | ||||||
|  |         ''' | ||||||
|  |         if isfunction(ref): | ||||||
|  |             name: str = getattr(ref, '__name__') | ||||||
|  |             mod_name: str = ref.__module__ | ||||||
|  | 
 | ||||||
|  |         elif ismethod(ref): | ||||||
|  |             # build out the path manually i guess..? | ||||||
|  |             # TODO: better way? | ||||||
|  |             name: str = '.'.join([ | ||||||
|  |                 type(ref.__self__).__name__, | ||||||
|  |                 ref.__func__.__name__, | ||||||
|  |             ]) | ||||||
|  |             mod_name: str = ref.__self__.__module__ | ||||||
|  | 
 | ||||||
|  |         else:  # object or other? | ||||||
|  |             # isinstance(ref, object) | ||||||
|  |             # and not isfunction(ref) | ||||||
|  |             name: str = type(ref).__name__ | ||||||
|  |             mod_name: str = ref.__module__ | ||||||
|  | 
 | ||||||
|  |         # TODO: return static value direactly? | ||||||
|  |         # | ||||||
|  |         # fully qualified namespace path, tuple. | ||||||
|  |         fqnp: tuple[str, str] = ( | ||||||
|  |             mod_name, | ||||||
|  |             name, | ||||||
|  |         ) | ||||||
|  |         return fqnp | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def from_ref( | ||||||
|  |         cls, | ||||||
|  |         ref: type|object, | ||||||
|  | 
 | ||||||
|  |     ) -> NamespacePath: | ||||||
|  | 
 | ||||||
|  |         fqnp: tuple[str, str] = cls._mk_fqnp(ref) | ||||||
|  |         return cls(':'.join(fqnp)) | ||||||
|  | 
 | ||||||
|  |     def to_tuple( | ||||||
|  |         self, | ||||||
|  | 
 | ||||||
|  |         # TODO: could this work re `self:<meth>` case from above? | ||||||
|  |         # load_ref: bool = True, | ||||||
|  | 
 | ||||||
|  |     ) -> tuple[str, str]: | ||||||
|  |         return self._mk_fqnp( | ||||||
|  |             self.load_ref() | ||||||
|  |         ) | ||||||
|  | @ -0,0 +1,730 @@ | ||||||
|  | # tractor: structured concurrent "actors". | ||||||
|  | # Copyright 2018-eternity Tyler Goodlet. | ||||||
|  | 
 | ||||||
|  | # This program is free software: you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU Affero General Public License as published by | ||||||
|  | # the Free Software Foundation, either version 3 of the License, or | ||||||
|  | # (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | # You should have received a copy of the GNU Affero General Public License | ||||||
|  | # along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | Define our strictly typed IPC message spec for the SCIPP: | ||||||
|  | 
 | ||||||
|  | that is, | ||||||
|  | 
 | ||||||
|  | the "Structurred-Concurrency-Inter-Process-(dialog)-(un)Protocol". | ||||||
|  | 
 | ||||||
|  | ''' | ||||||
|  | from __future__ import annotations | ||||||
|  | import types | ||||||
|  | from typing import ( | ||||||
|  |     Any, | ||||||
|  |     Generic, | ||||||
|  |     Literal, | ||||||
|  |     Type, | ||||||
|  |     TypeVar, | ||||||
|  |     TypeAlias, | ||||||
|  |     Union, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from msgspec import ( | ||||||
|  |     defstruct, | ||||||
|  |     # field, | ||||||
|  |     Raw, | ||||||
|  |     Struct, | ||||||
|  |     # UNSET, | ||||||
|  |     # UnsetType, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | from tractor.msg import ( | ||||||
|  |     pretty_struct, | ||||||
|  | ) | ||||||
|  | from tractor.log import get_logger | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | log = get_logger('tractor.msgspec') | ||||||
|  | 
 | ||||||
|  | # type variable for the boxed payload field `.pld` | ||||||
|  | PayloadT = TypeVar('PayloadT') | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class PayloadMsg( | ||||||
|  |     Struct, | ||||||
|  |     Generic[PayloadT], | ||||||
|  | 
 | ||||||
|  |     # https://jcristharif.com/msgspec/structs.html#tagged-unions | ||||||
|  |     tag=True, | ||||||
|  |     tag_field='msg_type', | ||||||
|  | 
 | ||||||
|  |     # https://jcristharif.com/msgspec/structs.html#field-ordering | ||||||
|  |     # kw_only=True, | ||||||
|  | 
 | ||||||
|  |     # https://jcristharif.com/msgspec/structs.html#equality-and-order | ||||||
|  |     # order=True, | ||||||
|  | 
 | ||||||
|  |     # https://jcristharif.com/msgspec/structs.html#encoding-decoding-as-arrays | ||||||
|  |     # as_array=True, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     An abstract payload boxing/shuttling IPC msg type. | ||||||
|  | 
 | ||||||
|  |     Boxes data-values passed to/from user code | ||||||
|  | 
 | ||||||
|  |     (i.e. any values passed by `tractor` application code using any of | ||||||
|  | 
 | ||||||
|  |       |_ `._streaming.MsgStream.send/receive()` | ||||||
|  |       |_ `._context.Context.started/result()` | ||||||
|  |       |_ `._ipc.Channel.send/recv()` | ||||||
|  | 
 | ||||||
|  |      aka our "IPC primitive APIs") | ||||||
|  | 
 | ||||||
|  |     as message "payloads" set to the `.pld` field and uses | ||||||
|  |     `msgspec`'s "tagged unions" feature to support a subset of our | ||||||
|  |     "SC-transitive shuttle protocol" specification with | ||||||
|  |     a `msgspec.Struct` inheritance tree. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     cid: str  # call/context-id | ||||||
|  |     # ^-TODO-^: more explicit type? | ||||||
|  |     # -[ ] use UNSET here? | ||||||
|  |     #  https://jcristharif.com/msgspec/supported-types.html#unset | ||||||
|  |     # | ||||||
|  |     # -[ ] `uuid.UUID` which has multi-protocol support | ||||||
|  |     #  https://jcristharif.com/msgspec/supported-types.html#uuid | ||||||
|  | 
 | ||||||
|  |     # The msg's "payload" (spelled without vowels): | ||||||
|  |     # https://en.wikipedia.org/wiki/Payload_(computing) | ||||||
|  |     pld: Raw | ||||||
|  | 
 | ||||||
|  |     # ^-NOTE-^ inherited from any `PayloadMsg` (and maybe type | ||||||
|  |     # overriden via the `._ops.limit_plds()` API), but by default is | ||||||
|  |     # parameterized to be `Any`. | ||||||
|  |     # | ||||||
|  |     # XXX this `Union` must strictly NOT contain `Any` if | ||||||
|  |     # a limited msg-type-spec is intended, such that when | ||||||
|  |     # creating and applying a new `MsgCodec` its  | ||||||
|  |     # `.decoder: Decoder` is configured with a `Union[Type[Struct]]` which | ||||||
|  |     # restricts the allowed payload content (this `.pld` field)  | ||||||
|  |     # by type system defined loading constraints B) | ||||||
|  |     # | ||||||
|  |     # TODO: could also be set to `msgspec.Raw` if the sub-decoders | ||||||
|  |     # approach is preferred over the generic parameterization  | ||||||
|  |     # approach as take by `mk_msg_spec()` below. | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: complete rename | ||||||
|  | Msg = PayloadMsg | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Aid( | ||||||
|  |     Struct, | ||||||
|  |     tag=True, | ||||||
|  |     tag_field='msg_type', | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Actor-identity msg. | ||||||
|  | 
 | ||||||
|  |     Initial contact exchange enabling an actor "mailbox handshake" | ||||||
|  |     delivering the peer identity (and maybe eventually contact) | ||||||
|  |     info. | ||||||
|  | 
 | ||||||
|  |     Used by discovery protocol to register actors as well as | ||||||
|  |     conduct the initial comms (capability) filtering. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     name: str | ||||||
|  |     uuid: str | ||||||
|  |     # TODO: use built-in support for UUIDs? | ||||||
|  |     # -[ ] `uuid.UUID` which has multi-protocol support | ||||||
|  |     #  https://jcristharif.com/msgspec/supported-types.html#uuid | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class SpawnSpec( | ||||||
|  |     pretty_struct.Struct, | ||||||
|  |     tag=True, | ||||||
|  |     tag_field='msg_type', | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Initial runtime spec handed down from a spawning parent to its | ||||||
|  |     child subactor immediately following first contact via an | ||||||
|  |     `Aid` msg. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     # TODO: similar to the `Start` kwargs spec needed below, we need | ||||||
|  |     # a hard `Struct` def for all of these fields! | ||||||
|  |     _parent_main_data: dict | ||||||
|  |     _runtime_vars: dict[str, Any] | ||||||
|  | 
 | ||||||
|  |     # module import capability | ||||||
|  |     enable_modules: dict[str, str] | ||||||
|  | 
 | ||||||
|  |     # TODO: not just sockaddr pairs? | ||||||
|  |     # -[ ] abstract into a `TransportAddr` type? | ||||||
|  |     reg_addrs: list[tuple[str, int]] | ||||||
|  |     bind_addrs: list[tuple[str, int]] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: caps based RPC support in the payload? | ||||||
|  | # | ||||||
|  | # -[ ] integration with our ``enable_modules: list[str]`` caps sys. | ||||||
|  | #   ``pkgutil.resolve_name()`` internally uses | ||||||
|  | #   ``importlib.import_module()`` which can be filtered by | ||||||
|  | #   inserting a ``MetaPathFinder`` into ``sys.meta_path`` (which | ||||||
|  | #   we could do before entering the ``Actor._process_messages()`` | ||||||
|  | #   loop)? | ||||||
|  | #   - https://github.com/python/cpython/blob/main/Lib/pkgutil.py#L645 | ||||||
|  | #   - https://stackoverflow.com/questions/1350466/preventing-python-code-from-importing-certain-modules | ||||||
|  | #   - https://stackoverflow.com/a/63320902 | ||||||
|  | #   - https://docs.python.org/3/library/sys.html#sys.meta_path | ||||||
|  | # | ||||||
|  | # -[ ] can we combine .ns + .func into a native `NamespacePath` field? | ||||||
|  | # | ||||||
|  | # -[ ] better name, like `Call/TaskInput`? | ||||||
|  | # | ||||||
|  | # -[ ] XXX a debugger lock msg transaction with payloads like, | ||||||
|  | #   child -> `.pld: DebugLock` -> root | ||||||
|  | #   child <- `.pld: DebugLocked` <- root | ||||||
|  | #   child -> `.pld: DebugRelease` -> root | ||||||
|  | # | ||||||
|  | #   WHY => when a pld spec is provided it might not allow for | ||||||
|  | #   debug mode msgs as they currently are (using plain old `pld. | ||||||
|  | #   str` payloads) so we only when debug_mode=True we need to | ||||||
|  | #   union in this debugger payload set? | ||||||
|  | # | ||||||
|  | #   mk_msg_spec( | ||||||
|  | #       MyPldSpec, | ||||||
|  | #       debug_mode=True, | ||||||
|  | #   ) -> ( | ||||||
|  | #       Union[MyPldSpec] | ||||||
|  | #      | Union[DebugLock, DebugLocked, DebugRelease] | ||||||
|  | #   ) | ||||||
|  | 
 | ||||||
|  | # class Params( | ||||||
|  | #     Struct, | ||||||
|  | #     Generic[PayloadT], | ||||||
|  | # ): | ||||||
|  | #     spec: PayloadT|ParamSpec | ||||||
|  | #     inputs: InputsT|dict[str, Any] | ||||||
|  | 
 | ||||||
|  |     # TODO: for eg. we could stringently check the target | ||||||
|  |     # task-func's type sig and enforce it? | ||||||
|  |     # as an example for an IPTC, | ||||||
|  |     # @tractor.context | ||||||
|  |     # async def send_back_nsp( | ||||||
|  |     #     ctx: Context, | ||||||
|  |     #     expect_debug: bool, | ||||||
|  |     #     pld_spec_str: str, | ||||||
|  |     #     add_hooks: bool, | ||||||
|  |     #     started_msg_dict: dict, | ||||||
|  |     # ) -> <WhatHere!>: | ||||||
|  | 
 | ||||||
|  |     # TODO: figure out which of the `typing` feats we want to | ||||||
|  |     # support: | ||||||
|  |     # - plain ol `ParamSpec`: | ||||||
|  |     #   https://docs.python.org/3/library/typing.html#typing.ParamSpec | ||||||
|  |     # - new in 3.12 type parameter lists Bo | ||||||
|  |     # |_ https://docs.python.org/3/reference/compound_stmts.html#type-params | ||||||
|  |     # |_ historical pep 695: https://peps.python.org/pep-0695/ | ||||||
|  |     # |_ full lang spec: https://typing.readthedocs.io/en/latest/spec/ | ||||||
|  |     # |_ on annotation scopes: | ||||||
|  |     #    https://docs.python.org/3/reference/executionmodel.html#annotation-scopes | ||||||
|  |     # spec: ParamSpec[ | ||||||
|  |     #     expect_debug: bool, | ||||||
|  |     #     pld_spec_str: str, | ||||||
|  |     #     add_hooks: bool, | ||||||
|  |     #     started_msg_dict: dict, | ||||||
|  |     # ] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: possibly sub-type for runtime method requests? | ||||||
|  | # -[ ] `Runtime(Start)` with a `.ns: str = 'self' or | ||||||
|  | #     we can just enforce any such method as having a strict | ||||||
|  | #     ns for calling funcs, namely the `Actor` instance? | ||||||
|  | class Start( | ||||||
|  |     Struct, | ||||||
|  |     tag=True, | ||||||
|  |     tag_field='msg_type', | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Initial request to remotely schedule an RPC `trio.Task` via | ||||||
|  |     `Actor.start_remote_task()`. | ||||||
|  | 
 | ||||||
|  |     It is called by all the following public APIs: | ||||||
|  | 
 | ||||||
|  |     - `ActorNursery.run_in_actor()` | ||||||
|  | 
 | ||||||
|  |     - `Portal.run()` | ||||||
|  |           `|_.run_from_ns()` | ||||||
|  |           `|_.open_stream_from()` | ||||||
|  |           `|_._submit_for_result()` | ||||||
|  | 
 | ||||||
|  |     - `Context.open_context()` | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     cid: str | ||||||
|  | 
 | ||||||
|  |     ns: str | ||||||
|  |     func: str | ||||||
|  | 
 | ||||||
|  |     # TODO: make this a sub-struct which can be further | ||||||
|  |     # type-limited, maybe `Inputs`? | ||||||
|  |     # => SEE ABOVE <= | ||||||
|  |     kwargs: dict[str, Any] | ||||||
|  |     uid: tuple[str, str]  # (calling) actor-id | ||||||
|  | 
 | ||||||
|  |     # TODO: enforcing a msg-spec in terms `Msg.pld` | ||||||
|  |     # parameterizable msgs to be used in the appls IPC dialog. | ||||||
|  |     # => SEE `._codec.MsgDec` for more <= | ||||||
|  |     pld_spec: str = str(Any) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class StartAck( | ||||||
|  |     Struct, | ||||||
|  |     tag=True, | ||||||
|  |     tag_field='msg_type', | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Init response to a `Cmd` request indicating the far | ||||||
|  |     end's RPC spec, namely its callable "type". | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     cid: str | ||||||
|  |     # TODO: maybe better names for all these? | ||||||
|  |     # -[ ] obvi ^ would need sync with `._rpc` | ||||||
|  |     functype: Literal[ | ||||||
|  |         'asyncfunc', | ||||||
|  |         'asyncgen', | ||||||
|  |         'context',  # TODO: the only one eventually? | ||||||
|  |     ] | ||||||
|  | 
 | ||||||
|  |     # import typing | ||||||
|  |     # eval(str(Any), {}, {'typing': typing}) | ||||||
|  |     # started_spec: str = str(Any) | ||||||
|  |     # return_spec | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Started( | ||||||
|  |     PayloadMsg, | ||||||
|  |     Generic[PayloadT], | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Packet to shuttle the "first value" delivered by | ||||||
|  |     `Context.started(value: Any)` from a `@tractor.context` | ||||||
|  |     decorated IPC endpoint. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     pld: PayloadT|Raw | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: cancel request dedicated msg? | ||||||
|  | # -[ ] instead of using our existing `Start`? | ||||||
|  | # | ||||||
|  | # class Cancel: | ||||||
|  | #     cid: str | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Yield( | ||||||
|  |     PayloadMsg, | ||||||
|  |     Generic[PayloadT], | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Per IPC transmission of a value from `await MsgStream.send(<value>)`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     pld: PayloadT|Raw | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Stop( | ||||||
|  |     Struct, | ||||||
|  |     tag=True, | ||||||
|  |     tag_field='msg_type', | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Stream termination signal much like an IPC version  | ||||||
|  |     of `StopAsyncIteration`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     cid: str | ||||||
|  |     # TODO: do we want to support a payload on stop? | ||||||
|  |     # pld: UnsetType = UNSET | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: is `Result` or `Out[come]` a better name? | ||||||
|  | class Return( | ||||||
|  |     PayloadMsg, | ||||||
|  |     Generic[PayloadT], | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Final `return <value>` from a remotely scheduled | ||||||
|  |     func-as-`trio.Task`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     pld: PayloadT|Raw | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class CancelAck( | ||||||
|  |     PayloadMsg, | ||||||
|  |     Generic[PayloadT], | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     Deliver the `bool` return-value from a cancellation `Actor` | ||||||
|  |     method scheduled via and prior RPC request. | ||||||
|  | 
 | ||||||
|  |     - `Actor.cancel()` | ||||||
|  |        `|_.cancel_soon()` | ||||||
|  |        `|_.cancel_rpc_tasks()` | ||||||
|  |        `|_._cancel_task()` | ||||||
|  |        `|_.cancel_server()` | ||||||
|  | 
 | ||||||
|  |     RPCs to these methods must **always** be able to deliver a result | ||||||
|  |     despite the currently configured IPC msg spec such that graceful | ||||||
|  |     cancellation is always functional in the runtime. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     pld: bool | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # TODO: unify this with `._exceptions.RemoteActorError` | ||||||
|  | # such that we can have a msg which is both raisable and | ||||||
|  | # IPC-wire ready? | ||||||
|  | # B~o | ||||||
|  | class Error( | ||||||
|  |     Struct, | ||||||
|  |     tag=True, | ||||||
|  |     tag_field='msg_type', | ||||||
|  | 
 | ||||||
|  |     # TODO may omit defaults? | ||||||
|  |     # https://jcristharif.com/msgspec/structs.html#omitting-default-values | ||||||
|  |     # omit_defaults=True, | ||||||
|  | ): | ||||||
|  |     ''' | ||||||
|  |     A pkt that wraps `RemoteActorError`s for relay and raising. | ||||||
|  | 
 | ||||||
|  |     Fields are 1-to-1 meta-data as needed originally by | ||||||
|  |     `RemoteActorError.msgdata: dict` but now are defined here. | ||||||
|  | 
 | ||||||
|  |     Note: this msg shuttles `ContextCancelled` and `StreamOverrun` | ||||||
|  |     as well is used to rewrap any `MsgTypeError` for relay-reponse | ||||||
|  |     to bad `Yield.pld` senders during an IPC ctx's streaming dialog | ||||||
|  |     phase. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     src_uid: tuple[str, str] | ||||||
|  |     src_type_str: str | ||||||
|  |     boxed_type_str: str | ||||||
|  |     relay_path: list[tuple[str, str]] | ||||||
|  | 
 | ||||||
|  |     # normally either both are provided or just | ||||||
|  |     # a message for certain special cases where | ||||||
|  |     # we pack a message for a locally raised | ||||||
|  |     # mte or ctxc. | ||||||
|  |     message: str|None = None | ||||||
|  |     tb_str: str = '' | ||||||
|  | 
 | ||||||
|  |     # TODO: only optionally include sub-type specfic fields? | ||||||
|  |     # -[ ] use UNSET or don't include them via `omit_defaults` (see | ||||||
|  |     #      inheritance-line options above) | ||||||
|  |     # | ||||||
|  |     # `ContextCancelled` reports the src cancelling `Actor.uid` | ||||||
|  |     canceller: tuple[str, str]|None = None | ||||||
|  | 
 | ||||||
|  |     # `StreamOverrun`-specific src `Actor.uid` | ||||||
|  |     sender: tuple[str, str]|None = None | ||||||
|  | 
 | ||||||
|  |     # `MsgTypeError` meta-data | ||||||
|  |     cid: str|None = None | ||||||
|  |     # when the receiver side fails to decode a delivered | ||||||
|  |     # `PayloadMsg`-subtype; one and/or both the msg-struct instance | ||||||
|  |     # and `Any`-decoded to `dict` of the msg are set and relayed | ||||||
|  |     # (back to the sender) for introspection. | ||||||
|  |     _bad_msg: Started|Yield|Return|None = None | ||||||
|  |     _bad_msg_as_dict: dict|None = None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def from_dict_msg( | ||||||
|  |     dict_msg: dict, | ||||||
|  | 
 | ||||||
|  |     msgT: MsgType|None = None, | ||||||
|  |     tag_field: str = 'msg_type', | ||||||
|  |     use_pretty: bool = False, | ||||||
|  | 
 | ||||||
|  | ) -> MsgType: | ||||||
|  |     ''' | ||||||
|  |     Helper to build a specific `MsgType` struct from a "vanilla" | ||||||
|  |     decoded `dict`-ified equivalent of the msg: i.e. if the | ||||||
|  |     `msgpack.Decoder.type == Any`, the default when using | ||||||
|  |     `msgspec.msgpack` and not "typed decoding" using | ||||||
|  |     `msgspec.Struct`. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     msg_type_tag_field: str = ( | ||||||
|  |         msgT.__struct_config__.tag_field | ||||||
|  |         if msgT is not None | ||||||
|  |         else tag_field | ||||||
|  |     ) | ||||||
|  |     # XXX ensure tag field is removed | ||||||
|  |     msgT_name: str = dict_msg.pop(msg_type_tag_field) | ||||||
|  |     msgT: MsgType = _msg_table[msgT_name] | ||||||
|  |     if use_pretty: | ||||||
|  |         msgT = defstruct( | ||||||
|  |             name=msgT_name, | ||||||
|  |             fields=[ | ||||||
|  |                 (key, fi.type) | ||||||
|  |                 for fi, key, _ | ||||||
|  |                 in pretty_struct.iter_fields(msgT) | ||||||
|  |             ], | ||||||
|  |             bases=( | ||||||
|  |                 pretty_struct.Struct, | ||||||
|  |                 msgT, | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |     return msgT(**dict_msg) | ||||||
|  | 
 | ||||||
|  | # TODO: should be make a set of cancel msgs? | ||||||
|  | # -[ ] a version of `ContextCancelled`? | ||||||
|  | #     |_ and/or with a scope field? | ||||||
|  | # -[ ] or, a full `ActorCancelled`? | ||||||
|  | # | ||||||
|  | # class Cancelled(MsgType): | ||||||
|  | #     cid: str | ||||||
|  | # | ||||||
|  | # -[ ] what about overruns? | ||||||
|  | # | ||||||
|  | # class Overrun(MsgType): | ||||||
|  | #     cid: str | ||||||
|  | 
 | ||||||
|  | _runtime_msgs: list[Struct] = [ | ||||||
|  | 
 | ||||||
|  |     # identity handshake on first IPC `Channel` contact. | ||||||
|  |     Aid, | ||||||
|  | 
 | ||||||
|  |     # parent-to-child spawn specification passed as 2nd msg after | ||||||
|  |     # handshake ONLY after child connects back to parent. | ||||||
|  |     SpawnSpec, | ||||||
|  | 
 | ||||||
|  |     # inter-actor RPC initiation | ||||||
|  |     Start,  # schedule remote task-as-func | ||||||
|  |     StartAck,  # ack the schedule request | ||||||
|  | 
 | ||||||
|  |     # emission from `MsgStream.aclose()` | ||||||
|  |     Stop, | ||||||
|  | 
 | ||||||
|  |     # `Return` sub-type that we always accept from | ||||||
|  |     # runtime-internal cancel endpoints | ||||||
|  |     CancelAck, | ||||||
|  | 
 | ||||||
|  |     # box remote errors, normally subtypes | ||||||
|  |     # of `RemoteActorError`. | ||||||
|  |     Error, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | # the no-outcome-yet IAC (inter-actor-communication) sub-set which | ||||||
|  | # can be `PayloadMsg.pld` payload field type-limited by application code | ||||||
|  | # using `apply_codec()` and `limit_msg_spec()`. | ||||||
|  | _payload_msgs: list[PayloadMsg] = [ | ||||||
|  |     # first <value> from `Context.started(<value>)` | ||||||
|  |     Started, | ||||||
|  | 
 | ||||||
|  |     # any <value> sent via `MsgStream.send(<value>)` | ||||||
|  |     Yield, | ||||||
|  | 
 | ||||||
|  |     # the final value returned from a `@context` decorated | ||||||
|  |     # IPC endpoint. | ||||||
|  |     Return, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | # built-in SC shuttle protocol msg type set in | ||||||
|  | # approx order of the IPC txn-state spaces. | ||||||
|  | __msg_types__: list[MsgType] = ( | ||||||
|  |     _runtime_msgs | ||||||
|  |     + | ||||||
|  |     _payload_msgs | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | _msg_table: dict[str, MsgType] = { | ||||||
|  |     msgT.__name__: msgT | ||||||
|  |     for msgT in __msg_types__ | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | # TODO: use new type declaration syntax for msg-type-spec | ||||||
|  | # https://docs.python.org/3/library/typing.html#type-aliases | ||||||
|  | # https://docs.python.org/3/reference/simple_stmts.html#type | ||||||
|  | MsgType: TypeAlias = Union[*__msg_types__] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def mk_msg_spec( | ||||||
|  |     payload_type_union: Union[Type] = Any, | ||||||
|  | 
 | ||||||
|  |     spec_build_method: Literal[ | ||||||
|  |         'indexed_generics',  # works | ||||||
|  |         'defstruct', | ||||||
|  |         'types_new_class', | ||||||
|  | 
 | ||||||
|  |     ] = 'indexed_generics', | ||||||
|  | 
 | ||||||
|  | ) -> tuple[ | ||||||
|  |     Union[MsgType], | ||||||
|  |     list[MsgType], | ||||||
|  | ]: | ||||||
|  |     ''' | ||||||
|  |     Create a payload-(data-)type-parameterized IPC message specification. | ||||||
|  | 
 | ||||||
|  |     Allows generating IPC msg types from the above builtin set | ||||||
|  |     with a payload (field) restricted data-type, the `Msg.pld: PayloadT`. | ||||||
|  | 
 | ||||||
|  |     This allows runtime-task contexts to use the python type system | ||||||
|  |     to limit/filter payload values as determined by the input | ||||||
|  |     `payload_type_union: Union[Type]`. | ||||||
|  | 
 | ||||||
|  |     Notes: originally multiple approaches for constructing the | ||||||
|  |     type-union passed to `msgspec` were attempted as selected via the | ||||||
|  |     `spec_build_method`, but it turns out only the defaul method | ||||||
|  |     'indexed_generics' seems to work reliably in all use cases. As | ||||||
|  |     such, the others will likely be removed in the near future. | ||||||
|  | 
 | ||||||
|  |     ''' | ||||||
|  |     submsg_types: list[MsgType] = Msg.__subclasses__() | ||||||
|  |     bases: tuple = ( | ||||||
|  |         # XXX NOTE XXX the below generic-parameterization seems to | ||||||
|  |         # be THE ONLY way to get this to work correctly in terms | ||||||
|  |         # of getting ValidationError on a roundtrip? | ||||||
|  |         Msg[payload_type_union], | ||||||
|  |         Generic[PayloadT], | ||||||
|  |     ) | ||||||
|  |     defstruct_bases: tuple = ( | ||||||
|  |         Msg, # [payload_type_union], | ||||||
|  |         # Generic[PayloadT], | ||||||
|  |         # ^-XXX-^: not allowed? lul.. | ||||||
|  |     ) | ||||||
|  |     ipc_msg_types: list[Msg] = [] | ||||||
|  | 
 | ||||||
|  |     idx_msg_types: list[Msg] = [] | ||||||
|  |     defs_msg_types: list[Msg] = [] | ||||||
|  |     nc_msg_types: list[Msg] = [] | ||||||
|  | 
 | ||||||
|  |     for msgtype in __msg_types__: | ||||||
|  | 
 | ||||||
|  |         # for the NON-payload (user api) type specify-able | ||||||
|  |         # msgs types, we simply aggregate the def as is | ||||||
|  |         # for inclusion in the output type `Union`. | ||||||
|  |         if msgtype not in _payload_msgs: | ||||||
|  |             ipc_msg_types.append(msgtype) | ||||||
|  |             continue | ||||||
|  | 
 | ||||||
|  |         # check inheritance sanity | ||||||
|  |         assert msgtype in submsg_types | ||||||
|  | 
 | ||||||
|  |         # TODO: wait why do we need the dynamic version here? | ||||||
|  |         # XXX ANSWER XXX -> BC INHERITANCE.. don't work w generics.. | ||||||
|  |         # | ||||||
|  |         # NOTE previously bc msgtypes WERE NOT inheritting | ||||||
|  |         # directly the `Generic[PayloadT]` type, the manual method | ||||||
|  |         # of generic-paraming with `.__class_getitem__()` wasn't | ||||||
|  |         # working.. | ||||||
|  |         # | ||||||
|  |         # XXX but bc i changed that to make every subtype inherit | ||||||
|  |         # it, this manual "indexed parameterization" method seems | ||||||
|  |         # to work? | ||||||
|  |         # | ||||||
|  |         # -[x] paraming the `PayloadT` values via `Generic[T]` | ||||||
|  |         #   does work it seems but WITHOUT inheritance of generics | ||||||
|  |         # | ||||||
|  |         # -[-] is there a way to get it to work at module level | ||||||
|  |         #   just using inheritance or maybe a metaclass? | ||||||
|  |         #  => thot that `defstruct` might work, but NOPE, see | ||||||
|  |         #   below.. | ||||||
|  |         # | ||||||
|  |         idxed_msg_type: Msg = msgtype[payload_type_union] | ||||||
|  |         idx_msg_types.append(idxed_msg_type) | ||||||
|  | 
 | ||||||
|  |         # TODO: WHY do we need to dynamically generate the | ||||||
|  |         # subtype-msgs here to ensure the `.pld` parameterization | ||||||
|  |         # propagates as well as works at all in terms of the | ||||||
|  |         # `msgpack.Decoder()`..? | ||||||
|  |         # | ||||||
|  |         # dynamically create the payload type-spec-limited msg set. | ||||||
|  |         newclass_msgtype: Type = types.new_class( | ||||||
|  |             name=msgtype.__name__, | ||||||
|  |             bases=bases, | ||||||
|  |             kwds={}, | ||||||
|  |         ) | ||||||
|  |         nc_msg_types.append( | ||||||
|  |             newclass_msgtype[payload_type_union] | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # with `msgspec.structs.defstruct` | ||||||
|  |         # XXX ALSO DOESN'T WORK | ||||||
|  |         defstruct_msgtype = defstruct( | ||||||
|  |             name=msgtype.__name__, | ||||||
|  |             fields=[ | ||||||
|  |                 ('cid', str), | ||||||
|  | 
 | ||||||
|  |                 # XXX doesn't seem to work.. | ||||||
|  |                 # ('pld', PayloadT), | ||||||
|  | 
 | ||||||
|  |                 ('pld', payload_type_union), | ||||||
|  |             ], | ||||||
|  |             bases=defstruct_bases, | ||||||
|  |         ) | ||||||
|  |         defs_msg_types.append(defstruct_msgtype) | ||||||
|  | 
 | ||||||
|  |         # assert index_paramed_msg_type == manual_paramed_msg_subtype | ||||||
|  | 
 | ||||||
|  |         # paramed_msg_type = manual_paramed_msg_subtype | ||||||
|  | 
 | ||||||
|  |         # ipc_payload_msgs_type_union |= index_paramed_msg_type | ||||||
|  | 
 | ||||||
|  |     idx_spec: Union[Type[Msg]] = Union[*idx_msg_types] | ||||||
|  |     def_spec: Union[Type[Msg]] = Union[*defs_msg_types] | ||||||
|  |     nc_spec: Union[Type[Msg]] = Union[*nc_msg_types] | ||||||
|  | 
 | ||||||
|  |     specs: dict[str, Union[Type[Msg]]] = { | ||||||
|  |         'indexed_generics': idx_spec, | ||||||
|  |         'defstruct': def_spec, | ||||||
|  |         'types_new_class': nc_spec, | ||||||
|  |     } | ||||||
|  |     msgtypes_table: dict[str, list[Msg]] = { | ||||||
|  |         'indexed_generics': idx_msg_types, | ||||||
|  |         'defstruct': defs_msg_types, | ||||||
|  |         'types_new_class': nc_msg_types, | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     # XXX lol apparently type unions can't ever | ||||||
|  |     # be equal eh? | ||||||
|  |     # TODO: grok the diff here better.. | ||||||
|  |     # | ||||||
|  |     # assert ( | ||||||
|  |     #     idx_spec | ||||||
|  |     #     == | ||||||
|  |     #     nc_spec | ||||||
|  |     #     == | ||||||
|  |     #     def_spec | ||||||
|  |     # ) | ||||||
|  |     # breakpoint() | ||||||
|  | 
 | ||||||
|  |     pld_spec: Union[Type] = specs[spec_build_method] | ||||||
|  |     runtime_spec: Union[Type] = Union[*ipc_msg_types] | ||||||
|  |     ipc_spec = pld_spec | runtime_spec | ||||||
|  |     log.runtime( | ||||||
|  |         'Generating new IPC msg-spec\n' | ||||||
|  |         f'{ipc_spec}\n' | ||||||
|  |     ) | ||||||
|  |     assert ( | ||||||
|  |         ipc_spec | ||||||
|  |         and | ||||||
|  |         ipc_spec is not Any | ||||||
|  |     ) | ||||||
|  |     return ( | ||||||
|  |         ipc_spec, | ||||||
|  |         msgtypes_table[spec_build_method] | ||||||
|  |         + | ||||||
|  |         ipc_msg_types, | ||||||
|  |     ) | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -19,22 +19,13 @@ Sugary patterns for trio + tractor designs. | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| from ._mngrs import ( | from ._mngrs import ( | ||||||
|     gather_contexts, |     gather_contexts as gather_contexts, | ||||||
|     maybe_open_context, |     maybe_open_context as maybe_open_context, | ||||||
|     maybe_open_nursery, |     maybe_open_nursery as maybe_open_nursery, | ||||||
| ) | ) | ||||||
| from ._broadcast import ( | from ._broadcast import ( | ||||||
|     broadcast_receiver, |     AsyncReceiver as AsyncReceiver, | ||||||
|     BroadcastReceiver, |     broadcast_receiver as broadcast_receiver, | ||||||
|     Lagged, |     BroadcastReceiver as BroadcastReceiver, | ||||||
|  |     Lagged as Lagged, | ||||||
| ) | ) | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| __all__ = [ |  | ||||||
|     'gather_contexts', |  | ||||||
|     'broadcast_receiver', |  | ||||||
|     'BroadcastReceiver', |  | ||||||
|     'Lagged', |  | ||||||
|     'maybe_open_context', |  | ||||||
|     'maybe_open_nursery', |  | ||||||
| ] |  | ||||||
|  |  | ||||||
|  | @ -26,7 +26,6 @@ from contextlib import asynccontextmanager | ||||||
| from functools import partial | from functools import partial | ||||||
| from operator import ne | from operator import ne | ||||||
| from typing import ( | from typing import ( | ||||||
|     Optional, |  | ||||||
|     Callable, |     Callable, | ||||||
|     Awaitable, |     Awaitable, | ||||||
|     Any, |     Any, | ||||||
|  | @ -45,6 +44,11 @@ from tractor.log import get_logger | ||||||
| 
 | 
 | ||||||
| log = get_logger(__name__) | log = get_logger(__name__) | ||||||
| 
 | 
 | ||||||
|  | # TODO: use new type-vars syntax from 3.12 | ||||||
|  | # https://realpython.com/python312-new-features/#dedicated-type-variable-syntax | ||||||
|  | # https://docs.python.org/3/whatsnew/3.12.html#whatsnew312-pep695 | ||||||
|  | # https://docs.python.org/3/reference/simple_stmts.html#type | ||||||
|  | # | ||||||
| # A regular invariant generic type | # A regular invariant generic type | ||||||
| T = TypeVar("T") | T = TypeVar("T") | ||||||
| 
 | 
 | ||||||
|  | @ -110,7 +114,7 @@ class BroadcastState(Struct): | ||||||
| 
 | 
 | ||||||
|     # broadcast event to wake up all sleeping consumer tasks |     # broadcast event to wake up all sleeping consumer tasks | ||||||
|     # on a newly produced value from the sender. |     # on a newly produced value from the sender. | ||||||
|     recv_ready: Optional[tuple[int, trio.Event]] = None |     recv_ready: tuple[int, trio.Event]|None = None | ||||||
| 
 | 
 | ||||||
|     # if a ``trio.EndOfChannel`` is received on any |     # if a ``trio.EndOfChannel`` is received on any | ||||||
|     # consumer all consumers should be placed in this state |     # consumer all consumers should be placed in this state | ||||||
|  | @ -152,11 +156,12 @@ class BroadcastState(Struct): | ||||||
| 
 | 
 | ||||||
| class BroadcastReceiver(ReceiveChannel): | class BroadcastReceiver(ReceiveChannel): | ||||||
|     ''' |     ''' | ||||||
|     A memory receive channel broadcaster which is non-lossy for the |     A memory receive channel broadcaster which is non-lossy for | ||||||
|     fastest consumer. |     the fastest consumer. | ||||||
| 
 | 
 | ||||||
|     Additional consumer tasks can receive all produced values by registering |     Additional consumer tasks can receive all produced values by | ||||||
|     with ``.subscribe()`` and receiving from the new instance it delivers. |     registering with ``.subscribe()`` and receiving from the new | ||||||
|  |     instance it delivers. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     def __init__( |     def __init__( | ||||||
|  | @ -164,7 +169,7 @@ class BroadcastReceiver(ReceiveChannel): | ||||||
| 
 | 
 | ||||||
|         rx_chan: AsyncReceiver, |         rx_chan: AsyncReceiver, | ||||||
|         state: BroadcastState, |         state: BroadcastState, | ||||||
|         receive_afunc: Optional[Callable[[], Awaitable[Any]]] = None, |         receive_afunc: Callable[[], Awaitable[Any]]|None = None, | ||||||
|         raise_on_lag: bool = True, |         raise_on_lag: bool = True, | ||||||
| 
 | 
 | ||||||
|     ) -> None: |     ) -> None: | ||||||
|  | @ -377,7 +382,7 @@ class BroadcastReceiver(ReceiveChannel): | ||||||
|                         # likely it makes sense to unwind back to the |                         # likely it makes sense to unwind back to the | ||||||
|                         # underlying? |                         # underlying? | ||||||
|                         # import tractor |                         # import tractor | ||||||
|                         # await tractor.breakpoint() |                         # await tractor.pause() | ||||||
|                         log.warning( |                         log.warning( | ||||||
|                             f'Only one sub left for {self}?\n' |                             f'Only one sub left for {self}?\n' | ||||||
|                             'We can probably unwind from breceiver?' |                             'We can probably unwind from breceiver?' | ||||||
|  | @ -452,7 +457,7 @@ def broadcast_receiver( | ||||||
| 
 | 
 | ||||||
|     recv_chan: AsyncReceiver, |     recv_chan: AsyncReceiver, | ||||||
|     max_buffer_size: int, |     max_buffer_size: int, | ||||||
|     receive_afunc: Optional[Callable[[], Awaitable[Any]]] = None, |     receive_afunc: Callable[[], Awaitable[Any]]|None = None, | ||||||
|     raise_on_lag: bool = True, |     raise_on_lag: bool = True, | ||||||
| 
 | 
 | ||||||
| ) -> BroadcastReceiver: | ) -> BroadcastReceiver: | ||||||
|  |  | ||||||
|  | @ -18,8 +18,12 @@ | ||||||
| Async context manager primitives with hard ``trio``-aware semantics | Async context manager primitives with hard ``trio``-aware semantics | ||||||
| 
 | 
 | ||||||
| ''' | ''' | ||||||
| from contextlib import asynccontextmanager as acm | from __future__ import annotations | ||||||
|  | from contextlib import ( | ||||||
|  |     asynccontextmanager as acm, | ||||||
|  | ) | ||||||
| import inspect | import inspect | ||||||
|  | from types import ModuleType | ||||||
| from typing import ( | from typing import ( | ||||||
|     Any, |     Any, | ||||||
|     AsyncContextManager, |     AsyncContextManager, | ||||||
|  | @ -30,13 +34,15 @@ from typing import ( | ||||||
|     Optional, |     Optional, | ||||||
|     Sequence, |     Sequence, | ||||||
|     TypeVar, |     TypeVar, | ||||||
|  |     TYPE_CHECKING, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| import trio | import trio | ||||||
| from trio_typing import TaskStatus | from tractor._state import current_actor | ||||||
|  | from tractor.log import get_logger | ||||||
| 
 | 
 | ||||||
| from .._state import current_actor | if TYPE_CHECKING: | ||||||
| from ..log import get_logger |     from tractor import ActorNursery | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| log = get_logger(__name__) | log = get_logger(__name__) | ||||||
|  | @ -47,8 +53,10 @@ T = TypeVar("T") | ||||||
| 
 | 
 | ||||||
| @acm | @acm | ||||||
| async def maybe_open_nursery( | async def maybe_open_nursery( | ||||||
|     nursery: trio.Nursery | None = None, |     nursery: trio.Nursery|ActorNursery|None = None, | ||||||
|     shield: bool = False, |     shield: bool = False, | ||||||
|  |     lib: ModuleType = trio, | ||||||
|  | 
 | ||||||
| ) -> AsyncGenerator[trio.Nursery, Any]: | ) -> AsyncGenerator[trio.Nursery, Any]: | ||||||
|     ''' |     ''' | ||||||
|     Create a new nursery if None provided. |     Create a new nursery if None provided. | ||||||
|  | @ -59,17 +67,17 @@ async def maybe_open_nursery( | ||||||
|     if nursery is not None: |     if nursery is not None: | ||||||
|         yield nursery |         yield nursery | ||||||
|     else: |     else: | ||||||
|         async with trio.open_nursery() as nursery: |         async with lib.open_nursery() as nursery: | ||||||
|             nursery.cancel_scope.shield = shield |             nursery.cancel_scope.shield = shield | ||||||
|             yield nursery |             yield nursery | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def _enter_and_wait( | async def _enter_and_wait( | ||||||
| 
 |  | ||||||
|     mngr: AsyncContextManager[T], |     mngr: AsyncContextManager[T], | ||||||
|     unwrapped: dict[int, T], |     unwrapped: dict[int, T], | ||||||
|     all_entered: trio.Event, |     all_entered: trio.Event, | ||||||
|     parent_exit: trio.Event, |     parent_exit: trio.Event, | ||||||
|  |     seed: int, | ||||||
| 
 | 
 | ||||||
| ) -> None: | ) -> None: | ||||||
|     ''' |     ''' | ||||||
|  | @ -80,7 +88,10 @@ async def _enter_and_wait( | ||||||
|     async with mngr as value: |     async with mngr as value: | ||||||
|         unwrapped[id(mngr)] = value |         unwrapped[id(mngr)] = value | ||||||
| 
 | 
 | ||||||
|         if all(unwrapped.values()): |         if all( | ||||||
|  |             val != seed | ||||||
|  |             for val in unwrapped.values() | ||||||
|  |         ): | ||||||
|             all_entered.set() |             all_entered.set() | ||||||
| 
 | 
 | ||||||
|         await parent_exit.wait() |         await parent_exit.wait() | ||||||
|  | @ -88,23 +99,34 @@ async def _enter_and_wait( | ||||||
| 
 | 
 | ||||||
| @acm | @acm | ||||||
| async def gather_contexts( | async def gather_contexts( | ||||||
| 
 |  | ||||||
|     mngrs: Sequence[AsyncContextManager[T]], |     mngrs: Sequence[AsyncContextManager[T]], | ||||||
| 
 | 
 | ||||||
| ) -> AsyncGenerator[tuple[Optional[T], ...], None]: | ) -> AsyncGenerator[ | ||||||
|  |     tuple[ | ||||||
|  |         T | None, | ||||||
|  |         ... | ||||||
|  |     ], | ||||||
|  |     None, | ||||||
|  | ]: | ||||||
|     ''' |     ''' | ||||||
|     Concurrently enter a sequence of async context managers, each in |     Concurrently enter a sequence of async context managers (acms), | ||||||
|     a separate ``trio`` task and deliver the unwrapped values in the |     each from a separate `trio` task and deliver the unwrapped | ||||||
|     same order once all managers have entered. On exit all contexts are |     `yield`-ed values in the same order once all managers have entered. | ||||||
|     subsequently and concurrently exited. |  | ||||||
| 
 | 
 | ||||||
|     This function is somewhat similar to common usage of |     On exit, all acms are subsequently and concurrently exited. | ||||||
|     ``contextlib.AsyncExitStack.enter_async_context()`` (in a loop) in | 
 | ||||||
|     combo with ``asyncio.gather()`` except the managers are concurrently |     This function is somewhat similar to a batch of non-blocking | ||||||
|     entered and exited, and cancellation just works. |     calls to `contextlib.AsyncExitStack.enter_async_context()` | ||||||
|  |     (inside a loop) *in combo with* a `asyncio.gather()` to get the | ||||||
|  |     `.__aenter__()`-ed values, except the managers are both | ||||||
|  |     concurrently entered and exited and *cancellation just works*(R). | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     unwrapped: dict[int, Optional[T]] = {}.fromkeys(id(mngr) for mngr in mngrs) |     seed: int = id(mngrs) | ||||||
|  |     unwrapped: dict[int, T | None] = {}.fromkeys( | ||||||
|  |         (id(mngr) for mngr in mngrs), | ||||||
|  |         seed, | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     all_entered = trio.Event() |     all_entered = trio.Event() | ||||||
|     parent_exit = trio.Event() |     parent_exit = trio.Event() | ||||||
|  | @ -116,8 +138,9 @@ async def gather_contexts( | ||||||
| 
 | 
 | ||||||
|     if not mngrs: |     if not mngrs: | ||||||
|         raise ValueError( |         raise ValueError( | ||||||
|             'input mngrs is empty?\n' |             '`.trionics.gather_contexts()` input mngrs is empty?\n' | ||||||
|             'Did try to use inline generator syntax?' |             'Did try to use inline generator syntax?\n' | ||||||
|  |             'Use a non-lazy iterator or sequence type intead!' | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     async with trio.open_nursery() as n: |     async with trio.open_nursery() as n: | ||||||
|  | @ -128,6 +151,7 @@ async def gather_contexts( | ||||||
|                 unwrapped, |                 unwrapped, | ||||||
|                 all_entered, |                 all_entered, | ||||||
|                 parent_exit, |                 parent_exit, | ||||||
|  |                 seed, | ||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|         # deliver control once all managers have started up |         # deliver control once all managers have started up | ||||||
|  | @ -168,7 +192,7 @@ class _Cache: | ||||||
|         cls, |         cls, | ||||||
|         mng, |         mng, | ||||||
|         ctx_key: tuple, |         ctx_key: tuple, | ||||||
|         task_status: TaskStatus[T] = trio.TASK_STATUS_IGNORED, |         task_status: trio.TaskStatus[T] = trio.TASK_STATUS_IGNORED, | ||||||
| 
 | 
 | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         async with mng as value: |         async with mng as value: | ||||||
|  | @ -195,9 +219,10 @@ async def maybe_open_context( | ||||||
| 
 | 
 | ||||||
| ) -> AsyncIterator[tuple[bool, T]]: | ) -> AsyncIterator[tuple[bool, T]]: | ||||||
|     ''' |     ''' | ||||||
|     Maybe open a context manager if there is not already a _Cached |     Maybe open an async-context-manager (acm) if there is not already | ||||||
|     version for the provided ``key`` for *this* actor. Return the |     a `_Cached` version for the provided (input) `key` for *this* actor. | ||||||
|     _Cached instance on a _Cache hit. | 
 | ||||||
|  |     Return the `_Cached` instance on a _Cache hit. | ||||||
| 
 | 
 | ||||||
|     ''' |     ''' | ||||||
|     fid = id(acm_func) |     fid = id(acm_func) | ||||||
|  | @ -209,6 +234,7 @@ async def maybe_open_context( | ||||||
| 
 | 
 | ||||||
|     # yielded output |     # yielded output | ||||||
|     yielded: Any = None |     yielded: Any = None | ||||||
|  |     lock_registered: bool = False | ||||||
| 
 | 
 | ||||||
|     # Lock resource acquisition around task racing  / ``trio``'s |     # Lock resource acquisition around task racing  / ``trio``'s | ||||||
|     # scheduler protocol. |     # scheduler protocol. | ||||||
|  | @ -216,6 +242,7 @@ async def maybe_open_context( | ||||||
|     # to allow re-entrant use cases where one `maybe_open_context()` |     # to allow re-entrant use cases where one `maybe_open_context()` | ||||||
|     # wrapped factor may want to call into another. |     # wrapped factor may want to call into another. | ||||||
|     lock = _Cache.locks.setdefault(fid, trio.Lock()) |     lock = _Cache.locks.setdefault(fid, trio.Lock()) | ||||||
|  |     lock_registered: bool = True | ||||||
|     await lock.acquire() |     await lock.acquire() | ||||||
| 
 | 
 | ||||||
|     # XXX: one singleton nursery per actor and we want to |     # XXX: one singleton nursery per actor and we want to | ||||||
|  | @ -254,8 +281,16 @@ async def maybe_open_context( | ||||||
|         yield False, yielded |         yield False, yielded | ||||||
| 
 | 
 | ||||||
|     else: |     else: | ||||||
|         log.info(f'Reusing _Cached resource for {ctx_key}') |  | ||||||
|         _Cache.users += 1 |         _Cache.users += 1 | ||||||
|  |         log.runtime( | ||||||
|  |             f'Re-using cached resource for user {_Cache.users}\n\n' | ||||||
|  |             f'{ctx_key!r} -> {type(yielded)}\n' | ||||||
|  | 
 | ||||||
|  |             # TODO: make this work with values but without | ||||||
|  |             # `msgspec.Struct` causing frickin crashes on field-type | ||||||
|  |             # lookups.. | ||||||
|  |             # f'{ctx_key!r} -> {yielded!r}\n' | ||||||
|  |         ) | ||||||
|         lock.release() |         lock.release() | ||||||
|         yield True, yielded |         yield True, yielded | ||||||
| 
 | 
 | ||||||
|  | @ -275,4 +310,9 @@ async def maybe_open_context( | ||||||
|                     _, no_more_users = entry |                     _, no_more_users = entry | ||||||
|                     no_more_users.set() |                     no_more_users.set() | ||||||
| 
 | 
 | ||||||
|                 _Cache.locks.pop(fid) |                 if lock_registered: | ||||||
|  |                     maybe_lock = _Cache.locks.pop(fid, None) | ||||||
|  |                     if maybe_lock is None: | ||||||
|  |                         log.error( | ||||||
|  |                             f'Resource lock for {fid} ALREADY POPPED?' | ||||||
|  |                         ) | ||||||
|  |  | ||||||
|  | @ -0,0 +1,533 @@ | ||||||
|  | version = 1 | ||||||
|  | revision = 1 | ||||||
|  | requires-python = ">=3.11" | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "async-generator" | ||||||
|  | version = "1.10" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/ce/b6/6fa6b3b598a03cba5e80f829e0dadbb49d7645f523d209b2fb7ea0bbb02a/async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144", size = 29870 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/71/52/39d20e03abd0ac9159c162ec24b93fbcaa111e8400308f2465432495ca2b/async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b", size = 18857 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "attrs" | ||||||
|  | version = "24.3.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "cffi" | ||||||
|  | version = "1.17.1" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "pycparser" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "colorama" | ||||||
|  | version = "0.4.6" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "colorlog" | ||||||
|  | version = "6.9.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "colorama", marker = "sys_platform == 'win32'" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "greenback" | ||||||
|  | version = "1.2.1" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "greenlet" }, | ||||||
|  |     { name = "outcome" }, | ||||||
|  |     { name = "sniffio" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/dc/c1/ab3a42c0f3ed56df9cd33de1539b3198d98c6ccbaf88a73d6be0b72d85e0/greenback-1.2.1.tar.gz", hash = "sha256:de3ca656885c03b96dab36079f3de74bb5ba061da9bfe3bb69dccc866ef95ea3", size = 42597 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/71/d0/b8dc79d5ecfffacad9c844b6ae76b9c6259935796d3c561deccbf8fa421d/greenback-1.2.1-py3-none-any.whl", hash = "sha256:98768edbbe4340091a9730cf64a683fcbaa3f2cb81e4ac41d7ed28d3b6f74b79", size = 28062 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "greenlet" | ||||||
|  | version = "3.1.1" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/28/62/1c2665558618553c42922ed47a4e6d6527e2fa3516a8256c2f431c5d0441/greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70", size = 272479 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/76/9d/421e2d5f07285b6e4e3a676b016ca781f63cfe4a0cd8eaecf3fd6f7a71ae/greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159", size = 640404 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/e5/de/6e05f5c59262a584e502dd3d261bbdd2c97ab5416cc9c0b91ea38932a901/greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e", size = 652813 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/49/93/d5f93c84241acdea15a8fd329362c2c71c79e1a507c3f142a5d67ea435ae/greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1", size = 648517 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/15/85/72f77fc02d00470c86a5c982b8daafdf65d38aefbbe441cebff3bf7037fc/greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383", size = 647831 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/f7/4b/1c9695aa24f808e156c8f4813f685d975ca73c000c2a5056c514c64980f6/greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a", size = 602413 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/76/70/ad6e5b31ef330f03b12559d19fda2606a522d3849cde46b24f223d6d1619/greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511", size = 1129619 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/f4/fb/201e1b932e584066e0f0658b538e73c459b34d44b4bd4034f682423bc801/greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395", size = 1155198 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/12/da/b9ed5e310bb8b89661b80cbcd4db5a067903bbcd7fc854923f5ebb4144f0/greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39", size = 298930 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", size = 274260 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", size = 649064 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", size = 663420 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", size = 658035 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", size = 660105 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", size = 613077 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", size = 1135975 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", size = 1163955 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", size = 299655 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "idna" | ||||||
|  | version = "3.10" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "importlib-metadata" | ||||||
|  | version = "8.6.1" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "zipp" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/79/9d/0fb148dc4d6fa4a7dd1d8378168d9b4cd8d4560a6fbf6f0121c5fc34eb68/importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e", size = 26971 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "iniconfig" | ||||||
|  | version = "2.0.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "msgspec" | ||||||
|  | version = "0.19.0" | ||||||
|  | source = { git = "https://github.com/jcrist/msgspec.git#dd965dce22e5278d4935bea923441ecde31b5325" } | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "mypy-extensions" | ||||||
|  | version = "1.0.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "outcome" | ||||||
|  | version = "1.3.0.post0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "attrs" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "packaging" | ||||||
|  | version = "24.2" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "pdbp" | ||||||
|  | version = "1.6.1" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "colorama", marker = "sys_platform == 'win32'" }, | ||||||
|  |     { name = "pygments" }, | ||||||
|  |     { name = "tabcompleter" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/69/13/80da03638f62facbee76312ca9ee5941c017b080f2e4c6919fd4e87e16e3/pdbp-1.6.1.tar.gz", hash = "sha256:f4041642952a05df89664e166d5bd379607a0866ddd753c06874f65552bdf40b", size = 25322 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/29/93/d56fb9ba5569dc29d8263c72e46d21a2fd38741339ebf03f54cf7561828c/pdbp-1.6.1-py3-none-any.whl", hash = "sha256:f10bad2ee044c0e5c168cb0825abfdbdc01c50013e9755df5261b060bdd35c22", size = 21495 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "pexpect" | ||||||
|  | version = "4.9.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "ptyprocess" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "pluggy" | ||||||
|  | version = "1.5.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "prompt-toolkit" | ||||||
|  | version = "3.0.50" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "wcwidth" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/a1/e1/bd15cb8ffdcfeeb2bdc215de3c3cffca11408d829e4b8416dcfe71ba8854/prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", size = 429087 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/e4/ea/d836f008d33151c7a1f62caf3d8dd782e4d15f6a43897f64480c2b8de2ad/prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198", size = 387816 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "ptyprocess" | ||||||
|  | version = "0.7.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "pycparser" | ||||||
|  | version = "2.22" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "pygments" | ||||||
|  | version = "2.19.1" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "pyperclip" | ||||||
|  | version = "1.9.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/30/23/2f0a3efc4d6a32f3b63cdff36cd398d9701d26cda58e3ab97ac79fb5e60d/pyperclip-1.9.0.tar.gz", hash = "sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310", size = 20961 } | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "pyreadline3" | ||||||
|  | version = "3.5.4" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "pytest" | ||||||
|  | version = "8.3.4" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "colorama", marker = "sys_platform == 'win32'" }, | ||||||
|  |     { name = "iniconfig" }, | ||||||
|  |     { name = "packaging" }, | ||||||
|  |     { name = "pluggy" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "sniffio" | ||||||
|  | version = "1.3.1" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "sortedcontainers" | ||||||
|  | version = "2.4.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "stackscope" | ||||||
|  | version = "0.2.2" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/4a/fc/20dbb993353f31230138f3c63f3f0c881d1853e70d7a30cd68d2ba4cf1e2/stackscope-0.2.2.tar.gz", hash = "sha256:f508c93eb4861ada466dd3ff613ca203962ceb7587ad013759f15394e6a4e619", size = 90479 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/f1/5f/0a674fcafa03528089badb46419413f342537b5b57d2fefc9900fb8ee4e4/stackscope-0.2.2-py3-none-any.whl", hash = "sha256:c199b0cda738d39c993ee04eb01961b06b7e9aeb43ebf9fd6226cdd72ea9faf6", size = 80807 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "tabcompleter" | ||||||
|  | version = "1.4.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "pyreadline3", marker = "sys_platform == 'win32'" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/73/1a/ed3544579628c5709bae6fae2255e94c6982a9ff77d42d8ba59fd2f3b21a/tabcompleter-1.4.0.tar.gz", hash = "sha256:7562a9938e62f8e7c3be612c3ac4e14c5ec4307b58ba9031c148260e866e8814", size = 10431 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/65/44/bb509c3d2c0b5a87e7a5af1d5917a402a32ff026f777a6d7cb6990746cbb/tabcompleter-1.4.0-py3-none-any.whl", hash = "sha256:d744aa735b49c0a6cc2fb8fcd40077fec47425e4388301010b14e6ce3311368b", size = 6725 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "tractor" | ||||||
|  | version = "0.1.0a6.dev0" | ||||||
|  | source = { editable = "." } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "colorlog" }, | ||||||
|  |     { name = "msgspec" }, | ||||||
|  |     { name = "pdbp" }, | ||||||
|  |     { name = "tricycle" }, | ||||||
|  |     { name = "trio" }, | ||||||
|  |     { name = "trio-typing" }, | ||||||
|  |     { name = "wrapt" }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [package.dev-dependencies] | ||||||
|  | dev = [ | ||||||
|  |     { name = "greenback" }, | ||||||
|  |     { name = "pexpect" }, | ||||||
|  |     { name = "prompt-toolkit" }, | ||||||
|  |     { name = "pyperclip" }, | ||||||
|  |     { name = "pytest" }, | ||||||
|  |     { name = "stackscope" }, | ||||||
|  |     { name = "xonsh" }, | ||||||
|  |     { name = "xonsh-vox-tabcomplete" }, | ||||||
|  |     { name = "xontrib-vox" }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [package.metadata] | ||||||
|  | requires-dist = [ | ||||||
|  |     { name = "colorlog", specifier = ">=6.8.2,<7" }, | ||||||
|  |     { name = "msgspec", git = "https://github.com/jcrist/msgspec.git" }, | ||||||
|  |     { name = "pdbp", specifier = ">=1.5.0,<2" }, | ||||||
|  |     { name = "tricycle", specifier = ">=0.4.1,<0.5" }, | ||||||
|  |     { name = "trio", specifier = ">=0.24,<0.25" }, | ||||||
|  |     { name = "trio-typing", specifier = ">=0.10.0,<0.11" }, | ||||||
|  |     { name = "wrapt", specifier = ">=1.16.0,<2" }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [package.metadata.requires-dev] | ||||||
|  | dev = [ | ||||||
|  |     { name = "greenback", specifier = ">=1.2.1,<2" }, | ||||||
|  |     { name = "pexpect", specifier = ">=4.9.0,<5" }, | ||||||
|  |     { name = "prompt-toolkit", specifier = ">=3.0.43,<4" }, | ||||||
|  |     { name = "pyperclip", specifier = ">=1.9.0" }, | ||||||
|  |     { name = "pytest", specifier = ">=8.2.0,<9" }, | ||||||
|  |     { name = "stackscope", specifier = ">=0.2.2,<0.3" }, | ||||||
|  |     { name = "xonsh", specifier = ">=0.19.1" }, | ||||||
|  |     { name = "xonsh-vox-tabcomplete", specifier = ">=0.5,<0.6" }, | ||||||
|  |     { name = "xontrib-vox", specifier = ">=0.0.1,<0.0.2" }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "tricycle" | ||||||
|  | version = "0.4.1" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "trio" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/f8/8e/fdd7bc467b40eedd0a5f2ed36b0d692c6e6f2473be00c8160e2e9f53adc1/tricycle-0.4.1.tar.gz", hash = "sha256:f56edb4b3e1bed3e2552b1b499b24a2dab47741e92e9b4d806acc5c35c9e6066", size = 41551 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/d7/c6/7cc05d60e21c683df99167db071ce5d848f5063c2a63971a8443466f603e/tricycle-0.4.1-py3-none-any.whl", hash = "sha256:67900995a73e7445e2c70250cdca04a778d9c3923dd960a97ad4569085e0fb3f", size = 35316 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "trio" | ||||||
|  | version = "0.24.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "attrs" }, | ||||||
|  |     { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, | ||||||
|  |     { name = "idna" }, | ||||||
|  |     { name = "outcome" }, | ||||||
|  |     { name = "sniffio" }, | ||||||
|  |     { name = "sortedcontainers" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/8a/f3/07c152213222c615fe2391b8e1fea0f5af83599219050a549c20fcbd9ba2/trio-0.24.0.tar.gz", hash = "sha256:ffa09a74a6bf81b84f8613909fb0beaee84757450183a7a2e0b47b455c0cac5d", size = 545131 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/14/fb/9299cf74953f473a15accfdbe2c15218e766bae8c796f2567c83bae03e98/trio-0.24.0-py3-none-any.whl", hash = "sha256:c3bd3a4e3e3025cd9a2241eae75637c43fe0b9e88b4c97b9161a55b9e54cd72c", size = 460205 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "trio-typing" | ||||||
|  | version = "0.10.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "async-generator" }, | ||||||
|  |     { name = "importlib-metadata" }, | ||||||
|  |     { name = "mypy-extensions" }, | ||||||
|  |     { name = "packaging" }, | ||||||
|  |     { name = "trio" }, | ||||||
|  |     { name = "typing-extensions" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/b5/74/a87aafa40ec3a37089148b859892cbe2eef08d132c816d58a60459be5337/trio-typing-0.10.0.tar.gz", hash = "sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3", size = 38747 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/89/ff/9bd795273eb14fac7f6a59d16cc8c4d0948a619a1193d375437c7f50f3eb/trio_typing-0.10.0-py3-none-any.whl", hash = "sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264", size = 42224 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "typing-extensions" | ||||||
|  | version = "4.12.2" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "wcwidth" | ||||||
|  | version = "0.2.13" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "wrapt" | ||||||
|  | version = "1.17.2" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/cd/f7/a2aab2cbc7a665efab072344a8949a71081eed1d2f451f7f7d2b966594a2/wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58", size = 53308 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/50/ff/149aba8365fdacef52b31a258c4dc1c57c79759c335eff0b3316a2664a64/wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda", size = 38488 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/65/46/5a917ce85b5c3b490d35c02bf71aedaa9f2f63f2d15d9949cc4ba56e8ba9/wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438", size = 38776 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/ca/74/336c918d2915a4943501c77566db41d1bd6e9f4dbc317f356b9a244dfe83/wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a", size = 83776 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/09/99/c0c844a5ccde0fe5761d4305485297f91d67cf2a1a824c5f282e661ec7ff/wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000", size = 75420 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/b4/b0/9fc566b0fe08b282c850063591a756057c3247b2362b9286429ec5bf1721/wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6", size = 83199 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/9d/4b/71996e62d543b0a0bd95dda485219856def3347e3e9380cc0d6cf10cfb2f/wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b", size = 82307 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/39/35/0282c0d8789c0dc9bcc738911776c762a701f95cfe113fb8f0b40e45c2b9/wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662", size = 75025 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/4f/6d/90c9fd2c3c6fee181feecb620d95105370198b6b98a0770cba090441a828/wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72", size = 81879 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/8f/fa/9fb6e594f2ce03ef03eddbdb5f4f90acb1452221a5351116c7c4708ac865/wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317", size = 36419 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/47/f8/fb1773491a253cbc123c5d5dc15c86041f746ed30416535f2a8df1f4a392/wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3", size = 38773 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "xonsh" | ||||||
|  | version = "0.19.1" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/98/6e/b54a0b2685535995ee50f655103c463f9d339455c9b08c4bce3e03e7bb17/xonsh-0.19.1.tar.gz", hash = "sha256:5d3de649c909f6d14bc69232219bcbdb8152c830e91ddf17ad169c672397fb97", size = 796468 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/8c/e6/db44068c5725af9678e37980ae9503165393d51b80dc8517fa4ec74af1cf/xonsh-0.19.1-py310-none-any.whl", hash = "sha256:83eb6610ed3535f8542abd80af9554fb7e2805b0b3f96e445f98d4b5cf1f7046", size = 640686 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/77/4e/e487e82349866b245c559433c9ba626026a2e66bd17d7f9ac1045082f146/xonsh-0.19.1-py311-none-any.whl", hash = "sha256:c176e515b0260ab803963d1f0924f1e32f1064aa6fd5d791aa0cf6cda3a924ae", size = 640680 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/5d/88/09060815548219b8f6953a06c247cb5c92d03cbdf7a02a980bda1b5754db/xonsh-0.19.1-py312-none-any.whl", hash = "sha256:fe1266c86b117aced3bdc4d5972420bda715864435d0bd3722d63451e8001036", size = 640604 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/83/ff/7873cb8184cffeafddbf861712831c2baa2e9dbecdbfd33b1228f0db0019/xonsh-0.19.1-py313-none-any.whl", hash = "sha256:3f158b6fc0bba954e0b989004d4261bafc4bd94c68c2abd75b825da23e5a869c", size = 641166 }, | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/cc/03/b9f8dd338df0a330011d104e63d4d0acd8bbbc1e990ff049487b6bdf585d/xonsh-0.19.1-py39-none-any.whl", hash = "sha256:a900a6eb87d881a7ef90b1ac8522ba3699582f0bcb1e9abd863d32f6d63faf04", size = 632912 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "xonsh-vox-tabcomplete" | ||||||
|  | version = "0.5" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/ab/fd/af0c2ee6c067c2a4dc64ec03598c94de1f6ec5984b3116af917f3add4a16/xonsh_vox_tabcomplete-0.5-py3-none-any.whl", hash = "sha256:9701b198180f167071234e77eab87b7befa97c1873b088d0b3fbbe6d6d8dcaad", size = 14381 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "xontrib-vox" | ||||||
|  | version = "0.0.1" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | dependencies = [ | ||||||
|  |     { name = "xonsh" }, | ||||||
|  | ] | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/6c/ac/a5db68a1f2e4036f7ff4c8546b1cbe29edee2ff40e0ff931836745988b79/xontrib-vox-0.0.1.tar.gz", hash = "sha256:c1f0b155992b4b0ebe6dcfd651084a8707ade7372f7e456c484d2a85339d9907", size = 16504 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/23/58/dcdf11849c8340033da00669527ce75d8292a4e8d82605c082ed236a081a/xontrib_vox-0.0.1-py3-none-any.whl", hash = "sha256:df2bbb815832db5b04d46684f540eac967ee40ef265add2662a95d6947d04c70", size = 13467 }, | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "zipp" | ||||||
|  | version = "3.21.0" | ||||||
|  | source = { registry = "https://pypi.org/simple" } | ||||||
|  | sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 } | ||||||
|  | wheels = [ | ||||||
|  |     { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 }, | ||||||
|  | ] | ||||||
		Loading…
	
		Reference in New Issue