Skip to content

Commit 788f06c

Browse files
authored
Fix issue saving S4 objects as RDS (#77)
* more tests and fixing bugs with null/None values when serializing to RDS.
1 parent 8e014c6 commit 788f06c

38 files changed

Lines changed: 2133 additions & 220 deletions

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# Changelog
22

3-
## Version 0.10.0
3+
## Version 0.10.0 - 0.10.1
44

55
- Added methods to write to RDS/RData files.
66
- Supports atomic types, generic dictionaries/lists, and **BiocPy objects**.
7+
- Read `symbols` registered in RDS objects.
8+
- Fixed an issue with S4 classes not properly saved as RDS files.
79

810
## Version 0.9.0 - 0.9.1
911

README.md

Lines changed: 91 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,101 +4,141 @@
44

55
# rds2py
66

7-
Parse and save Python objects as **RDS or RData** files. `rds2py` supports various base classes from R, and Bioconductor's `SummarizedExperiment` and `SingleCellExperiment` S4 classes. **_For more details, check out [rds2cpp library](https://github.com/LTLA/rds2cpp)._**
7+
`rds2py` allows you to read and write R's native **RDS** and **RData** files directly in Python. Beyond standard R types, it provides integration with the [BiocPy](https://github.com/biocpy) ecosystem, allowing you to easily roundtrip complex S4 data structures like `SummarizedExperiment`, `SingleCellExperiment`, and `GenomicRanges`. **_For more details, check out [rds2cpp library](https://github.com/LTLA/rds2cpp)._**
88

99
## Installation
1010

1111
Package is published to [PyPI](https://pypi.org/project/rds2py/)
1212

1313
```shell
1414
pip install rds2py
15+
```
16+
17+
To enable automatic conversion to Bioconductor/BiocPy classes, make sure to install the optional dependencies:
1518

16-
# or install optional dependencies
19+
```shell
1720
pip install rds2py[optional]
1821
```
1922

20-
By default, the package does not install packages to convert python representations to BiocPy classes. Please consider installing all optional dependencies.
2123

22-
## Usage
24+
## Quickstart
2325

24-
> [!NOTE]
25-
>
26-
> If you do not have an RDS object handy, feel free to download one from [single-cell-test-files](https://github.com/jkanche/random-test-files/releases).
26+
### 1. Reading RDS and RData files
27+
28+
Reading an RDS or RData file is as simple as a single function call. `rds2py` automatically detects and maps known R/Bioconductor classes to their Python equivalents:
2729

2830
```python
2931
from rds2py import read_rds, read_rda
30-
r_obj = read_rds("path/to/file.rds") # or read_rda("path/to/file.rda")
32+
33+
# Read an RDS file (returns a Python/BiocPy object or dict)
34+
data = read_rds("path/to/file.rds")
35+
36+
# Read objects from an RData workspace file (returns a dictionary of objects)
37+
workspace = read_rda("path/to/workspace.rda")
3138
```
3239

33-
The returned `r_obj` either returns an appropriate Python class if a parser is already implemented or returns the dictionary containing the data from the RDS file.
40+
If `rds2py` encounters an S4 class or complex R structure it doesn't have a parser registered for, it falls back to returning a dictionary so you don't lose any data.
3441

35-
### Save RDS/RData files
42+
### 2. Saving to RDS and RData files
3643

37-
You can also construct RDS or RData files from Python objects. `rds2py` supports writing atomic types, generic dictionaries/lists, and **BiocPy objects**.
44+
You can serialize Python objects back to RDS or RData formats. This includes NumPy arrays, SciPy sparse matrices, standard dictionaries/lists, and BiocPy objects:
3845

3946
```python
40-
from rds2py import write_rds, write_rda
4147
import numpy as np
42-
43-
# Write atomic types
44-
write_rds(np.array([1, 2, 3], dtype=np.int32), "path/to/file.rds")
45-
46-
# Write complex objects
48+
from rds2py import write_rds, write_rda
4749
from genomicranges import GenomicRanges
4850
from iranges import IRanges
4951

50-
gr = GenomicRanges(
51-
seqnames=["chr1", "chr2"],
52-
ranges=IRanges(start=[1, 2], width=[10, 20]),
53-
strand=["+", "-"]
54-
)
55-
write_rds(gr, "path/to/granges.rds")
52+
# 1. Write an atomic NumPy array
53+
write_rds(np.array([10, 20, 30], dtype=np.int32), "array.rds")
54+
55+
# 2. Write a complex Bioconductor GenomicRanges object
56+
gr = GenomicRanges(seqnames=["chr1", "chr2"], ranges=IRanges(start=[1, 100], width=[10, 50]), strand=["+", "-"])
57+
write_rds(gr, "genomic_ranges.rds")
58+
59+
# 3. Write multiple Python objects into a single RData workspace
60+
objects = {"my_array": np.array([1.1, 2.2, 3.3]), "my_granges": gr}
61+
write_rda(objects, "workspace.rda")
5662
```
5763

58-
### Write-your-own-reader
64+
### 3. Custom Extensions
5965

60-
Reading RDS or RData files as dictionary representations allows users to write their own custom readers into appropriate Python representations.
66+
If you have custom S4 representations or class mapping needs, you can parse the raw RDS structure into Python dictionary representations using `parse_rds`/`parse_rda` and apply your custom deserializers:
6167

6268
```python
63-
from rds2py import parse_rds, parse_rda
69+
from rds2py import parse_rds
70+
from rds2py.read_granges import read_genomic_ranges
71+
72+
# 1. Parse into a raw dictionary representation of the RDS tree
73+
raw_dict = parse_rds("path/to/file.rds")
74+
print(raw_dict.keys()) # ['type', 'class_name', 'attributes', 'data', ...]
6475

65-
robject = parse_rds("path/to/file.rds") # or use parse_rda for rdata files
66-
print(robject)
76+
# 2. Build or invoke custom parser logic
77+
if raw_dict.get("class_name") == "GRanges":
78+
gr = read_genomic_ranges(raw_dict)
79+
print(gr)
6780
```
6881

69-
If you know this RDS file contains an `GenomicRanges` object, you can use the built-in reader or write your own reader to convert this dictionary.
82+
For writing custom objects, you can register your classes to `rds2py`'s serialization registry using the `save_rds` singledispatch generic:
7083

7184
```python
72-
from rds2py.read_granges import read_genomic_ranges
85+
from rds2py.generics import save_rds
86+
7387

74-
gr = read_genomic_ranges(robject)
75-
print(gr)
88+
class MyCustomClass:
89+
def __init__(self, value):
90+
self.value = value
91+
92+
93+
@save_rds.register(MyCustomClass)
94+
def _serialize_custom(x: MyCustomClass, path=None):
95+
# Construct the raw RDS dictionary representation expected by rds2cpp
96+
converted = {
97+
"type": "integer",
98+
"data": [x.value],
99+
"attributes": {"class": {"type": "string", "data": ["MyCustomRClass"]}},
100+
}
101+
102+
# Optionally save if path is provided, otherwise return representation
103+
if path is not None:
104+
from rds2py.lib_rds_parser import write_rds as write_rds_native
105+
106+
write_rds_native(converted, path)
107+
return converted
76108
```
77109

110+
78111
## Type Conversion Reference
79112

80-
| R Type | Python/NumPy Type |
81-
| ---------- | ------------------------------------ |
82-
| numeric | numpy.ndarray (float64) |
83-
| integer | numpy.ndarray (int32) |
84-
| character | list of str |
85-
| logical | numpy.ndarray (bool) |
86-
| factor | list |
87-
| data.frame | BiocFrame |
88-
| matrix | numpy.ndarray or scipy.sparse matrix |
89-
| dgCMatrix | scipy.sparse.csc_matrix |
90-
| dgRMatrix | scipy.sparse.csr_matrix |
91-
92-
and integration with BiocPy ecosystem for Bioconductor classes
93-
- SummarizedExperiment
94-
- RangedSummarizedExperiment
95-
- SingleCellExperiment
96-
- GenomicRanges
97-
- MultiAssayExperiment
113+
The table below describes how core R types are mapped to Python/NumPy/SciPy counterparts:
114+
115+
| R Type / Class | Python / NumPy / SciPy Counterpart |
116+
| :--- | :--- |
117+
| **numeric** | `numpy.ndarray` (`float64`) |
118+
| **integer** | `numpy.ndarray` (`int32`) |
119+
| **logical** | `numpy.ndarray` (`bool`) |
120+
| **character** | `list` of `str` |
121+
| **factor** | `list` / representation levels |
122+
| **matrix (dense)** | `numpy.ndarray` |
123+
| **dgCMatrix** (Column-sparse) | `scipy.sparse.csc_matrix` |
124+
| **dgRMatrix** (Row-sparse) | `scipy.sparse.csr_matrix` |
125+
| **data.frame** / **DFrame** | `biocframe.BiocFrame` |
126+
127+
### Supported Bioconductor Classes
128+
When `rds2py[optional]` is installed, the package fully translates R/S4 classes to their BiocPy equivalents:
129+
- **GenomicRanges** / **GRanges** <-> `genomicranges.GenomicRanges`
130+
- **GenomicRangesList** / **GRangesList** <-> `genomicranges.CompressedGenomicRangesList`
131+
- **SummarizedExperiment** <-> `summarizedexperiment.SummarizedExperiment`
132+
- **RangedSummarizedExperiment** <-> `summarizedexperiment.RangedSummarizedExperiment`
133+
- **SingleCellExperiment** <-> `singlecellexperiment.SingleCellExperiment`
134+
- **MultiAssayExperiment** <-> `multiassayexperiment.MultiAssayExperiment`
135+
136+
---
98137

99138
## Developer Notes
100139

101-
This project uses pybind11 to provide bindings to the rds2cpp library. Please make sure necessary C++ compiler is installed on your system.
140+
- `rds2py` uses `pybind11` to bind the core C++ `rds2cpp` library. Compiling from source requires a compatible C++ compiler.
141+
- Tests can be run via `tox` or directly using `pytest`.
102142

103143
<!-- pyscaffold-notes -->
104144

docs/custom_serialization.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Custom Serialization and Deserialization Guide
2+
3+
This guide shows you how to extend `rds2py` to support custom Python classes. By implementing custom readers and writers, you can serialize your custom Python representations directly into native R RDS/RData structures, and read them back seamlessly.
4+
5+
`rds2py` achieves this two-way extensibility using:
6+
1. Python's `functools.singledispatch` mechanism for writing/serialization (`save_rds`).
7+
2. A global class mapping registry for reading/deserialization (`read_rds`).
8+
9+
---
10+
11+
## 1. Custom Serialization (Python -> RDS)
12+
13+
To serialize a custom Python class, you register it with the `save_rds` generic dispatcher. Your custom function needs to take your object and convert it into a structured dictionary that matches R's internal representation format.
14+
15+
### The Structured RDS Representation Format
16+
R objects are represented in Python as nested dictionaries containing the following keys:
17+
- `"type"`: The R type descriptor (e.g., `"S4"`, `"vector"`, `"integer"`, `"double"`, `"string"`, `"logical"`, or `"null"`).
18+
- `"class_name"`: The target R class name (e.g., `"MyCustomRClass"`).
19+
- `"package_name"`: *(Optional, for S4 classes)* The name of the R package where the class is defined.
20+
- `"attributes"`: A dictionary representing R attributes or S4 slots. Each slot value must also be a structured representation dictionary.
21+
- `"data"`: The flat list or array of values for vector/atomic types.
22+
23+
### Example: Implementing a Custom Serializer
24+
25+
Let's say we have a custom Python class named `MyFeature`:
26+
27+
```python
28+
class MyFeature:
29+
def __init__(self, name: str, values: list):
30+
self.name = name
31+
self.values = values
32+
```
33+
34+
To serialize `MyFeature` as a native R S4 class called `"MyCustomRClass"` from package `"MyRPackage"`, we register it using `@save_rds.register`:
35+
36+
```python
37+
from typing import Optional
38+
from rds2py import save_rds
39+
40+
41+
@save_rds.register(MyFeature)
42+
def _save_rds_myfeature(x: MyFeature, path: Optional[str] = None):
43+
# Native C++ writer call
44+
from rds2py.lib_rds_parser import write_rds as write_rds_native
45+
46+
# 1. Structure the Python object into the expected R dictionary format
47+
converted = {
48+
"type": "S4",
49+
"class_name": "MyCustomRClass",
50+
"package_name": "MyRPackage",
51+
"attributes": {
52+
# Recursively call save_rds to serialize internal elements
53+
"featureName": save_rds(x.name),
54+
"featureValues": save_rds(x.values),
55+
},
56+
}
57+
58+
# 2. If a save path is specified, write directly using the native writer
59+
if path is not None:
60+
write_rds_native(converted, path)
61+
62+
return converted
63+
```
64+
65+
---
66+
67+
## 2. Custom Deserialization (RDS -> Python)
68+
69+
To read custom S4 objects back into Python classes via `read_rds`, you need to:
70+
1. Write a deserialization function that constructs your Python class from the raw parsed dictionary.
71+
2. Register your deserializer function in `rds2py`'s global class mapping registry.
72+
73+
### Example: Implementing the Reader
74+
75+
```python
76+
from rds2py.generics import _dispatcher
77+
from rds2py.rdsutils import get_class
78+
79+
80+
def read_my_custom_class(robject: dict, **kwargs) -> MyFeature:
81+
# 1. Verify the incoming R class name
82+
cls_name = get_class(robject)
83+
if cls_name != "MyCustomRClass":
84+
raise ValueError(f"Expected class 'MyCustomRClass', but received '{cls_name}'")
85+
86+
# 2. Extract and parse the slots recursively
87+
# We call the internal _dispatcher helper to parse child structures
88+
feature_name = _dispatcher(robject["attributes"]["featureName"], **kwargs)
89+
feature_values = _dispatcher(robject["attributes"]["featureValues"], **kwargs)
90+
91+
# 3. Instantiate and return your custom Python class
92+
return MyFeature(name=feature_name, values=list(feature_values))
93+
```
94+
95+
### Registering the Reader
96+
Map your class name to the reader function in the global class registry (`REGISTRY` from `rds2py.generics`):
97+
98+
```python
99+
from rds2py.generics import REGISTRY
100+
101+
# Register our custom deserializer in the global map
102+
REGISTRY["MyCustomRClass"] = read_my_custom_class
103+
```
104+
105+
---
106+
107+
## 3. Full Roundtrip
108+
109+
Here is how the entire custom serialization and deserialization workflow works together:
110+
111+
```python
112+
import tempfile
113+
import os
114+
from rds2py import write_rds, read_rds
115+
116+
# 1. Create a custom instance
117+
feature = MyFeature(name="expression_level", values=[10, 20, 30])
118+
119+
# 2. Serialize to a temporary RDS file
120+
with tempfile.NamedTemporaryFile(suffix=".rds", delete=False) as tmp:
121+
path = tmp.name
122+
123+
try:
124+
# Write custom class to RDS format
125+
write_rds(feature, path)
126+
127+
# Read the RDS file back into Python
128+
recreated = read_rds(path)
129+
130+
# 3. Verify that the roundtrip correctly recreated the custom class
131+
assert isinstance(recreated, MyFeature)
132+
assert recreated.name == "expression_level"
133+
assert recreated.values == [10, 20, 30]
134+
print("Roundtrip validation successful!")
135+
finally:
136+
if os.path.exists(path):
137+
os.unlink(path)
138+
```

docs/index.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,31 @@
1-
# rds2py
1+
# rds2py: R Serialization Formats in Python
22

3-
Parse, extract and create Python representations for datasets stored in RDS files. It supports Bioconductor's `SummarizedExperiment` and `SingleCellExperiment` objects. This is possible because of [Aaron's rds2cpp library](https://github.com/LTLA/rds2cpp).
3+
`rds2py` is designed to parse, extract, and write R data formats (RDS and RData) directly in Python. It provides native, out-of-the-box integration with the [BiocPy](https://github.com/biocpy) ecosystem, allowing seamless roundtripping of complex S4 datasets like `SummarizedExperiment`, `SingleCellExperiment`, and `GenomicRanges`.
44

5-
The package uses memory views (except for strings) so that we can access the same memory from C++ space in Python (through Cython of course). This is especially useful for large datasets so we don't make copies of data.
5+
This library is built on top of [Aaron Lun's rds2cpp library](https://github.com/LTLA/rds2cpp).
66

7-
## Install
7+
## Installation
88

9-
Package is published to [PyPI](https://pypi.org/project/rds2py/)
9+
`rds2py` is available on [PyPI](https://pypi.org/project/rds2py/):
1010

1111
```shell
1212
pip install rds2py
1313
```
1414

15-
## Contents
15+
To enable full conversion support for Bioconductor/BiocPy classes, consider installing the optional dependencies:
16+
17+
```shell
18+
pip install rds2py[optional]
19+
```
20+
21+
## Table of Contents
1622

1723
```{toctree}
1824
:maxdepth: 2
1925
2026
Overview <readme>
2127
Tutorial <tutorial>
28+
Custom Serialization Guide <custom_serialization>
2229
Contributions & Help <contributing>
2330
License <license>
2431
Authors <authors>

0 commit comments

Comments
 (0)