Skip to content
Ashwin Naren edited this page Feb 21, 2025 · 11 revisions

RustPython has special attributes to support easy python object building.

pyfunction, pymethod

These attributes are very common for every code chunk. They eventually turn into builtin_function_or_method as Fn(&VirtualMachine, FuncArgs) -> PyResult.

The common form looks like this:

    #[pyfunction]
    pub fn ascii(obj: PyObjectRef, vm: &VirtualMachine) -> PyResult<String> {
        let repr = vm.to_repr(&obj)?;
        let ascii = to_ascii(repr.as_str());
        Ok(ascii)
    }

The vm paramter is just suffix. We add it as the last parameter unless we don't use vm at all - very rare case. It takes an object obj as PyObjectRef, which is a general python object. It returns PyResult<String>, which will turn into PyResult<PyObjectRef> the same representation of PyResult.

Every return value must be convertible to PyResult. This is defined as IntoPyResult trait. So any return value of them must implement IntoPyResult. It will be PyResult<PyObjectRef>, PyObjectRef and any PyResult<T> when T implements IntoPyObject. Practically we can list them like:

  • Any T when PyResult<T> is possible
  • PyObjectRef
  • PyResult<()> and () as None
  • PyRef<T: PyValue> like PyIntRef, PyStrRef
  • T: PyValue like PyInt, PyStr
  • Numbers like usize or f64 for PyInt and PyFloat
  • String for PyStr
  • And more types implementing IntoPyObject.

Just like the return type, parameters are also described in similar way.

fn math_comb(n: PyIntRef, k: PyIntRef, vm: &VirtualMachine) -> PyResult<BigInt> {
    ...
}

For this function, n and k are defined as PyIntRef. This conversion is supported by TryFromObject trait. When any parameter type is T: TryFromObject instead of PyObjectRef, it will call the conversion function and return TypeError during the conversion. Practically, they are PyRef<T: PyValue> and a few more types like numbers and strings.

Note that all those conversions are done by runtime, which are identical as manual conversions when we call the conversion function manually from PyObjectRef.

#[pyfunction] is used to create a free function. #[pymethod] is used to create a method which can be bound. So here can be usually another prefix parameter self for PyRef<T: PyValue> and &self for T: PyValue.

#[pyclass(...)]
impl PyStr {
    ...

    #[pymethod(magic)]
    fn contains(&self, needle: PyStrRef) -> bool {
        self.value.contains(needle.as_str())
    }

    ...
}

These parameters are mostly treated just same as other parameter, especially when Self is PyRef<T>. &self for T is sometimes a bit different. The actual object for self is always PyRef<Self>, but the features are limited when it is represented as just &Self. Then there will be a special pattern like zelf: PyRef<Self>.

...

    #[pymethod(magic)]
    fn mul(zelf: PyRef<Self>, value: isize, vm: &VirtualMachine) -> PyRef<Self> {
        ...
    }

...

This pattern is just same as self for PyRef<T>. So nothing special. Just a different notation.

pyclass

pyclass when applied to a struct and its' associated impl is used to create a python type in Rust side. For now, they essentially consists of 2 steps:

  1. A data type with #[pyclass] attribute.
  2. pyclass on an impl block gathering various stuff related to python attributes.

DirEntry type in vm/src/stdlib/os.rs is a nice and compact example of small class.

    #[pyclass(name)]
    #[derive(Debug)]
    struct DirEntry {
        entry: fs::DirEntry,
    }

    #[pyclass]
    impl DirEntry {
        ...
    }

#[pyclass] on struct and data type

The data type is rust-side payload for the type. For simple usage, check for PyInt and PyStr. There are even empty types like PyNone. For complex types, PyDict will be interesting. pyclass macro helps to implement a few traits with given attrs.

  • module: false for builtins and a string for others. This field will be automatically filled when defined in #[pymodule] macro.
  • name: The class name.
  • base: A rust type name of base class for inheritance. Mostly empty. See PyBool for an example.
  • metaclass: A rust type name of metaclass when required. Mostly empty.

#[pyclass] on impl and python attributes

This part is the most interesting part. Basically #[pyclass] collects python attributes. A class can contains #[pymethod], #[pyclassmethod], #[pygetset] and #[pyslot]. These attributes will be covered in next sections.

One of important feature of #[pyclass] is filling slots of PyType. Typically - but not necessarily - a group of slots are defiend as a trait in RustPython. with(...) will collect python attributes from the traits. Additionally flags set the type flags. See PyStr and Hashable for the slot traits. See also PyFunction and HAS_DICT for flags.

pymethod, pyclassmethod

This article already covered pymethod with pyfunction.

pygetset

Sometimes it is required to expose attributes of a class #[pygetset] allows this to happen

#[pygetset]
fn co_posonlyargcount(&self) -> usize {
    self.code.posonlyarg_count as usize
}

If it is desirable to make the variable editable, consider returning and AtomicCell, RwLock, or Mutex.

If this is not feasible, or if it is desired to run some checks when writing to the attribute, using #[pygetset] coupled with #[pygetset(setter)] allows for separate get and set functions.

#[pygetset]
fn name(&self) -> PyStrRef {
    self.inner.name()
}

#[pygetset(setter)]
fn set_name(&self, name: PyStrRef) {
    self.inner.set_name(name)
}

pyslot

slots provide fast path for a few frequently-accessed type attributes. #[pyslot] connects the annotated function to each slot. The function name must be same as slot name or tp_ prefixed slot name.

In RustPython, most of them are conventionally implemented through a trait.

  • Hashable: __hash__
  • Callable: __call__
  • Comparable: __eq__, __ne__, __ge__, __ge__, __le__, __lt__
  • Buffer: tp_as_buffer

...

Note: For now, non-zero-sized payload(#[pyclass]) without tp_new slot will make payload error after creating the instance.

pymodule