Nine Rules for Elegant Rust Library APIs

Practical Lessons from Porting Bed-Reader, a Bioinformatics Library, from Python to Rust

Carl M. Kadie
Towards Data Science

--

Photo by Kai Dahms on Unsplash

I love creating software libraries. Two months ago, I started porting one of our Python packages into a Rust crate. This new Rust crate matches the Python package’s ease of use and expressiveness. Along the way, I learned nine rules that can help you create beautiful libraries in Rust. The rules are:

  1. Create examples that don’t embarrass you.
  2. Accept all kinds of strings, paths, vectors, arrays, and iterables.
  3. Know your users’ needs, ideally by eating your own dogfood.
  4. Use builders, because you can’t use keyword parameters.
  5. Write good documentation to keep your design honest.
  6. Accept all types of types.
  7. Write API tests.
  8. Define and return nice errors.
  9. Use Clippy.

In this context, a library is a Rust crate for other programs to use. A library’s API (application programming interface) is the set of public functions and objects that programs can call. We want to design an elegant API, not just a functional API. A functional API merely lets the user do everything they need to do. An elegant API lets them do what they need to do in a simple and sensible way.

To be concrete, here is our Python-facing API and the new Rust-facing API on the same task.

Task: List the first 5 individual ids, the first 5 SNP ids, and every unique chromosome. Then, read every value in chromosome 5.

Python API:

>>> with open_bed(file_name2) as bed3:
... print(bed3.iid[:5])
... print(bed3.sid[:5])
... print(np.unique(bed3.chromosome))
... val3 = bed3.read(index=np.s_[:,bed3.chromosome=='5'])
... print(val3.shape)
['iid_0' 'iid_1' 'iid_2' 'iid_3' 'iid_4']
['sid_0' 'sid_1' 'sid_2' 'sid_3' 'sid_4']
['1' '10' '11' '12' '13' '14' '15' '16' '17' '18' '19' '2' '20' '21' '22' '3' '4' '5' '6' '7' '8' '9']
(100, 6)

Rust API:

let mut bed = Bed::new(file_name)?;
println!("{:?}", bed.iid()?.slice(s![..5]));
println!("{:?}", bed.sid()?.slice(s![..5]));
println!("{:?}", bed.chromosome()?.iter().collect::<HashSet<_>>());
let val = ReadOptions::builder()
.sid_index(bed.chromosome()?.map(|elem| elem == "5"))
.f64()
.read(&mut bed)?;
// Outputs ndarray: ["iid_0", "iid_1", "iid_2", "iid_3", "iid_4"]
// Outputs ndarray: ["sid_0", "sid_1", "sid_2", "sid_3", "sid_4"]
// Outputs: {"12", "10", "4", "8", "19", "21", "9", "15", "6", "16", "13", "7", "17", "18", "1", "22", "11", "2", "20", "3", "5", "14"}
assert!(val.dim() == (100, 6));

Are these elegant? Elegance is in the eye of the beholder, but as a user of the Python API, I find it elegant. With respect to the Rust API, I am happy that it follows the Python design closely and believe it is elegant.

Inspiration: Two previous articles inspired and informed these efforts. First, in 2016, Pascal Hertleif wrote Elegant Library APIs in Rust. Later, Brian Anderson created and now maintains Rust API Guidelines. In comparison, this article is more general, more specific, and much less comprehensive. It discusses general API design principles that apply to all languages, not just Rust. It highlights the specific techniques and tools I found most useful when porting Bed-Reader. It ignores API design questions that I did not face (for example, designing macros).

Background: Bed-Reader is a library for reading and writing PLINK Bed Files, a binary format used in bioinformatics to store genotype (DNA) data. Files in Bed format can be as large as a terabyte. Bed-Reader gives users fast, random access to large subsets of the data. It returns a 2-D array in the user’s choice of int8, float32, or float64. Bed-Reader also gives users access to 12 pieces of metadata, six associated with individuals and six associated with SNPs (roughly speaking, DNA locations). Importantly, the genotype data is often 100,000 times larger than the metadata.

PLINK stores genotype data and metadata

Creating an elegant Rust library API requires many design decisions. Based on my experience with Bed-Reader, here are the decisions I recommend. To avoid wishy-washiness, I’ll express these recommendations as rules. The rules alternate between the general and the specific.

