Skip to content

Commit cb99fb1

Browse files
author
Andrew J Westlake
committed
Added some docs, examples, testing feature separation
1 parent a43bb5f commit cb99fb1

File tree

10 files changed

+219
-61
lines changed

10 files changed

+219
-61
lines changed

.githooks/pre-commit

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
#!/bin/bash
22

3-
cargo check --all-targets
3+
cargo check --all-targets --features testing

.githooks/pre-push

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
#!/bin/bash
22

3-
cargo test
3+
cargo test --features testing

.github/workflows/ci.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,28 +85,28 @@ jobs:
8585
run: echo LD_LIBRARY_PATH=${pythonLocation}/lib >> $GITHUB_ENV
8686

8787
- name: Build docs
88-
run: cargo doc --no-default-features --verbose --target ${{ matrix.platform.rust-target }}
88+
run: cargo doc --no-default-features --features testing --verbose --target ${{ matrix.platform.rust-target }}
8989

9090
- name: Build (no features)
9191
run: cargo build --no-default-features --verbose --target ${{ matrix.platform.rust-target }}
9292

9393
- name: Build (all additive features)
94-
run: cargo build --no-default-features --verbose --target ${{ matrix.platform.rust-target }}
94+
run: cargo build --no-default-features --features testing --verbose --target ${{ matrix.platform.rust-target }}
9595

9696
# Run tests (except on PyPy, because no embedding API).
9797
- if: matrix.python-version != 'pypy-3.6'
9898
name: Test
99-
run: cargo test --no-default-features --target ${{ matrix.platform.rust-target }}
99+
run: cargo test --no-default-features --features testing --target ${{ matrix.platform.rust-target }}
100100

101101
# Run tests again, but in abi3 mode
102102
- if: matrix.python-version != 'pypy-3.6'
103103
name: Test (abi3)
104-
run: cargo test --no-default-features --target ${{ matrix.platform.rust-target }}
104+
run: cargo test --no-default-features --features testing --target ${{ matrix.platform.rust-target }}
105105

106106
# Run tests again, for abi3-py36 (the minimal Python version)
107107
- if: (matrix.python-version != 'pypy-3.6') && (matrix.python-version != '3.6')
108108
name: Test (abi3-py36)
109-
run: cargo test --no-default-features --target ${{ matrix.platform.rust-target }}
109+
run: cargo test --no-default-features --features testing --target ${{ matrix.platform.rust-target }}
110110

111111
- name: Install python test dependencies
112112
run: |

.github/workflows/guide.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
# This adds the docs to gh-pages-build/doc
2222
- name: Build the doc
2323
run: |
24-
cargo doc --no-deps
24+
cargo doc --no-deps --features testing
2525
mkdir -p gh-pages-build
2626
cp -r target/doc gh-pages-build/doc
2727
echo "<meta http-equiv=refresh content=0;url=pyo3/index.html>" > gh-pages-build/doc/index.html

Cargo.toml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,24 @@ edition = "2018"
66

77
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
88

9+
[features]
10+
testing = ["clap"]
11+
default = []
12+
913
[[test]]
1014
name = "test_asyncio"
1115
path = "pytests/test_asyncio.rs"
1216
harness = false
17+
required-features = ["testing"]
18+
19+
[[test]]
20+
name = "test_run_forever"
21+
path = "pytests/test_run_forever.rs"
22+
harness = false
23+
required-features = ["testing"]
1324

1425
[dependencies]
15-
clap = "2.33"
26+
clap = { version = "2.33", optional = true }
1627
futures = "0.3"
1728
lazy_static = "1.4"
1829
once_cell = "1.5"

