Skip to content

Commit 2a0fb63

Browse files
authored
drop unindent and indoc dependencies (#5608)
* drop `unindent` and `indoc` dependencies * clippy, msrv, remove indoc fully, newsfragment * msrv * refactor `unindent_bytes` function
1 parent 5d55fe5 commit 2a0fb63

File tree

11 files changed

+283
-34
lines changed

11 files changed

+283
-34
lines changed

Cargo.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@ pyo3-ffi = { path = "pyo3-ffi", version = "=0.27.1" }
3333

3434
# support crates for macros feature
3535
pyo3-macros = { path = "pyo3-macros", version = "=0.27.1", optional = true }
36-
indoc = { version = "2.0.1", optional = true }
37-
unindent = { version = "0.2.1", optional = true }
3836

3937
# support crate for multiple-pymethods feature
4038
inventory = { version = "0.3.5", optional = true }
@@ -98,7 +96,7 @@ experimental-async = ["macros", "pyo3-macros/experimental-async"]
9896
experimental-inspect = ["pyo3-macros/experimental-inspect"]
9997

10098
# Enables macros: #[pyclass], #[pymodule], #[pyfunction] etc.
101-
macros = ["pyo3-macros", "indoc", "unindent"]
99+
macros = ["pyo3-macros"]
102100

103101
# Enables multiple #[pymethods] per #[pyclass]
104102
multiple-pymethods = ["inventory", "pyo3-macros/multiple-pymethods"]

newsfragments/5608.packaging.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Drop `indoc` and `unindent` dependencies.

src/conversions/num_bigint.rs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,6 @@ mod tests {
328328
use crate::exceptions::PyTypeError;
329329
use crate::test_utils::generate_unique_module_name;
330330
use crate::types::{PyAnyMethods as _, PyDict, PyModule};
331-
use indoc::indoc;
332331
use pyo3_ffi::c_str;
333332

334333
fn rust_fib<T>() -> impl Iterator<Item = T>
@@ -390,15 +389,15 @@ mod tests {
390389
}
391390

392391
fn python_index_class(py: Python<'_>) -> Bound<'_, PyModule> {
393-
let index_code = c_str!(indoc!(
392+
let index_code = c_str!(
394393
r#"
395-
class C:
396-
def __init__(self, x):
397-
self.x = x
398-
def __index__(self):
399-
return self.x
400-
"#
401-
));
394+
class C:
395+
def __init__(self, x):
396+
self.x = x
397+
def __index__(self):
398+
return self.x
399+
"#
400+
);
402401
PyModule::from_code(
403402
py,
404403
index_code,

src/impl_.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ pub mod pymethods;
2626
pub mod pymodule;
2727
#[doc(hidden)]
2828
pub mod trampoline;
29+
pub mod unindent;
2930
pub mod wrap;

src/impl_/concat.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ pub const fn combine_to_array<const LEN: usize>(pieces: &[&[u8]]) -> [u8; LEN] {
3737
}
3838

3939
/// Replacement for `slice::copy_from_slice`, which is const from 1.87
40-
const fn slice_copy_from_slice(out: &mut [u8], src: &[u8]) {
40+
pub(crate) const fn slice_copy_from_slice(out: &mut [u8], src: &[u8]) {
4141
let mut i = 0;
4242
while i < src.len() {
4343
out[i] = src[i];

src/impl_/unindent.rs

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
use crate::impl_::concat::slice_copy_from_slice;
2+
3+
/// This is a reimplementation of the `indoc` crate's unindent functionality:
4+
///
5+
/// 1. Count the leading spaces of each line, ignoring the first line and any lines that are empty or contain spaces only.
6+
/// 2. Take the minimum.
7+
/// 3. If the first line is empty i.e. the string begins with a newline, remove the first line.
8+
/// 4. Remove the computed number of spaces from the beginning of each line.
9+
const fn unindent_bytes(bytes: &mut [u8]) -> usize {
10+
// (1) + (2) - count leading spaces, take the minimum
11+
let Some(to_unindent) = get_minimum_leading_spaces(bytes) else {
12+
// all lines were empty, nothing to unindent
13+
return bytes.len();
14+
};
15+
16+
// now copy from the original buffer, bringing values forward as needed
17+
let mut read_idx = 0;
18+
let mut write_idx = 0;
19+
20+
// (3) - remove first line if it is empty
21+
match consume_eol(bytes, read_idx) {
22+
// skip empty first line
23+
Some(eol) => read_idx = eol,
24+
// copy non-empty first line as-is
25+
None => {
26+
(read_idx, write_idx) = copy_forward_until_eol(bytes, read_idx, write_idx);
27+
}
28+
};
29+
30+
// (4) - unindent remaining lines
31+
while read_idx < bytes.len() {
32+
let leading_spaces = count_spaces(bytes, read_idx);
33+
34+
if leading_spaces < to_unindent {
35+
read_idx += leading_spaces;
36+
assert!(
37+
consume_eol(bytes, read_idx).is_some(),
38+
"removed fewer spaces than expected on non-empty line"
39+
);
40+
} else {
41+
// leading_spaces may be equal to or larger than to_unindent, only need to unindent
42+
// the required amount, additional indentation is meaningful
43+
read_idx += to_unindent;
44+
}
45+
46+
// copy remainder of line
47+
(read_idx, write_idx) = copy_forward_until_eol(bytes, read_idx, write_idx);
48+
}
49+
50+
write_idx
51+
}
52+
53+
/// Counts the minimum leading spaces of all non-empty lines except the first line.
54+
///
55+
/// Returns `None` if there are no non-empty lines except the first line.
56+
const fn get_minimum_leading_spaces(bytes: &[u8]) -> Option<usize> {
57+
// scan for leading spaces (ignoring first line and empty lines)
58+
let mut i = 0;
59+
60+
// skip first line
61+
i = advance_to_next_line(bytes, i);
62+
63+
let mut to_unindent = None;
64+
65+
// for remaining lines, count leading spaces
66+
while i < bytes.len() {
67+
let line_leading_spaces = count_spaces(bytes, i);
68+
i += line_leading_spaces;
69+
70+
// line only had spaces, ignore for the count
71+
if let Some(eol) = consume_eol(bytes, i) {
72+
i = eol;
73+
continue;
74+
}
75+
76+
// this line has content, consider its leading spaces
77+
if let Some(current) = to_unindent {
78+
// .unwrap_or(usize::MAX) not available in const fn
79+
if line_leading_spaces < current {
80+
to_unindent = Some(line_leading_spaces);
81+
}
82+
} else {
83+
to_unindent = Some(line_leading_spaces);
84+
}
85+
86+
i = advance_to_next_line(bytes, i);
87+
}
88+
89+
to_unindent
90+
}
91+
92+
const fn advance_to_next_line(bytes: &[u8], mut i: usize) -> usize {
93+
while i < bytes.len() {
94+
if let Some(eol) = consume_eol(bytes, i) {
95+
return eol;
96+
}
97+
i += 1;
98+
}
99+
i
100+
}
101+
102+
/// Brings elements in `bytes` forward until `\n` (inclusive) or end of `source`.
103+
///
104+
/// `read_idx` must be greater than or equal to `write_idx`.
105+
const fn copy_forward_until_eol(
106+
bytes: &mut [u8],
107+
mut read_idx: usize,
108+
mut write_idx: usize,
109+
) -> (usize, usize) {
110+
assert!(read_idx >= write_idx);
111+
while read_idx < bytes.len() {
112+
let value = bytes[read_idx];
113+
bytes[write_idx] = value;
114+
read_idx += 1;
115+
write_idx += 1;
116+
if value == b'\n' {
117+
break;
118+
}
119+
}
120+
(read_idx, write_idx)
121+
}
122+
123+
const fn count_spaces(bytes: &[u8], mut i: usize) -> usize {
124+
let mut count = 0;
125+
while i < bytes.len() && bytes[i] == b' ' {
126+
count += 1;
127+
i += 1;
128+
}
129+
count
130+
}
131+
132+
const fn consume_eol(bytes: &[u8], i: usize) -> Option<usize> {
133+
if bytes.len() == i {
134+
// special case: treat end of buffer as EOL without consuming anything
135+
Some(i)
136+
} else if bytes.len() > i && bytes[i] == b'\n' {
137+
Some(i + 1)
138+
} else if bytes[i] == b'\r' && bytes.len() > i + 1 && bytes[i + 1] == b'\n' {
139+
Some(i + 2)
140+
} else {
141+
None
142+
}
143+
}
144+
145+
pub const fn unindent_sized<const N: usize>(src: &[u8]) -> ([u8; N], usize) {
146+
let mut out: [u8; N] = [0; N];
147+
slice_copy_from_slice(&mut out, src);
148+
let new_len = unindent_bytes(&mut out);
149+
(out, new_len)
150+
}
151+
152+
/// Helper for `py_run!` macro which unindents a string at compile time.
153+
#[macro_export]
154+
#[doc(hidden)]
155+
macro_rules! unindent {
156+
($value:expr) => {{
157+
const RAW: &str = $value;
158+
const LEN: usize = RAW.len();
159+
const UNINDENTED: ([u8; LEN], usize) =
160+
$crate::impl_::unindent::unindent_sized::<LEN>(RAW.as_bytes());
161+
// SAFETY: this removes only spaces and preserves all other contents
162+
unsafe { ::core::str::from_utf8_unchecked(UNINDENTED.0.split_at(UNINDENTED.1).0) }
163+
}};
164+
}
165+
166+
pub use crate::unindent;
167+
168+
/// Equivalent of the `unindent!` macro, but works at runtime.
169+
pub fn unindent(s: &str) -> String {
170+
let mut bytes = s.as_bytes().to_owned();
171+
let unindented_size = unindent_bytes(&mut bytes);
172+
bytes.resize(unindented_size, 0);
173+
String::from_utf8(bytes).unwrap()
174+
}
175+
176+
#[cfg(test)]
177+
mod tests {
178+
use super::*;
179+
180+
const SAMPLE_1_WITH_FIRST_LINE: &str = " first line
181+
line one
182+
183+
line two
184+
";
185+
186+
const UNINDENTED_1: &str = " first line\nline one\n\n line two\n";
187+
188+
const SAMPLE_2_EMPTY_FIRST_LINE: &str = "
189+
line one
190+
191+
line two
192+
";
193+
const UNINDENTED_2: &str = "line one\n\n line two\n";
194+
195+
const SAMPLE_3_NO_INDENT: &str = "
196+
no indent
197+
here";
198+
199+
const UNINDENTED_3: &str = "no indent\n here";
200+
201+
const SAMPLE_4_NOOP: &str = "no indent\nhere\n but here";
202+
203+
const SAMPLE_5_EMPTY: &str = " \n \n";
204+
205+
const ALL_CASES: &[(&str, &str)] = &[
206+
(SAMPLE_1_WITH_FIRST_LINE, UNINDENTED_1),
207+
(SAMPLE_2_EMPTY_FIRST_LINE, UNINDENTED_2),
208+
(SAMPLE_3_NO_INDENT, UNINDENTED_3),
209+
(SAMPLE_4_NOOP, SAMPLE_4_NOOP),
210+
(SAMPLE_5_EMPTY, SAMPLE_5_EMPTY),
211+
];
212+
213+
// run const tests for each sample to ensure they work at compile time
214+
215+
#[test]
216+
fn test_unindent_const() {
217+
const UNINDENTED: &str = unindent!(SAMPLE_1_WITH_FIRST_LINE);
218+
assert_eq!(UNINDENTED, UNINDENTED_1);
219+
}
220+
221+
#[test]
222+
fn test_unindent_const_removes_empty_first_line() {
223+
const UNINDENTED: &str = unindent!(SAMPLE_2_EMPTY_FIRST_LINE);
224+
assert_eq!(UNINDENTED, UNINDENTED_2);
225+
}
226+
227+
#[test]
228+
fn test_unindent_const_no_indent() {
229+
const UNINDENTED: &str = unindent!(SAMPLE_3_NO_INDENT);
230+
assert_eq!(UNINDENTED, UNINDENTED_3);
231+
}
232+
233+
#[test]
234+
fn test_unindent_macro_runtime() {
235+
// this variation on the test ensures full coverage (const eval not included in coverage)
236+
const INDENTED: &str = SAMPLE_1_WITH_FIRST_LINE;
237+
const LEN: usize = INDENTED.len();
238+
let (unindented, unindented_size) = unindent_sized::<LEN>(INDENTED.as_bytes());
239+
let unindented = std::str::from_utf8(&unindented[..unindented_size]).unwrap();
240+
assert_eq!(unindented, UNINDENTED_1);
241+
}
242+
243+
#[test]
244+
fn test_unindent_function() {
245+
for (indented, expected) in ALL_CASES {
246+
let unindented = unindent(indented);
247+
assert_eq!(&unindented, expected);
248+
}
249+
}
250+
}

src/lib.rs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -398,13 +398,6 @@ pub mod class {
398398
}
399399
}
400400

401-
#[cfg(feature = "macros")]
402-
#[doc(hidden)]
403-
pub use {
404-
indoc, // Re-exported for py_run
405-
unindent, // Re-exported for py_run
406-
};
407-
408401
#[cfg(all(feature = "macros", feature = "multiple-pymethods"))]
409402
#[doc(hidden)]
410403
pub use inventory; // Re-exported for `#[pyclass]` and `#[pymethods]` with `multiple-pymethods`.

src/macros.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,20 +85,27 @@
8585
/// ```
8686
#[macro_export]
8787
macro_rules! py_run {
88+
// unindent the code at compile time
8889
($py:expr, $($val:ident)+, $code:literal) => {{
89-
$crate::py_run_impl!($py, $($val)+, $crate::indoc::indoc!($code))
90-
}};
91-
($py:expr, $($val:ident)+, $code:expr) => {{
92-
$crate::py_run_impl!($py, $($val)+, $crate::unindent::unindent($code))
90+
$crate::py_run_impl!($py, $($val)+, $crate::impl_::unindent::unindent!($code))
9391
}};
9492
($py:expr, *$dict:expr, $code:literal) => {{
95-
$crate::py_run_impl!($py, *$dict, $crate::indoc::indoc!($code))
93+
$crate::py_run_impl!($py, *$dict, $crate::impl_::unindent::unindent!($code))
94+
}};
95+
// unindent the code at runtime
96+
($py:expr, $($val:ident)+, $code:expr) => {{
97+
$crate::py_run_impl!($py, $($val)+, $crate::impl_::unindent::unindent($code))
9698
}};
9799
($py:expr, *$dict:expr, $code:expr) => {{
98-
$crate::py_run_impl!($py, *$dict, $crate::unindent::unindent($code))
100+
$crate::py_run_impl!($py, *$dict, $crate::impl_::unindent::unindent($code))
99101
}};
100102
}
101103

104+
/// Internal implementation of the `py_run!` macro.
105+
///
106+
/// FIXME: this currently unconditionally allocates a `CString`. We should consider making this not so:
107+
/// - Maybe require users to pass `&CStr` / `CString`?
108+
/// - Maybe adjust the `unindent` code to produce `&Cstr` / `Cstring`?
102109
#[macro_export]
103110
#[doc(hidden)]
104111
macro_rules! py_run_impl {

tests/test_class_new.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ impl SuperClass {
156156
fn subclass_new() {
157157
Python::attach(|py| {
158158
let super_cls = py.get_type::<SuperClass>();
159-
let source = pyo3_ffi::c_str!(pyo3::indoc::indoc!(
159+
let source = pyo3_ffi::c_str!(
160160
r#"
161161
class Class(SuperClass):
162162
def __new__(cls):
@@ -168,7 +168,7 @@ class Class(SuperClass):
168168
c = Class()
169169
assert c.from_rust is False
170170
"#
171-
));
171+
);
172172
let globals = PyModule::import(py, "__main__").unwrap().dict();
173173
globals.set_item("SuperClass", super_cls).unwrap();
174174
py.run(source, Some(&globals), None)

0 commit comments

Comments
 (0)