Rule 1: Create examples that don’t embarrass you

You should create many examples that use your library’s API. You should keep working on your library’s API until you are proud of these examples.

To illustrate this, let’s look at the three examples from Bed-Reader’s README.md file. For each task, we’ll look at solutions using

  • the Python API
  • a merely functional Rust API
  • the new, more elegant, Rust API

Task: Read all genomic data from a .bed file.

Python API:

>>> import numpy as np
>>> from bed_reader import open_bed, sample_file
>>>
>>> file_name = sample_file("small.bed")
>>> bed = open_bed(file_name)
>>> val = bed.read()
>>> print(val)
[[ 1. 0. nan 0.]
[ 2. 0. nan 2.]
[ 0. 1. 2. 0.]]
>>> del bed

Merely functional Rust API:

use crate::read;let file_name = "bed_reader/tests/data/small.bed";
let val = read(file_name, true, true, f32::NAN)?;
println!("{val:?}");
// [[1.0, 0.0, NaN, 0.0],
// [2.0, 0.0, NaN, 2.0],
// [0.0, 1.0, 2.0, 0.0]],...

The functional Rust API does great! On the one hand, the true, true inputs are a bit confusing. On the other, it is one line shorter than Python. I could almost be proud of this solution.

New, more elegant Rust API:

use ndarray as nd;
use bed_reader::{Bed, ReadOptions, assert_eq_nan, sample_bed_file};

let file_name = sample_bed_file("small.bed")?;
let mut bed = Bed::new(file_name)?;
let val = ReadOptions::builder().f64().read(&mut bed)?;

assert_eq_nan(
&val,
&nd::array![
[1.0, 0.0, f64::NAN, 0.0],
[2.0, 0.0, f64::NAN, 2.0],
[0.0, 1.0, 2.0, 0.0]
],
);

Compared to the merely functional API, the new API replaces the confusing true, true inputs with a builder pattern. (See Rule 4 for details.) On the other hand, it requires an additional line of code. So, it is not necessarily better.

Let’s look at the next task.

Task: Read every second individual and SNPs (DNA location) from 20 to 30.

Python API:

>>> file_name2 = sample_file("some_missing.bed")
>>> bed2 = open_bed(file_name2)
>>> val2 = bed2.read(index=np.s_[::2,20:30])
>>> print(val2.shape)
(50, 10)
>>> del bed2

Notice the use of [::2,20:30], an instance of Python NumPy’s fancy indexing. Such indexing is familiar to Python scientific programmers and perfect for specifying which slices of genomic data to read.

Merely functional Rust API:

use crate::{counts, read_with_indexes};let file_name = "bed_reader/tests/data/some_missing.bed";let (iid_count, _) = counts(file_name)?;
let iid_index = (0..iid_count).step_by(2).collect::<Vec<_>>();
let sid_index = (20..30).collect::<Vec<_>>();
let val = read_with_indexes(
file_name,
iid_index.as_slice(),
sid_index.as_slice(),
true,
true,
f32::NAN,
)?;
println!("{:?}", val.shape());
// [50, 10]

Python’s two core lines of code in become four statements (and 12 lines) in Rust. As before, we have the unclear true, true. In addition, specifying the indexes of interest has become more difficult. Also, the user must somehow understand that indexes must be defined as vectors (so that they are owned somewhere) but then passed via as_slice's (what the function expects).

This example embarrassed me, especially compared to the Python. It motivated me improve the Rust API like so:

New, more elegant Rust API:

use ndarray::s;

let file_name = sample_bed_file("some_missing.bed")?;
let mut bed = Bed::new(file_name)?;
let val = ReadOptions::builder()
.iid_index(s![..;2])
.sid_index(20..30)
.f64()
.read(&mut bed)?;

assert!(val.dim() == (50, 10));

I like this much better. Like Python, it supports the fancy indexing. Sadly, Rust’s standard ranges — for example,20..30 — don’t support steps, so the API must also support the ndarray slicess![..;2]. (We see in Rule 6 how Rust can accept inputs of such different types.)

Let’s turn now to a final example that uses metadata.

Task: List the first 5 individual ids, the first 5 SNP ids, and every unique chromosome. Then, read every value in chromosome 5.

