use quarb::Value;
use quarb::library::{
CommandLibrary, InputSlot, LibCall, LibFailure, OperandSpec, OptionSpec, Pipe, Synopsis,
};
use quarb_pyvalue::PyGlue;
const GLUE: &str = r#"
import contextlib, sys
import pymorphy3
_analysers = {}
def _morph(lang):
lang = lang or "ru"
if lang not in _analysers:
_analysers[lang] = pymorphy3.MorphAnalyzer(lang=lang)
return _analysers[lang]
def _word(x):
if isinstance(x, dict):
x = x.get("text") or x.get("name") or ""
return "" if x is None else str(x)
def _best(word, lang):
parses = _morph(lang).parse(_word(word))
return parses[0] if parses else None
def lemma(word, lang="ru"):
p = _best(word, lang)
return None if p is None else p.normal_form
def tag(word, lang="ru"):
p = _best(word, lang)
return None if p is None else str(p.tag)
def pos(word, lang="ru"):
p = _best(word, lang)
return None if p is None else p.tag.POS
def parse(word, n=None, lang="ru"):
parses = _morph(lang).parse(_word(word))
if n is not None:
parses = parses[: int(n)]
return [
{
"lemma": p.normal_form,
"tag": str(p.tag),
"pos": p.tag.POS,
"score": round(p.score, 6),
}
for p in parses
]
_GRAMMEMES = ("case", "number", "gender", "person", "tense", "mood", "voice", "aspect", "animacy", "transitivity")
def inflect(word, lang="ru", **grammemes):
p = _best(word, lang)
if p is None:
return None
wanted = {str(v) for k, v in grammemes.items() if k in _GRAMMEMES and v is not None}
if not wanted:
return p.word
q = p.inflect(wanted)
return None if q is None else q.word
def _call(name, args, kwargs):
f = globals()[name]
with contextlib.redirect_stdout(sys.stderr):
if args and isinstance(args[0], list):
head, rest = args[0], args[1:]
return [f(item, *rest, **kwargs) for item in head]
return f(*args, **kwargs)
"#;
pub struct Morph;
const LANG: OptionSpec = OptionSpec {
short: Some("-l"),
long: "lang",
takes_value: true,
doc: "the dictionary: ru (the default), uk where its dictionaries are installed",
};
const WORD: OperandSpec = OperandSpec {
name: "word",
doc: "the word form (the input; a node by its text)",
values: &[],
required: true,
};
const GRAMMEME_OPTIONS: &[OptionSpec] = &[
LANG,
OptionSpec {
short: None,
long: "case",
takes_value: true,
doc: "nomn gent datv accs ablt loct voct gen2 acc2 loc2",
},
OptionSpec {
short: None,
long: "number",
takes_value: true,
doc: "sing plur",
},
OptionSpec {
short: None,
long: "gender",
takes_value: true,
doc: "masc femn neut",
},
OptionSpec {
short: None,
long: "person",
takes_value: true,
doc: "1per 2per 3per",
},
OptionSpec {
short: None,
long: "tense",
takes_value: true,
doc: "pres past futr",
},
OptionSpec {
short: None,
long: "mood",
takes_value: true,
doc: "indc impr",
},
OptionSpec {
short: None,
long: "voice",
takes_value: true,
doc: "actv pssv",
},
OptionSpec {
short: None,
long: "aspect",
takes_value: true,
doc: "perf impf",
},
OptionSpec {
short: None,
long: "animacy",
takes_value: true,
doc: "anim inan",
},
OptionSpec {
short: None,
long: "transitivity",
takes_value: true,
doc: "tran intr",
},
];
const COMMANDS: &[Synopsis] = &[
Synopsis {
name: "lemma",
doc: "the normal form of the word's most likely parse",
pipes: Pipe::Both,
operands: &[WORD],
options: &[LANG],
input: InputSlot::Operand(0),
returns: "string (null where the word has no parse)",
},
Synopsis {
name: "tag",
doc: "the grammemes of the most likely parse, as OpenCorpora writes them",
pipes: Pipe::Both,
operands: &[WORD],
options: &[LANG],
input: InputSlot::Operand(0),
returns: "string",
},
Synopsis {
name: "pos",
doc: "the part of speech of the most likely parse (NOUN, VERB, ADJF, …)",
pipes: Pipe::Both,
operands: &[WORD],
options: &[LANG],
input: InputSlot::Operand(0),
returns: "string",
},
Synopsis {
name: "parse",
doc: "every parse of the word, most likely first: lemma, tag, pos, score",
pipes: Pipe::Both,
operands: &[WORD],
options: &[
OptionSpec {
short: Some("-n"),
long: "n",
takes_value: true,
doc: "at most this many parses (all by default; -n=1 the best)",
},
LANG,
],
input: InputSlot::Operand(0),
returns: "list of records %(lemma; tag; pos; score)",
},
Synopsis {
name: "inflect",
doc: "the word's most likely parse inflected to the grammemes given",
pipes: Pipe::Both,
operands: &[WORD],
options: GRAMMEME_OPTIONS,
input: InputSlot::Operand(0),
returns: "string (null where no form exists)",
},
];
static PY: PyGlue = PyGlue::new(GLUE, "quarb_morph.py", "quarb_morph");
impl CommandLibrary for Morph {
fn prefix(&self) -> &str {
"morph"
}
fn commands(&self) -> &[Synopsis] {
COMMANDS
}
fn requirements(&self) -> &[&str] {
&["pymorphy3", "pymorphy3-dicts-ru"]
}
fn available(&self) -> Result<(), String> {
quarb_pyvalue::importable(
&["pymorphy3", "pymorphy3_dicts_ru"],
"pymorphy3 pymorphy3-dicts-ru",
)
}
fn call(&self, name: &str, call: LibCall) -> Result<Value, LibFailure> {
let Some(syn) = self.synopsis(name) else {
return Err(LibFailure::Unknown(format!(
"library 'morph' has no command '{name}'"
)));
};
PY.call(syn, name, call)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plain(name: &str, input: Value, options: Vec<(&str, Value)>) -> Value {
Morph
.call(
name,
LibCall {
agg: false,
input: Some(input),
operands: vec![],
options: options
.into_iter()
.map(|(k, v)| (k.to_string(), Some(v)))
.collect(),
},
)
.unwrap()
}
#[test]
fn lemma_tag_and_parse_read_a_russian_form() {
if Morph.available().is_err() {
eprintln!("pymorphy3 not installed; skipping");
return;
}
assert_eq!(
plain("lemma", Value::Str("книги".into()), vec![]),
Value::Str("книга".into())
);
let Value::Str(tag) = plain("tag", Value::Str("книги".into()), vec![]) else {
panic!("tag")
};
assert!(tag.starts_with("NOUN"), "{tag}");
let Value::List(parses) = plain("parse", Value::Str("стали".into()), vec![]) else {
panic!("parse")
};
assert!(parses.len() >= 2, "{parses:?}");
let best = plain(
"parse",
Value::Str("стали".into()),
vec![("n", Value::Int(1))],
);
assert_eq!(best.into_items().map(|v| v.len()), Some(1));
assert_eq!(
plain(
"inflect",
Value::Str("книга".into()),
vec![
("case", Value::Str("gent".into())),
("number", Value::Str("plur".into()))
]
),
Value::Str("книг".into())
);
}
#[test]
fn the_stream_maps_once_after_the_aggregation_pipe() {
if Morph.available().is_err() {
return;
}
let out = Morph
.call(
"lemma",
LibCall {
agg: true,
input: Some(Value::list(vec![
Value::Str("книги".into()),
Value::Str("стали".into()),
])),
operands: vec![],
options: vec![],
},
)
.unwrap();
assert_eq!(
out,
Value::list(vec![Value::Str("книга".into()), Value::Str("стать".into())])
);
}
}