Skip to main content

lindera/
metadata.rs

1//! Dictionary metadata configuration.
2//!
3//! This module provides structures for configuring dictionary metadata, including
4//! character encodings and schema definitions. The defaults and schema wiring are
5//! delegated to [`lindera_binding_core::CoreMetadata`]; this module only adds the
6//! PyO3 wrappers.
7//!
8//! # Examples
9//!
10//! ```python
11//! # Create metadata with default values
12//! metadata = lindera.Metadata()
13//!
14//! # Create metadata with custom values
15//! metadata = lindera.Metadata(
16//!     name="custom_dict",
17//!     encoding="UTF-8",
18//! )
19//!
20//! # Load metadata from JSON
21//! metadata = lindera.Metadata.from_json_file("metadata.json")
22//! ```
23
24use std::collections::HashMap;
25
26use pyo3::prelude::*;
27
28use lindera::dictionary::Metadata;
29use lindera_binding_core::CoreMetadata;
30
31use crate::schema::PySchema;
32
33/// Dictionary metadata configuration.
34///
35/// A thin PyO3 wrapper over [`lindera_binding_core::CoreMetadata`], which owns
36/// the default values and the schema wiring.
37///
38/// # Fields
39///
40/// * `name` - Dictionary name
41/// * `encoding` - Character encoding (default: "UTF-8")
42/// * `default_word_cost` - Default cost for unknown words (default: -10000)
43/// * `default_left_context_id` - Default left context ID (default: 1288)
44/// * `default_right_context_id` - Default right context ID (default: 1288)
45/// * `default_field_value` - Default value for missing fields (default: "*")
46/// * `flexible_csv` - Allow flexible CSV parsing (default: false)
47/// * `skip_invalid_cost_or_id` - Skip entries with invalid cost/ID (default: false)
48/// * `normalize_details` - Normalize morphological details (default: false)
49/// * `dictionary_schema` - Schema for main dictionary
50/// * `user_dictionary_schema` - Schema for user dictionary
51#[pyclass(name = "Metadata", from_py_object)]
52#[derive(Debug, Clone)]
53pub struct PyMetadata {
54    /// The backing binding-core metadata.
55    pub inner: CoreMetadata,
56}
57
58#[pymethods]
59impl PyMetadata {
60    #[new]
61    #[pyo3(signature = (name=None, encoding=None, default_word_cost=None, default_left_context_id=None, default_right_context_id=None, default_field_value=None, flexible_csv=None, skip_invalid_cost_or_id=None, normalize_details=None, dictionary_schema=None, user_dictionary_schema=None))]
62    #[allow(clippy::too_many_arguments)]
63    pub fn new(
64        name: Option<String>,
65        encoding: Option<String>,
66        default_word_cost: Option<i16>,
67        default_left_context_id: Option<u16>,
68        default_right_context_id: Option<u16>,
69        default_field_value: Option<String>,
70        flexible_csv: Option<bool>,
71        skip_invalid_cost_or_id: Option<bool>,
72        normalize_details: Option<bool>,
73        dictionary_schema: Option<PySchema>,
74        user_dictionary_schema: Option<PySchema>,
75    ) -> Self {
76        PyMetadata {
77            inner: CoreMetadata::new(
78                name,
79                encoding,
80                default_word_cost,
81                default_left_context_id,
82                default_right_context_id,
83                default_field_value,
84                flexible_csv,
85                skip_invalid_cost_or_id,
86                normalize_details,
87                dictionary_schema.map(Into::into),
88                user_dictionary_schema.map(Into::into),
89            ),
90        }
91    }
92
93    #[staticmethod]
94    pub fn create_default() -> Self {
95        PyMetadata {
96            inner: CoreMetadata::create_default(),
97        }
98    }
99
100    #[staticmethod]
101    pub fn from_json_file(path: &str) -> PyResult<Self> {
102        use std::fs;
103
104        let json_str = fs::read_to_string(path).map_err(|e| {
105            pyo3::exceptions::PyIOError::new_err(format!("Failed to read file: {e}"))
106        })?;
107
108        let metadata: Metadata = serde_json::from_str(&json_str).map_err(|e| {
109            pyo3::exceptions::PyValueError::new_err(format!("Failed to parse JSON: {e}"))
110        })?;
111
112        Ok(metadata.into())
113    }
114
115    #[getter]
116    pub fn name(&self) -> &str {
117        &self.inner.name
118    }
119
120    #[setter]
121    pub fn set_name(&mut self, name: String) {
122        self.inner.name = name;
123    }
124
125    #[getter]
126    pub fn encoding(&self) -> &str {
127        &self.inner.encoding
128    }
129
130    #[setter]
131    pub fn set_encoding(&mut self, encoding: String) {
132        self.inner.encoding = encoding;
133    }
134
135    #[getter]
136    pub fn default_word_cost(&self) -> i16 {
137        self.inner.default_word_cost
138    }
139
140    #[setter]
141    pub fn set_default_word_cost(&mut self, cost: i16) {
142        self.inner.default_word_cost = cost;
143    }
144
145    #[getter]
146    pub fn default_left_context_id(&self) -> u16 {
147        self.inner.default_left_context_id
148    }
149
150    #[setter]
151    pub fn set_default_left_context_id(&mut self, id: u16) {
152        self.inner.default_left_context_id = id;
153    }
154
155    #[getter]
156    pub fn default_right_context_id(&self) -> u16 {
157        self.inner.default_right_context_id
158    }
159
160    #[setter]
161    pub fn set_default_right_context_id(&mut self, id: u16) {
162        self.inner.default_right_context_id = id;
163    }
164
165    #[getter]
166    pub fn default_field_value(&self) -> &str {
167        &self.inner.default_field_value
168    }
169
170    #[setter]
171    pub fn set_default_field_value(&mut self, value: String) {
172        self.inner.default_field_value = value;
173    }
174
175    #[getter]
176    pub fn flexible_csv(&self) -> bool {
177        self.inner.flexible_csv
178    }
179
180    #[setter]
181    pub fn set_flexible_csv(&mut self, value: bool) {
182        self.inner.flexible_csv = value;
183    }
184
185    #[getter]
186    pub fn skip_invalid_cost_or_id(&self) -> bool {
187        self.inner.skip_invalid_cost_or_id
188    }
189
190    #[setter]
191    pub fn set_skip_invalid_cost_or_id(&mut self, value: bool) {
192        self.inner.skip_invalid_cost_or_id = value;
193    }
194
195    #[getter]
196    pub fn normalize_details(&self) -> bool {
197        self.inner.normalize_details
198    }
199
200    #[setter]
201    pub fn set_normalize_details(&mut self, value: bool) {
202        self.inner.normalize_details = value;
203    }
204
205    #[getter]
206    pub fn dictionary_schema(&self) -> PySchema {
207        PySchema::from(self.inner.dictionary_schema.clone())
208    }
209
210    #[setter]
211    pub fn set_dictionary_schema(&mut self, schema: PySchema) {
212        self.inner.dictionary_schema = schema.into();
213    }
214
215    #[getter]
216    pub fn user_dictionary_schema(&self) -> PySchema {
217        PySchema::from(self.inner.user_dictionary_schema.clone())
218    }
219
220    #[setter]
221    pub fn set_user_dictionary_schema(&mut self, schema: PySchema) {
222        self.inner.user_dictionary_schema = schema.into();
223    }
224
225    pub fn to_dict(&self) -> HashMap<String, String> {
226        let mut dict = HashMap::new();
227        dict.insert("name".to_string(), self.inner.name.clone());
228        dict.insert("encoding".to_string(), self.inner.encoding.clone());
229        dict.insert(
230            "default_word_cost".to_string(),
231            self.inner.default_word_cost.to_string(),
232        );
233        dict.insert(
234            "default_left_context_id".to_string(),
235            self.inner.default_left_context_id.to_string(),
236        );
237        dict.insert(
238            "default_right_context_id".to_string(),
239            self.inner.default_right_context_id.to_string(),
240        );
241        dict.insert(
242            "default_field_value".to_string(),
243            self.inner.default_field_value.clone(),
244        );
245        dict.insert(
246            "flexible_csv".to_string(),
247            self.inner.flexible_csv.to_string(),
248        );
249        dict.insert(
250            "skip_invalid_cost_or_id".to_string(),
251            self.inner.skip_invalid_cost_or_id.to_string(),
252        );
253        dict.insert(
254            "normalize_details".to_string(),
255            self.inner.normalize_details.to_string(),
256        );
257        dict.insert(
258            "dictionary_schema_fields".to_string(),
259            self.inner.dictionary_schema.fields().join(","),
260        );
261        dict.insert(
262            "user_dictionary_schema_fields".to_string(),
263            self.inner.user_dictionary_schema.fields().join(","),
264        );
265        dict
266    }
267
268    fn __str__(&self) -> String {
269        format!(
270            "Metadata(name='{}', encoding='{}')",
271            self.inner.name, self.inner.encoding,
272        )
273    }
274
275    fn __repr__(&self) -> String {
276        format!(
277            "Metadata(name='{}', encoding='{}', schema_fields={})",
278            self.inner.name,
279            self.inner.encoding,
280            self.inner.dictionary_schema.field_count()
281        )
282    }
283}
284
285impl From<PyMetadata> for Metadata {
286    fn from(metadata: PyMetadata) -> Self {
287        metadata.inner.into()
288    }
289}
290
291impl From<Metadata> for PyMetadata {
292    fn from(metadata: Metadata) -> Self {
293        PyMetadata {
294            inner: CoreMetadata::from(metadata),
295        }
296    }
297}
298
299pub fn register(parent_module: &Bound<'_, PyModule>) -> PyResult<()> {
300    let py = parent_module.py();
301    let m = PyModule::new(py, "metadata")?;
302    m.add_class::<PyMetadata>()?;
303    parent_module.add_submodule(&m)?;
304    Ok(())
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use lindera::dictionary::Metadata;
311
312    #[test]
313    fn test_pymetadata_to_metadata() {
314        let py_meta = PyMetadata::new(
315            Some("test_dict".to_string()),
316            Some("EUC-JP".to_string()),
317            Some(-5000),
318            Some(100),
319            Some(200),
320            Some("N/A".to_string()),
321            Some(true),
322            Some(true),
323            Some(true),
324            None,
325            None,
326        );
327        let meta: Metadata = py_meta.into();
328        assert_eq!(meta.name, "test_dict");
329        assert_eq!(meta.encoding, "EUC-JP");
330        assert_eq!(meta.default_word_cost, -5000);
331        assert_eq!(meta.default_left_context_id, 100);
332        assert_eq!(meta.default_right_context_id, 200);
333        assert_eq!(meta.default_field_value, "N/A");
334        assert!(meta.flexible_csv);
335        assert!(meta.skip_invalid_cost_or_id);
336        assert!(meta.normalize_details);
337    }
338
339    #[test]
340    fn test_metadata_to_pymetadata() {
341        let schema = lindera::dictionary::Schema::new(vec![
342            "surface".to_string(),
343            "left_context_id".to_string(),
344            "right_context_id".to_string(),
345            "cost".to_string(),
346        ]);
347        let userdic_schema =
348            lindera::dictionary::Schema::new(vec!["surface".to_string(), "reading".to_string()]);
349        let meta = Metadata::new(
350            "my_dict".to_string(),
351            "UTF-8".to_string(),
352            -10000,
353            1288,
354            1288,
355            "*".to_string(),
356            false,
357            false,
358            false,
359            schema,
360            userdic_schema,
361        );
362        let py_meta: PyMetadata = meta.into();
363        assert_eq!(py_meta.name(), "my_dict");
364        assert_eq!(py_meta.encoding(), "UTF-8");
365        assert_eq!(py_meta.default_word_cost(), -10000);
366        assert_eq!(py_meta.default_left_context_id(), 1288);
367        assert_eq!(py_meta.default_right_context_id(), 1288);
368        assert_eq!(py_meta.default_field_value(), "*");
369        assert!(!py_meta.flexible_csv());
370        assert!(!py_meta.skip_invalid_cost_or_id());
371        assert!(!py_meta.normalize_details());
372        assert_eq!(py_meta.dictionary_schema().fields().len(), 4);
373        assert_eq!(py_meta.user_dictionary_schema().fields().len(), 2);
374    }
375
376    #[test]
377    fn test_pymetadata_default_values() {
378        let py_meta = PyMetadata::create_default();
379        assert_eq!(py_meta.name(), "default");
380        assert_eq!(py_meta.encoding(), "UTF-8");
381        assert_eq!(py_meta.default_word_cost(), -10000);
382        assert_eq!(py_meta.default_left_context_id(), 1288);
383        assert_eq!(py_meta.default_right_context_id(), 1288);
384        assert_eq!(py_meta.default_field_value(), "*");
385        assert!(!py_meta.flexible_csv());
386        assert!(!py_meta.skip_invalid_cost_or_id());
387        assert!(!py_meta.normalize_details());
388        assert_eq!(py_meta.dictionary_schema().field_count(), 13);
389        assert_eq!(py_meta.user_dictionary_schema().fields().len(), 3);
390    }
391
392    #[test]
393    fn test_pymetadata_roundtrip() {
394        let py_meta = PyMetadata::new(
395            Some("roundtrip".to_string()),
396            Some("UTF-8".to_string()),
397            Some(-8000),
398            Some(500),
399            Some(600),
400            Some("?".to_string()),
401            Some(true),
402            Some(false),
403            Some(true),
404            None,
405            None,
406        );
407        let meta: Metadata = py_meta.into();
408        let roundtripped: PyMetadata = meta.into();
409        assert_eq!(roundtripped.name(), "roundtrip");
410        assert_eq!(roundtripped.encoding(), "UTF-8");
411        assert_eq!(roundtripped.default_word_cost(), -8000);
412        assert_eq!(roundtripped.default_left_context_id(), 500);
413        assert_eq!(roundtripped.default_right_context_id(), 600);
414        assert_eq!(roundtripped.default_field_value(), "?");
415        assert!(roundtripped.flexible_csv());
416        assert!(!roundtripped.skip_invalid_cost_or_id());
417        assert!(roundtripped.normalize_details());
418    }
419}