We saw the Python solution in the introduction. How bad is the merely functional Rust solution? Sadly, the merely functional API doesn’t know about metadata. Happily, the metadata files are just six-column text files, so users can just read them themselves, right?

Merely functional Rust API:

Yikes, very embarrassing. Here is the better Rust solution (that we also saw in the introduction).

New, “Elegant” Rust API:

use std::collections::HashSet;

let mut bed = Bed::new(file_name)?;
println!("{:?}", bed.iid()?.slice(s![..5]));
println!("{:?}", bed.sid()?.slice(s![..5]));
println!("{:?}", bed.chromosome()?.iter().collect::<HashSet<_>>());
let val = ReadOptions::builder()
.sid_index(bed.chromosome()?.map(|elem| elem == "5"))
.f64()
.read(&mut bed)?;

// Outputs ndarray: ["iid_0", "iid_1", "iid_2", "iid_3", "iid_4"]
// Outputs ndarray: ["sid_0", "sid_1", "sid_2", "sid_3", "sid_4"]
// Outputs: {"12", "10", "4", "8", "19", "21", "9", "15", "6", "16", "13", "7", "17", "18", "1", "22", "11", "2", "20", "3", "5", "14"}
assert!(val.dim() == (100, 6));

Rust almost matches the Python. Rust is, however, a bit more verbose. For example, here is the code to borrow the 1-D NumPy/ndarray of individual ids and print the first 5 items:

print(bed.iid[:5]) # Python
println!("{:?}", bed.iid()?.slice(s![..5])); // Rust

These are semantically almost identical, but Python supports a more concise array-related syntax.

Here is the code to print the unique values in an array:

print(np.unique(bed.chromosome)) # Python
println!("{:?}", bed.chromosome()?.iter().collect::<HashSet<_>>()); // Rust

Python does a little better because its mature NumPy library includes np.unique while Rust’s ndarray does not.

Finally, to find SNPs in chromosome “5”, the code is:

np.s_[:,bed.chromosome=='5'] # Python
bed.chromosome()?.map(|elem| elem == "5") // Rust

I think Rust beats Python here because it uses only common language features and avoids the less common np.s_.

The main point of Rule 1 isn’t to aim for perfection. Rather, you should aim for meaningful and interesting examples. Then, work on the API until the examples can be solved without embarrassment and perhaps with pride.

Rule 2: Accept all kinds of strings, paths, vectors, arrays, and iterables

Your user may wish to use a String or a &String or a &str. When giving your API a path, your user may want to give you a PathBuf, a &PathBuf, a &Path, or any kind of string. Moreover, your user will sometimes wish to give you an owned value and sometimes a borrowed value. You can support all these possibilities with generics. What about accepting all types of vectors, arrays, and iterables? That, too, can be supported with generics.

Update: I’ve created a macro called anyinput to makes accepting all kinds of inputs easier. The anyinput documentation has details. If you use anyinput, you can skip forward to the next rule.

Specifically, for paths, define your path as generic type P where P is AsRef<Path>. For example, here is the definition for Bed::new, a function for opening a .bed file:

pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, BedErrorPlus> {
Bed::builder(path).build()
}

And here is every input typeBed::new accepts:

let path: &str = "bed_reader/tests/data/small.bed";
let _ = Bed::new(&path)?; // borrow a &str
let _ = Bed::new(path)?; // move a &str
let path: String = "bed_reader/tests/data/small.bed".to_string();
let _ = Bed::new(&path)?; // borrow a String
let path2: &String = &path;
let _ = Bed::new(&path2)?; // borrow a &String
let _ = Bed::new(path2)?; // move a &String
let _ = Bed::new(path)?; // move a String
let path: &Path = Path::new("bed_reader/tests/data/small.bed");
let _ = Bed::new(&path)?; // borrow a Path
let _ = Bed::new(path)?; // move a Path
let path: PathBuf = PathBuf::from("bed_reader/tests/data/small.bed");
let _ = Bed::new(&path)?; // borrow a PathBuf
let path2: &PathBuf = &path;
let _ = Bed::new(&path2)?; // borrow a &PathBuf
let _ = Bed::new(path2)?; // move a &PathBuf
let _ = Bed::new(path)?; // move a PathBuf

In your function, the .as_ref() method will efficiently turn this input into a &Path. The expression .as_ref().to_owned() will give you an owned PathBuf.

