Skip to content

Commit 07dab9c

Browse files
committed
feat: expose extractor interface to Python bindings
1 parent 005b961 commit 07dab9c

3 files changed

Lines changed: 117 additions & 3 deletions

File tree

examples/python_basic.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from simstring_rust.database import HashDb
44
from simstring_rust.errors import SearchError
5-
from simstring_rust.extractors import CharacterNgrams
5+
from simstring_rust.extractors import CharacterNgrams, CustomExtractor
66
from simstring_rust.measures import Cosine
77
from simstring_rust.searcher import Searcher
88

@@ -18,6 +18,15 @@ def main():
1818
sample_embedding = extractor.apply("Some text")
1919
print(f"Sample embedding for 'Some text': {sample_embedding}")
2020

21+
class LowerBigrams:
22+
def apply(self, text: str):
23+
tokens = text.lower().split()
24+
return [f"{left}|{right}" for left, right in zip(tokens, tokens[1:])]
25+
26+
custom_extractor = CustomExtractor(LowerBigrams())
27+
custom_embedding = custom_extractor.apply("Some Custom Text")
28+
print(f"Custom extractor embedding: {custom_embedding}")
29+
2130
# Choose a similarity measure.
2231
# Options: Cosine(), Dice(), Jaccard(), Overlap(), ExactMatch()
2332
measure = Cosine()

src/python/mod.rs

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,72 @@ use std::sync::Arc;
1010

1111
create_exception!(simstring_rust, SearchError, pyo3::exceptions::PyValueError);
1212

13+
#[derive(Clone)]
14+
struct CustomExtractorInner {
15+
extractor: Py<PyAny>,
16+
}
17+
18+
unsafe impl Send for CustomExtractorInner {}
19+
unsafe impl Sync for CustomExtractorInner {}
20+
21+
impl CustomExtractorInner {
22+
fn new(extractor: Py<PyAny>) -> Self {
23+
Self { extractor }
24+
}
25+
26+
fn collect_raw_features(&self, text: &str) -> PyResult<Vec<String>> {
27+
Python::with_gil(|py| {
28+
let extractor = self.extractor.bind(py);
29+
let result = extractor.call_method1("apply", (text,))?;
30+
let iter = result.iter()?;
31+
32+
let mut features = Vec::new();
33+
for item in iter {
34+
let item = item?;
35+
features.push(item.extract::<String>()?);
36+
}
37+
38+
Ok(features)
39+
})
40+
}
41+
42+
fn features(&self, text: &str, interner: &mut lasso::Rodeo) -> PyResult<Vec<lasso::Spur>> {
43+
let raw = self.collect_raw_features(text)?;
44+
Ok(super::append_feature_counts(interner, raw))
45+
}
46+
47+
fn apply(&self, text: &str) -> PyResult<Vec<String>> {
48+
let raw = self.collect_raw_features(text)?;
49+
let mut interner = lasso::Rodeo::default();
50+
let spurs = super::append_feature_counts(&mut interner, raw);
51+
52+
Ok(spurs
53+
.into_iter()
54+
.map(|spur| interner.resolve(&spur).to_string())
55+
.collect())
56+
}
57+
}
58+
1359
// Wrapper for FeatureExtractor trait as I can't find any direct translation.
1460
#[derive(Clone)]
1561
enum PyFeatureExtractor {
1662
Character(CharacterNgrams),
1763
Word(WordNgrams),
64+
Custom(CustomExtractorInner),
1865
}
1966

2067
impl FeatureExtractor for PyFeatureExtractor {
2168
fn features(&self, text: &str, interner: &mut lasso::Rodeo) -> Vec<lasso::Spur> {
2269
match self {
2370
PyFeatureExtractor::Character(e) => e.features(text, interner),
2471
PyFeatureExtractor::Word(e) => e.features(text, interner),
72+
PyFeatureExtractor::Custom(e) => match e.features(text, interner) {
73+
Ok(features) => features,
74+
Err(err) => {
75+
Python::with_gil(|py| err.print(py));
76+
panic!("Custom extractor apply() raised an exception");
77+
}
78+
},
2579
}
2680
}
2781
}
@@ -70,6 +124,28 @@ impl PyWordNgrams {
70124
}
71125
}
72126