README.md

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
[Rust](http://www.rust-lang.org/) bindings for [Python](https://www.python.org/). This includes running and interacting with Python code from a Rust binary, as well as writing native Python modules.
99

10-
* API Documentation: [stable](https://docs.rs/pyo3-asyncio/)
10+
* API Documentation: [stable](https://docs.rs/pyo3-asyncio/) | [master](https://awestlake87.github.io/pyo3-asyncio/master/doc)
1111

1212
* Contributing Notes: [github](https://github.com/awestlake87/pyo3-asyncio/blob/master/Contributing.md)
1313

@@ -33,13 +33,4 @@ to propagate through the Python layers. This allows panics in tests to exit the
3333
event loop like before, but may cause unexpected behaviour if a Rust future
3434
awaits a Python coroutine that awaits a Rust future that panics.
3535

36-
> These scenarios are currently untested in this crate.
37-
38-
## Running the Test
39-
40-
You can run the example test the same way you'd run any other Cargo integration
41-
test.
42-
43-
```
44-
$ cargo test
45-
```
36+
> These scenarios are currently untested in this crate.

pytests/test_asyncio.rs

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,7 @@ use std::{future::Future, thread, time::Duration};
33
use futures::stream::{self};
44
use pyo3::prelude::*;
55

6-
use pyo3_asyncio::test::{parse_args, test_harness, Test};
7-
8-
fn dump_err(py: Python<'_>) -> impl FnOnce(PyErr) + '_ {
9-
move |e| {
10-
// We can't display Python exceptions via std::fmt::Display,
11-
// so print the error here manually.
12-
e.print_and_set_sys_last_vars(py);
13-
}
14-
}
6+
use pyo3_asyncio::testing::{test_main, Test};
157

168
fn test_async_sleep<'p>(
179
py: Python<'p>,
@@ -40,30 +32,21 @@ fn test_blocking_sleep() {
4032
println!("success");
4133
}
4234

43-
fn py_main(py: Python) -> PyResult<()> {
44-
let args = parse_args("Pyo3 Asyncio Test Suite");
45-
46-
pyo3_asyncio::try_init(py)?;
47-
48-
pyo3_asyncio::run_until_complete(
49-
py,
50-
test_harness(
51-
stream::iter(vec![
52-
Test::new_async("test_async_sleep".into(), test_async_sleep(py)?),
53-
Test::new_sync("test_blocking_sleep".into(), || {
54-
test_blocking_sleep();
55-
Ok(())
56-
}),
57-
]),
58-
args,
59-
),
60-
)?;
61-
62-
Ok(())
63-
}
64-
6535
fn main() {
66-
Python::with_gil(|py| {
67-
py_main(py).map_err(dump_err(py)).unwrap();
68-
});
36+
test_main(stream::iter(vec![
37+
Test::new_async(
38+
"test_async_sleep".into(),
39+
Python::with_gil(|py| {
40+
test_async_sleep(py)
41+
.map_err(|e| {
42+
e.print_and_set_sys_last_vars(py);
43+
})
44+
.unwrap()
45+
}),
46+
),
47+
Test::new_sync("test_blocking_sleep".into(), || {
48+
test_blocking_sleep();
49+
Ok(())
50+
}),
51+
]))
6952
}

pytests/test_run_forever.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use std::time::Duration;
2+
3+
use pyo3::prelude::*;
4+
5+
fn dump_err(py: Python<'_>) -> impl FnOnce(PyErr) + '_ {
6+
move |e| {
7+
// We can't display Python exceptions via std::fmt::Display,
8+
// so print the error here manually.
9+
e.print_and_set_sys_last_vars(py);
10+
}
11+
}
12+
13+
fn main() {
14+
Python::with_gil(|py| {
15+
pyo3_asyncio::with_runtime(py, || {
16+
pyo3_asyncio::spawn(async move {
17+
tokio::time::sleep(Duration::from_secs(1)).await;
18+
19+
Python::with_gil(|py| {
20+
let event_loop = pyo3_asyncio::get_event_loop();
21+
22+
event_loop
23+
.call_method1(
24+
py,
25+
"call_soon_threadsafe",
26+
(event_loop
27+
.getattr(py, "stop")
28+
.map_err(dump_err(py))
29+
.unwrap(),),
30+
)
31+
.map_err(dump_err(py))
32+
.unwrap();
33+
})
34+
});
35+
36+
pyo3_asyncio::run_forever(py)?;
37+
38+
println!("test test_run_forever ... ok");
39+
Ok(())
40+
})
41+
.map_err(dump_err(py))
42+
.unwrap();
43+
})
44+
}

src/lib.rs

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,62 @@
1-
pub mod test;
1+
#![feature(doc_cfg)]
2+
#![warn(missing_docs)]
3+
4+
//! Rust Bindings to the Python Asyncio Event Loop
5+
//!
6+
//! # Motivation
7+
//!
8+
//! This crate aims to provide a convenient interface to manage the interop between Python and
9+
//! Rust's async/await models. It supports conversions between Rust and Python futures and manages
10+
//! the event loops for both languages. Python's threading model and GIL can make this interop a bit
11+
//! trickier than one might expect, so there are a few caveats that users should be aware of.
12+
//!
13+
//! ## Why Two Event Loops
14+
//!
15+
//! Currently, we don't have a way to run Rust futures directly on Python's event loop. Likewise,
16+
//! Python's coroutines cannot be directly spawned on a Rust event loop. The two coroutine models
17+
//! require some additional assistance from their event loops, so in all likelihood they will need
18+
//! a new _unique_ event loop that addresses the needs of both languages if the coroutines are to
19+
//! be run on the same event loop.
20+
//!
21+
//! It's not immediately clear that this would provide worthwhile performance wins either, so in the
22+
//! interest of keeping things simple, this crate runs both event loops independently and handles
23+
//! the communication between them.
24+
//!
25+
//! ## Python's Event Loop
26+
//!
27+
//! Python is very picky about the threads used by the `asyncio` executor. In particular, it needs
28+
//! to have control over the main thread in order to handle signals like CTRL-C correctly. This
29+
//! means that Cargo's default test harness will no longer work since it doesn't provide a method of
30+
//! overriding the main function to add our event loop initialization and finalization.
31+
//!
32+
//! ## Rust's Event Loop
33+
//!
34+
//! Currently only the Tokio runtime is supported by this crate. Tokio makes it easy to construct
35+
//! and maintain a runtime that runs on background threads only and it provides a single threaded
36+
//! scheduler to make it easier to work around Python's GIL.
37+
//!
38+
//! > _In the future, more runtimes may be supported for Rust._
39+
//!
40+
//! ## Features
41+
//!
42+
//! Items marked with
43+
//! <span
44+
//! class="module-item stab portability"
45+
//! style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"
46+
//! ><code>testing</code></span>
47+
//! are only available when the `testing` Cargo feature is enabled:
48+
//!
49+
//! ```toml
50+
//! [dependencies.pyo3-asyncio]
51+
//! version = "0.1.0"
52+
//! features = ["testing"]
53+
//! ```
54+
55+
/// Utilities for writing PyO3 Asyncio tests
56+
#[cfg(feature = "testing")]
57+
#[doc(cfg(testing))]
58+
#[doc(inline)]
59+
pub mod testing;
260

361
use std::{future::Future, thread};
462

@@ -14,6 +72,25 @@ use tokio::{
1472
task::JoinHandle,
1573
};
1674

75+
/// Test README
76+
#[doc(hidden)]
77+
pub mod doc_test {
78+
macro_rules! doc_comment {
79+
($x:expr, $module:item) => {
80+
#[doc = $x]
81+
$module
82+
};
83+
}
84+
85+
macro_rules! doctest {
86+
($x:expr, $y:ident) => {
87+
doc_comment!(include_str!($x), mod $y {});
88+
};
89+
}
90+
91+
doctest!("../README.md", readme_md);
92+
}
93+
1794
lazy_static! {
1895
static ref CURRENT_THREAD_RUNTIME: Runtime = {
1996
Builder::new_current_thread()
@@ -29,10 +106,49 @@ static CALL_SOON: OnceCell<PyObject> = OnceCell::new();
29106
static CREATE_TASK: OnceCell<PyObject> = OnceCell::new();
30107
static CREATE_FUTURE: OnceCell<PyObject> = OnceCell::new();
31108

109+
/// Wraps the provided function with the initialization and finalization for PyO3 Asyncio
110+
///
111+
/// This function **_MUST_** be called from the main thread.
112+
///
113+
/// # Arguments
114+
/// * `py` - The current PyO3 GIL guard
115+
/// * `f` - The function to call in between intialization and finalization
116+
///
117+
/// # Examples
118+
///
119+
/// ```no_run
120+
/// use pyo3::prelude::*;
121+
///
122+
/// fn main() {
123+
/// Python::with_gil(|py| {
124+
/// pyo3_asyncio::with_runtime(py, || {
125+
/// println!("PyO3 Asyncio Initialized!");
126+
/// Ok(())
127+
/// })
128+
/// .map_err(|e| {
129+
/// e.print_and_set_sys_last_vars(py);
130+
/// })
131+
/// .unwrap();
132+
/// })
133+
/// }
134+
/// ```
135+
pub fn with_runtime<F>(py: Python, f: F) -> PyResult<()>
136+
where
137+
F: FnOnce() -> PyResult<()>,
138+
{
139+
try_init(py)?;
140+
141+
(f)()?;
142+
143+
try_close(py)?;
144+
145+
Ok(())
146+
}
147+
32148
/// Attempt to initialize the Python and Rust event loops
33149
///
34150
/// Must be called at the start of your program
35-
pub fn try_init(py: Python) -> PyResult<()> {
151+
fn try_init(py: Python) -> PyResult<()> {
36152
let asyncio = py.import("asyncio")?;
37153
let event_loop = asyncio.call_method0("get_event_loop")?;
38154
let executor = py
@@ -59,13 +175,12 @@ pub fn try_init(py: Python) -> PyResult<()> {
59175
Ok(())
60176
}
61177

178+
/// Get a reference to the Python Event Loop from Rust
62179
pub fn get_event_loop() -> PyObject {
63180
EVENT_LOOP.get().unwrap().clone()
64181
}
65182

66183
/// Run the event loop forever
67-
///
68-
/// This function must be called on the main thread
69184
pub fn run_forever(py: Python) -> PyResult<()> {
70185
if let Err(e) = EVENT_LOOP.get().unwrap().call_method0(py, "run_forever") {
71186
if e.is_instance::<PyKeyboardInterrupt>(py) {
@@ -79,8 +194,6 @@ pub fn run_forever(py: Python) -> PyResult<()> {
79194
}
80195

81196
/// Run the event loop until the given Future completes
82-
///
83-
/// This function must be called on the main thread
84197
pub fn run_until_complete<F>(py: Python, fut: F) -> PyResult<()>
85198
where
86199
F: Future<Output = PyResult<()>> + Send + 'static,
@@ -98,7 +211,8 @@ where
98211
Ok(())
99212
}
100213

101-
pub fn close(py: Python) -> PyResult<()> {
214+
/// Shutdown the event loops and perform any necessary cleanup
215+
fn try_close(py: Python) -> PyResult<()> {
102216
// Shutdown the executor and wait until all threads are cleaned up
103217
EXECUTOR.get().unwrap().call_method0(py, "shutdown")?;
104218

0 commit comments

Comments
 (0)