For string-like things, define your function with S: AsRef<Str>. The .as_ref() method will efficiently turn the input into a &str and .as_ref().to_owned() will give you an ownedString.

For vectors, arrays, and iterables, sometimes we just need something iterable. If so, define your function with I: IntoIterator<Item = T>, where T is the type of the item, perhaps also generic. For example, MetadataBuilder::iid is a function that adds individual ids to an in-memory metadata builder. (We’ll see more about builders in Rule 4.)

pub fn iid<I: IntoIterator<Item = T>, T: AsRef<str>>(&mut self, iid: I) -> &mut Self {
self.iid = Some(Some(Rc::new(
iid.into_iter().map(|s| s.as_ref().to_owned()).collect(),
)));
self
}

In a moment, we’ll go over what this function can accept and at what cost. First, here are some of the things MetadataBuilder::iid accepts:

let list: [&str; 3] = ["i1", "i2", "i3"];
let _ = Metadata::builder().iid(&list).build()?; // borrow fixed-size array
let _ = Metadata::builder().iid(list).build()?; // move fixed-size array
let list: [String; 3] = ["i1".to_string(), "i2".to_string(), "i3".to_string()];
let _ = Metadata::builder().iid(&list).build()?; // borrow fixed-size array of String
let _ = Metadata::builder().iid(list).build()?; // move fixed-size array of String
let list: Vec<&str> = vec!["i1", "i2", "i3"];
let _ = Metadata::builder().iid(&list).build()?; // borrow Vec<&str>
let list2 = &list[..]; // borrowed slice
let _ = Metadata::builder().iid(list2).build()?; // borrow slice
let _ = Metadata::builder().iid(list).build()?; // move Vec<&str>
let list = nd::array!["i1", "i2", "i3"];
let view = list.view();
let _ = Metadata::builder().iid(&view).build()?; // borrow nd view
let _ = Metadata::builder().iid(view).build()?; // move nd view
let _ = Metadata::builder().iid(&list).build()?; // borrow ndarray
let _ = Metadata::builder().iid(list).build()?; // move ndarray
let list: std::str::Split<&str> = "i1,i2,i3".split(",");
let _ = Metadata::builder().iid(list).build()?; // move iterator

What of vectors, arrays, and ndarrays for which you need random access? Then you have a choice. You can treat everything as a Rust slice. For example:

pub fn any_slice<A: AsRef<[T]>, T>(slice: A) -> Result<(), anyhow::Error> {
let slice = slice.as_ref();
println!("slice len: {}", slice.len());
Ok(())
}

This directly accepts every array-like thing in Rust, except ndarrays and views. They are accepted indirectly by converting to a slice, for example, nd_array1.as_slice().expect(“ndarray as_slice”).

Alternatively, you can treat everything as an ndarray view. For example:

pub fn any_array_view<'a, T: 'a, A: Into<nd::ArrayView1<'a, T>>>(
array: A,
) -> Result<(), anyhow::Error> {
let array_view = array.into();
println!("array_view len: {}", array_view.len());
Ok(())
}

This directly accepts borrows of every array-like thing, but doesn’t accept moves. For example, this works:

let array: [&str; 3] = ["i1", "i2", "i3"];
let _ = any_array_view(&array)?; // borrow fixed-size array (can't move)

If your API only needs to iterate and borrow the user’s data, IntoIterator is great. However, if you need your own copy of the data, you must make a choice.

  • Use IntoIterator and reallocate. That’s what the iid function above does. As mentioned previously, the subexpressions.as_ref().to_owned() turns any string-like thing into an ownedString. The expression iid.into_iter()....collect(), then converts any iterable thing into the desired collection type (in this case, an nd::array1<String>). For Bed-Reader, I know these extra allocations are unimportant because the individual id data is often 1,000,000 times smaller than the main genomic data.
  • Alternatively, you can require the user to give you an owned collection of one specific type and take ownership. For example, the function above could have required an nd::array1<String>. This is more efficient, but less flexible.

Performance Tip: In the Rust User Forum, Kevin Reid points out that it is good to have this generic function be small and call a non-generic function that does most of the work. This minimizes the code size and compilation time.

Rule 3: Know your users’ needs, ideally by eating your own dogfood

