Skip to content

Commit aff860c

Browse files
authored
Add support for writing archives (#1)
1 parent a4c6a1e commit aff860c

6 files changed

Lines changed: 1191 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ requires = ["maturin>=1.9,<2.0"]
1313
build-backend = "maturin"
1414

1515
[dependency-groups]
16-
dev = ["maturin>=1.9.6"]
16+
dev = [
17+
"maturin>=1.9.6",
18+
"pytest>=8.3.5",
19+
"ruff>=0.14.2",
20+
]
1721

1822
[tool.maturin]
1923
features = ["pyo3/extension-module"]

src/lib.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
use flate2::read::GzDecoder;
2+
use flate2::write::GzEncoder;
3+
use flate2::Compression;
4+
use pyo3::exceptions::PyRuntimeError;
25
use pyo3::prelude::*;
6+
use pyo3::types::{PyAny, PyType};
37
use std::fs::File;
8+
use std::io::Write;
9+
use std::path::{Path, PathBuf};
410
use tar::Archive;
511

612
#[pyfunction]
@@ -12,8 +18,124 @@ fn untar_gz(tar_gz_path: String, destination_path: String) -> PyResult<()> {
1218
Ok(())
1319
}
1420

21+
#[pyclass]
22+
struct ArchiveWriter {
23+
builder: Option<tar::Builder<Box<dyn Write + Send + Sync>>>,
24+
}
25+
26+
#[pymethods]
27+
impl ArchiveWriter {
28+
#[classmethod]
29+
#[pyo3(signature = (path, mode="w:gz"))]
30+
fn open(
31+
_cls: &Bound<'_, PyType>,
32+
py: Python<'_>,
33+
path: PathBuf,
34+
mode: &str,
35+
) -> PyResult<Py<ArchiveWriter>> {
36+
match mode {
37+
"w:gz" => {
38+
let file = File::create(path)?;
39+
let enc = GzEncoder::new(file, Compression::default());
40+
let writer: Box<dyn Write + Send + Sync> = Box::new(enc);
41+
let builder = tar::Builder::new(writer);
42+
Py::new(
43+
py,
44+
ArchiveWriter {
45+
builder: Some(builder),
46+
},
47+
)
48+
}
49+
"w" => {
50+
let file = File::create(path)?;
51+
let writer: Box<dyn Write + Send + Sync> = Box::new(file);
52+
let builder = tar::Builder::new(writer);
53+
Py::new(
54+
py,
55+
ArchiveWriter {
56+
builder: Some(builder),
57+
},
58+
)
59+
}
60+
_ => Err(PyRuntimeError::new_err(
61+
"unsupported mode; only 'w' and 'w:gz' are supported",
62+
)),
63+
}
64+
}
65+
66+
#[pyo3(signature = (path, arcname=None, recursive=true))]
67+
fn add(&mut self, path: PathBuf, arcname: Option<String>, recursive: bool) -> PyResult<()> {
68+
let builder = self
69+
.builder
70+
.as_mut()
71+
.ok_or_else(|| PyRuntimeError::new_err("archive is already closed"))?;
72+
73+
let default_name = || -> PyResult<String> {
74+
let name = Path::new(&path)
75+
.file_name()
76+
.ok_or_else(|| PyRuntimeError::new_err("cannot derive name from path"))?
77+
.to_string_lossy()
78+
.into_owned();
79+
Ok(name)
80+
}()?;
81+
82+
let name = arcname.unwrap_or(default_name);
83+
84+
if path.is_dir() {
85+
if recursive {
86+
builder.append_dir_all(&name, &path)?;
87+
} else {
88+
builder.append_dir(&name, &path)?;
89+
}
90+
} else if path.is_file() {
91+
builder.append_path_with_name(&path, &name)?;
92+
} else {
93+
return Err(PyRuntimeError::new_err("path does not exist"));
94+
}
95+
Ok(())
96+
}
97+
98+
fn close(&mut self) -> PyResult<()> {
99+
if let Some(builder) = self.builder.take() {
100+
let mut writer = builder.into_inner()?;
101+
writer.flush()?;
102+
}
103+
Ok(())
104+
}
105+
106+
fn __enter__(py_self: PyRef<'_, Self>) -> PyRef<'_, Self> {
107+
py_self
108+
}
109+
110+
fn __exit__(
111+
&mut self,
112+
_exc_type: Option<Bound<'_, PyAny>>,
113+
_exc: Option<Bound<'_, PyAny>>,
114+
_tb: Option<Bound<'_, PyAny>>,
115+
) -> PyResult<bool> {
116+
self.close()?;
117+
Ok(false) // Propagate exceptions if any
118+
}
119+
}
120+
121+
#[pyfunction]
122+
#[pyo3(signature = (path, mode))]
123+
fn open(py: Python<'_>, path: PathBuf, mode: &str) -> PyResult<PyObject> {
124+
match mode {
125+
"w" | "w:gz" => {
126+
let writer = ArchiveWriter::open(&py.get_type::<ArchiveWriter>(), py, path, mode)?;
127+
Ok(writer.into())
128+
}
129+
_ => Err(PyRuntimeError::new_err(
130+
"unsupported mode; supported modes are 'w', 'w:gz'",
131+
)),
132+
}
133+
}
134+
15135
#[pymodule]
16136
fn fastar(m: &Bound<'_, PyModule>) -> PyResult<()> {
137+
m.add_class::<ArchiveWriter>()?;
138+
m.add_function(wrap_pyfunction!(open, m)?)?;
17139
m.add_function(wrap_pyfunction!(untar_gz, m)?)?;
18140
Ok(())
19141
}

tests/conftest.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Literal, TypeAlias
2+
from pathlib import Path
3+
import pytest
4+
5+
6+
WriteMode: TypeAlias = Literal["w", "w:gz"]
7+
ReadMode: TypeAlias = Literal["r", "r:gz"]
8+
9+
10+
@pytest.fixture
11+
def archive_path(tmp_path) -> Path:
12+
return tmp_path / "archive.tar.gz"
13+
14+
15+
@pytest.fixture(params=[("w", "r"), ("w:gz", "r:gz")])
16+
def modes(request) -> tuple[WriteMode, ReadMode]:
17+
return request.param
18+
19+
20+
@pytest.fixture
21+
def write_mode(modes) -> WriteMode:
22+
return modes[0]
23+
24+
25+
@pytest.fixture
26+
def read_mode(modes) -> ReadMode:
27+
return modes[1]

0 commit comments

Comments
 (0)