127+
#[pyclass(name = "CustomExtractor")]
128+
#[derive(Clone)]
129+
struct PyCustomExtractor(CustomExtractorInner);
130+
131+
#[pymethods]
132+
impl PyCustomExtractor {
133+
#[new]
134+
fn new(extractor: &Bound<'_, PyAny>) -> PyResult<Self> {
135+
if !extractor.hasattr("apply")? {
136+
return Err(pyo3::exceptions::PyTypeError::new_err(
137+
"Custom extractor must provide an apply(text: str) -> Iterable[str] method",
138+
));
139+
}
140+
141+
Ok(Self(CustomExtractorInner::new(extractor.unbind())))
142+
}
143+
144+
fn apply(&self, text: &str) -> PyResult<Vec<String>> {
145+
self.0.apply(text)
146+
}
147+
}
148+
73149
// Wrapper for Measure trait
74150
#[derive(Clone, Copy)]
75151
enum PyMeasure {
@@ -193,9 +269,11 @@ impl PyHashDb {
193269
PyFeatureExtractor::Character(char_ngram.0.clone())
194270
} else if let Ok(word_ngram) = extractor.extract::<PyRef<PyWordNgrams>>() {
195271
PyFeatureExtractor::Word(word_ngram.0.clone())
272+
} else if let Ok(custom) = extractor.extract::<PyRef<PyCustomExtractor>>() {
273+
PyFeatureExtractor::Custom(custom.0.clone())
196274
} else {
197275
return Err(pyo3::exceptions::PyTypeError::new_err(
198-
"Extractor must be CharacterNgrams or WordNgrams",
276+
"Extractor must be CharacterNgrams, WordNgrams, or CustomExtractor",
199277
));
200278
};
201279

@@ -300,6 +378,7 @@ fn simstring_rust(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
300378
let extractors_module = PyModule::new(py, "extractors")?;
301379
extractors_module.add_class::<PyCharacterNgrams>()?;
302380
extractors_module.add_class::<PyWordNgrams>()?;
381+
extractors_module.add_class::<PyCustomExtractor>()?;
303382
m.add_submodule(&extractors_module)?;
304383

305384
// Measures submodule

tests/python/test_bindings.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from simstring_rust.database import HashDb
55
from simstring_rust.errors import SearchError
6-
from simstring_rust.extractors import CharacterNgrams, WordNgrams
6+
from simstring_rust.extractors import CharacterNgrams, WordNgrams, CustomExtractor
77
from simstring_rust.measures import Cosine
88
from simstring_rust.searcher import Searcher
99

@@ -82,3 +82,29 @@ def test_word_ngram_apply(self):
8282

8383
expected = ["# foo1", "foo bar1", "bar baz1", "baz #1"]
8484
assert Counter(features) == Counter(expected)
85+
86+
def test_custom_extractor_apply(self):
87+
class UnigramExtractor:
88+
def apply(self, text: str):
89+
return list(text)
90+
91+
extractor = CustomExtractor(UnigramExtractor())
92+
features = extractor.apply("foo")
93+
94+
expected = ["f1", "o1", "o2"]
95+
assert Counter(features) == Counter(expected)
96+
97+
def test_custom_extractor_in_db(self):
98+
class UnigramExtractor:
99+
def apply(self, text: str):
100+
return list(text)
101+
102+
extractor = CustomExtractor(UnigramExtractor())
103+
db = HashDb(extractor)
104+
db.insert("foo")
105+
db.insert("bar")
106+
107+
searcher = Searcher(db, Cosine())
108+
results = searcher.search("foo", 0.8)
109+
110+
assert results == ["foo"]

0 commit comments

Comments
 (0)