Our genomic projects have been reading PLINK Bed files for over 10 years. Two years ago, a user requested we put the .bed reading code into its own Python package. We based the package’s Python API on our experience. For example, the API only reads metadata files when and if necessary. Moreover, it keeps any metadata that it reads in memory. This allows reads of the large genomic data to start almost instantly which we know is important for, for example, cluster runs.

When we created the Python API, we rewrote our other tools to use it, refining the Python API as we went. When we created the Rust API, we rewrote the Python API to use it, refining the Rust API as we went. This is called “eating your own dogfood”. As we developed the Python API, we also solicited feedback from the requesting user. We treated their willingness to give feedback as a favor to us.

I found it very gratifying to see both the internal and external user code get simpler as the Bed-Reader API improved.

Confession: We had no pressing need for a Rust Bed-Reader API. I created it because I wanted to learn about creating a medium-size API in Rust. I based the design on the Python API and Rust principles, not on Rust user demand.

Rule 4: Use builders, because you can’t use keyword parameters.

Here is an example of reading genotype data in Python using keyword parameters:

with open_bed(file_name, count_A1=False) as bed:
val = bed.read(
index=np.s_[:, :3],
dtype="int8",
order="C",
num_threads=1)
print(val.shape)

Here is the same example in Rust using a builder.

let mut bed = Bed::new(filename)?;
let val = ReadOptions::builder()
.sid_index(..3)
.i8()
.c()
.num_threads(1)
.read(&mut bed)?;
println!("{:?}", val.dim());

They are similar. Let’s look at the corresponding parameters and compare how Bed-Reader’s Python and Rust approaches compare in detail:

Python Rust
 index iid_index
 sid_index
 i8
 dtype f32
 f64
 f
 order c
 is_f
 num_threads num_threads
 count_a1
 count_A1 count_a2
 is_a1_counted
  • Python’s 2-D indexing (index=np.s_[:, :3]) becomes two 1-D parameters of which the user will often need only one (.sid_index(..3))— Rust, and its users, expect 1-D parameters. Also, users often need to index only one dimension.
  • Python’s string specifying one of three choices (dtype='int8') related to types becomes three parameters. Rust users will use one (.i8()) or none (when the type can be inferred).
  • Python’s string specifying one of two choices (order='c') becomes three parameters. Rust users can say .c() (which is shorter) or is_f(false)(which can be set with a variable).
  • A number (num_threads=1) stays a number (.num_threads(1)).
  • A Python Boolean (count_A1=False) becomes three parameters. Rust users can say .count_a2() or .count_a1(false).

Bed-Reader uses the popular and powerful derive_builder crate to build builders. Bed-Reader’s four builders are:

See links for documentation and source.

Tip: In the Rust User Forum, H2CO3 highlights the “Struct Update Syntax” (also known as Functional Record Update (FRU)) as an alternative to Builders. Using defaults, it may be possible for users to create an “options struct” by only specifying non-default field values.

Rule 5: Write good documentation to keep your design honest.

At one point, some parts of the Bed-Reader Rust API accepted a restricted in-memory metadata object and some parts did not. I tried to explain this inconsistency in the documentation, but grew frustrated. I finally decided that I’d rather fix the confusing design than explain it. Writing the documentation motivated me to improve the design.

Some tips for writing good documentation:

  • Use Rust’s excellent rustdoc system.
  • Document every public function, struct, enum, etc. Start your lib.rs with #![warn(missing_docs)] and you’ll be reminded to document everything.
  • Include examples in almost every bit of documentation. If you can’t create simple examples, your API design needs more work. Test these examples with Rust’s standard cargo test command.
  • Don’t panic. Your examples should return errors (via ?), not panic (via .unwrap()). The rustdoc book tells how to set this up for tests. Also see Rule 8.
  • Write a good README.md for your project. It can also serve double duty as the introduction to your API documentation. Include #![doc = include_str!("../README.md")] in your lib.rs. This inclusion provides an added benefit, namely, any examples in your README.md will be run as tests.
  • Read, re-read, and edit your documentation until it does a good job of explaining your API to your users. (The command for generating documentation and popping it up in a browser is cargo doc --no-deps --open.)

You can see my attempt to write good documentation here: bed_reader — Rust (docs.rs). The source code (available as a link from the documentation) shows the markdown formatting for, for example, tables summarizing features.

Rule 6: Accept all types of types.

When users of Bed-Reader specify individuals or SNPs to read, they can give:

  • an index number (for example, 1)
  • an array, vector, Rust slice, ndarray::Array, ndarray::View of index numbers ([0, 10, -2])
  • a Rust range (3.. and 10..=19) or , an ndarray slice(s![-20..-10;-2])
  • an array, vector, Rust slice, ndarray::Array, ndarray::View of Booleans ([true, false true])

How is this possible given Rust’s strong typing? The key is an Enum and conversion functions. Specifically,

  • Define an Enum with all the types you which to store. The Bed-Reader Enum, named Index, is defined as:
#[derive(Debug, Clone)]
pub enum Index {
#[allow(missing_docs)]
All,
#[allow(missing_docs)]
One(isize),
#[allow(missing_docs)]
Vec(Vec<isize>),
#[allow(missing_docs)]
NDArray(nd::Array1<isize>),
#[allow(missing_docs)]
VecBool(Vec<bool>),
#[allow(missing_docs)]
NDArrayBool(nd::Array1<bool>),
#[allow(missing_docs)]
NDSliceInfo(SliceInfo1),
#[allow(missing_docs)]
RangeAny(RangeAny),
}
  • Implement From functions to convert all types of interest into your Enum. The types you convert from cannot overlap. Here are four of Bed-Reader’s From implementations:
impl From<isize> for Index {
fn from(one: isize) -> Index {
Index::One(one)
}
}
impl From<&isize> for Index {
fn from(one: &isize) -> Index {
Index::One(one.to_owned())
}
}
impl<const N: usize> From<[bool; N]> for Index {
fn from(array: [bool; N]) -> Index {
Index::VecBool(array.to_vec())
}
}
impl<const N: usize> From<&[bool; N]> for Index {
fn from(array: &[bool; N]) -> Index {
Index::VecBool(array.to_vec())
}
}
  • Implement functions on your Enum to do whatever you need. For example, given the count of individuals, I need to know the length of an Index. I define .len(count). Clippy (see Rule 10) reminds me to also define is_empty.
  • Finally, if your Enum is used in a regular function, define it with impl Into<YourEnum> and then use .into() in your function (see below). If your function is part of a builder and you use derive_builder, you can mark your struct’s field with #[builder(setter(into))] (details here). For example:
// Tells the length of an index if the count of items is exactly 100
fn len100(index: impl Into<Index>) -> Result<usize, BedErrorPlus> {
let index = index.into();
let len = index.len(100)?;
Ok(len)
}
let _ = len100(3..)?;
let _ = ReadOptions::builder().iid_index(3..).i8().build()?;

(Thanks to Kevin Read for the tip on “impl Into”.)

Rule 7: Write API tests.

Write API tests, not just unit tests. This entails creating a tests/test_api.rs file, outside of your src folder. This lets you test the public methods of your API like a user. For example, Bed-Reader’s test_api.rs says use bed_reader::Bed; rather than use crate::Bed;.

Rule 8: Define and return nice errors.

Define and return nice errors, for example, via the thiserror crate. In Bed-Reader’s lib.rs, we create an Enum called BedError for all the errors the library defines. We then create an Enum called BedErrorPlus to cover BedError and errors from other crates.

Update 11/6/2023: The original version of the code returned BedErrorPlus, a large-in-terms-of-memory enum. Now, it returns Box<BedErrorPlus> which is 8 bytes. This was a suggestion from Clippy (see Rule 9) to save memory.

Rule 9: Use Clippy

Apply strict linting with rust-clippy.

So, there you have it: nine rules for Rust library APIs. When you have created your elegant Rust crate and are ready to publish, the command is cargo publish.

My experience with Bed-Reader shows that we can have the performance of Rust and the elegance of Python. This does come at a cost, namely, creating a nice API in Rust is currently not as easy as in Python. Follow these nine rules to make the process of creating a great Rust library API a bit easier.

The Seattle Rust Meetup hosted a presentation on this article with the video on YouTube. Please follow me on Medium. I write on scientific programming in Rust and Python, machine learning, and statistics. I tend to write about one article per month.

--

--

Ph.D. in CS and Machine Learning. Retired Microsoft & Microsoft Research. Volunteer, open-source projects related to ML, Genomics, Rust, and Python.