bindings: the catalogue audit book-pipeline had its own copy of

bibclean is "catalogue records audited and repaired, with a reason for every
change", and book-pipeline's metadata module was a second implementation of
the same thing — ported from it in the first place. Twelve functions bound:
the audit, its severity, the two commonest faults, the three repairs that
need no lookup, ISBNs checked and found, and whether two titles name the same
work.

Checked against that Python across all 5,385 books in the library — every
title, every author, every ISBN, every filename, and four thousand title
pairs. Identical on all of it but one: `The Soul After Death: Contemporary
'After-Death' Experiences`, where the Python lowercased a word inside the
quotation marks and bibclean leaves it alone.
This commit is contained in:
funkpower
2026-09-20 15:22:06 -04:00
parent b28e272506
commit d1ed82d112
5 changed files with 221 additions and 2 deletions
+6
View File
@@ -84,6 +84,12 @@ except ValueError as why:
| `canonical_subject` | what a closed subject scheme calls a heading |
| `compose_subjects` | the subjects a book should carry under one |
| `needs_subject` | whether it carries a heading the scheme knows |
| `clean_record` | a whole catalogue record audited and repaired |
| `record_problems`, `record_severity` | what is wrong with one, and how much |
| `title_in_author`, `looks_like_person` | the two commonest catalogue faults |
| `is_shouting`, `title_case`, `clean_underscores` | the repairs that need no lookup |
| `valid_isbn`, `isbn_from_text`, `isbn_from_filename` | ISBNs, checked and found |
| `same_work` | whether two titles name the same book |
| `citation_prompt` | the question `citefind --prompt` writes |
| `merge_citation_reply` | what `citefind --merge` does with the answer |
+26 -2
View File
@@ -12,48 +12,72 @@ lines of JSON.
from ._catena import (
__version__,
cue_windows,
canonical_subject,
chunk_markdown,
citation_prompt,
clean_record,
clean_underscores,
compose_subjects,
count_tokens,
cue_windows,
find_citations,
fuse_hits,
identify,
is_shouting,
isbn_from_filename,
isbn_from_text,
looks_like_footnote_citations,
looks_like_person,
merge_citation_reply,
needs_subject,
pack_cues,
parse_identifier,
parse_subtitles,
record_problems,
record_severity,
same_work,
scrub,
segment_cues,
series_of,
split_collection,
suggest_author,
title_case,
title_in_author,
valid_isbn,
)
__all__ = [
"__version__",
"cue_windows",
"canonical_subject",
"chunk_markdown",
"citation_prompt",
"clean_record",
"clean_underscores",
"compose_subjects",
"count_tokens",
"cue_windows",
"find_citations",
"fuse_hits",
"identify",
"is_shouting",
"isbn_from_filename",
"isbn_from_text",
"looks_like_footnote_citations",
"looks_like_person",
"merge_citation_reply",
"needs_subject",
"pack_cues",
"parse_identifier",
"parse_subtitles",
"record_problems",
"record_severity",
"same_work",
"scrub",
"segment_cues",
"series_of",
"split_collection",
"suggest_author",
"title_case",
"title_in_author",
"valid_isbn",
]
@@ -61,3 +61,17 @@ def parse_identifier(text: str) -> dict[str, Any] | None: ...
def series_of(
title: str, author: str | None = ...
) -> dict[str, Any] | None: ...
def clean_record(book: Mapping[str, Any]) -> dict[str, Any]: ...
def record_problems(
title: str | None = ..., author: str | None = ..., isbn: str | None = ...
) -> list[str]: ...
def record_severity(reasons: Iterable[str]) -> int: ...
def title_in_author(title: str | None = ..., author: str | None = ...) -> bool: ...
def looks_like_person(author: str) -> bool: ...
def is_shouting(title: str) -> bool: ...
def title_case(title: str) -> str: ...
def clean_underscores(title: str) -> str: ...
def valid_isbn(text: str) -> bool: ...
def isbn_from_text(text: str) -> str | None: ...
def isbn_from_filename(name: str) -> str | None: ...
def same_work(ours: str, theirs: str) -> bool: ...
+88
View File
@@ -215,6 +215,71 @@ pub fn parse_identifier(text: &str) -> Option<idnorm::Identifier> {
idnorm::parse(text)
}
/// A catalogue record audited: what is wrong with it, and the repairs that
/// need no lookup.
pub fn clean_record(book: &bibclean::Book) -> bibclean::Cleaned {
bibclean::clean(book)
}
/// What is wrong with a record, named.
pub fn record_problems(
title: Option<&str>,
author: Option<&str>,
isbn: Option<&str>,
) -> Vec<String> {
bibclean::reasons::problems(title, author, isbn)
}
/// How much attention a set of reasons deserves, worst first.
pub fn record_severity(reasons: &[String]) -> u32 {
bibclean::reasons::severity(reasons)
}
/// Whether the title has been copied into the author field.
pub fn title_in_author(title: Option<&str>, author: Option<&str>) -> bool {
bibclean::reasons::title_in_author(title, author)
}
/// Whether an author field names a person rather than a publisher or a shelf.
pub fn looks_like_person(author: &str) -> bool {
bibclean::reasons::looks_like_person(author)
}
/// Whether a title is set in capitals throughout.
pub fn is_shouting(title: &str) -> bool {
bibclean::fix::is_shouting(title)
}
/// A title in title case, leaving the small words small.
pub fn title_case(title: &str) -> String {
bibclean::fix::title_case(title)
}
/// A title with the underscores a filename left in it taken out.
pub fn clean_underscores(title: &str) -> String {
bibclean::fix::clean_underscores(title)
}
/// Whether a string is a well-formed ISBN, check digit and all.
pub fn valid_isbn(text: &str) -> bool {
bibclean::isbn::valid(text)
}
/// An ISBN found in a run of text.
pub fn isbn_from_text(text: &str) -> Option<String> {
bibclean::isbn::from_text(text)
}
/// The same, for a filename, where the digits are often all there is.
pub fn isbn_from_filename(name: &str) -> Option<String> {
bibclean::isbn::from_filename(name)
}
/// Whether two titles name the same work.
pub fn same_work(ours: &str, theirs: &str) -> bool {
bibclean::matching::same_work(ours, theirs)
}
/// What a closed subject scheme says about a set of headings.
///
/// The scheme is supplied rather than built in — which subjects a collection
@@ -304,6 +369,29 @@ mod tests {
assert_eq!(count_tokens("a passage"), textkit::count("a passage"));
}
#[test]
fn the_record_surface_agrees_with_the_library_underneath() {
assert_eq!(
record_problems(Some("THE REPUBLIC"), Some("Plato"), None),
bibclean::reasons::problems(Some("THE REPUBLIC"), Some("Plato"), None)
);
assert_eq!(title_case("THE REPUBLIC"), "The Republic");
assert_eq!(clean_underscores("the_republic_pdf"), "the republic");
assert!(valid_isbn("9780306406157"));
assert_eq!(
isbn_from_filename("book-9780306406157.epub").as_deref(),
Some("9780306406157")
);
assert!(same_work(
"Homo Deus",
"Homo Deus: A Brief History of Tomorrow"
));
// Name *shape*, which is deliberately permissive — it is one signal
// among several in `record_problems`, not a verdict on its own.
assert!(looks_like_person("Copleston, Frederick"));
assert!(!looks_like_person(""));
}
#[test]
fn the_author_surface_agrees_with_the_library_underneath() {
const TITLE: &str = "Aquinas (Companion)";
+87
View File
@@ -220,6 +220,81 @@ fn identify<'py>(py: Python<'py>, name: &str, data: &[u8]) -> PyResult<Bound<'py
to_py(py, &crate::identify(name, data))
}
/// A catalogue record audited: what is wrong with it, and the repairs that
/// need no lookup.
#[pyfunction]
fn clean_record<'py>(py: Python<'py>, book: &Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
to_py(py, &crate::clean_record(&from_py(book)?))
}
/// What is wrong with a record, named.
#[pyfunction]
#[pyo3(signature = (title = None, author = None, isbn = None))]
fn record_problems(title: Option<&str>, author: Option<&str>, isbn: Option<&str>) -> Vec<String> {
crate::record_problems(title, author, isbn)
}
/// How much attention a set of reasons deserves, worst first.
#[pyfunction]
fn record_severity(reasons: Vec<String>) -> u32 {
crate::record_severity(&reasons)
}
/// Whether the title has been copied into the author field.
#[pyfunction]
#[pyo3(signature = (title = None, author = None))]
fn title_in_author(title: Option<&str>, author: Option<&str>) -> bool {
crate::title_in_author(title, author)
}
/// Whether an author field names a person rather than a publisher.
#[pyfunction]
fn looks_like_person(author: &str) -> bool {
crate::looks_like_person(author)
}
/// Whether a title is set in capitals throughout.
#[pyfunction]
fn is_shouting(title: &str) -> bool {
crate::is_shouting(title)
}
/// A title in title case, leaving the small words small.
#[pyfunction]
fn title_case(title: &str) -> String {
crate::title_case(title)
}
/// A title with the underscores a filename left in it taken out.
#[pyfunction]
fn clean_underscores(title: &str) -> String {
crate::clean_underscores(title)
}
/// Whether a string is a well-formed ISBN, check digit and all.
#[pyfunction]
fn valid_isbn(text: &str) -> bool {
crate::valid_isbn(text)
}
/// An ISBN found in a run of text.
#[pyfunction]
fn isbn_from_text(text: &str) -> Option<String> {
crate::isbn_from_text(text)
}
/// An ISBN found in a filename.
#[pyfunction]
fn isbn_from_filename(name: &str) -> Option<String> {
crate::isbn_from_filename(name)
}
/// Whether two titles name the same work.
#[pyfunction]
fn same_work(ours: &str, theirs: &str) -> bool {
crate::same_work(ours, theirs)
}
/// The subject a heading means under a scheme, or `None`.
#[pyfunction]
fn canonical_subject(scheme: &Bound<'_, PyAny>, name: &str) -> PyResult<Option<String>> {
@@ -338,6 +413,18 @@ fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_function(wrap_pyfunction!(looks_like_footnote_citations, module)?)?;
module.add_function(wrap_pyfunction!(suggest_author, module)?)?;
module.add_function(wrap_pyfunction!(needs_subject, module)?)?;
module.add_function(wrap_pyfunction!(clean_record, module)?)?;
module.add_function(wrap_pyfunction!(record_problems, module)?)?;
module.add_function(wrap_pyfunction!(record_severity, module)?)?;
module.add_function(wrap_pyfunction!(title_in_author, module)?)?;
module.add_function(wrap_pyfunction!(looks_like_person, module)?)?;
module.add_function(wrap_pyfunction!(is_shouting, module)?)?;
module.add_function(wrap_pyfunction!(title_case, module)?)?;
module.add_function(wrap_pyfunction!(clean_underscores, module)?)?;
module.add_function(wrap_pyfunction!(valid_isbn, module)?)?;
module.add_function(wrap_pyfunction!(isbn_from_text, module)?)?;
module.add_function(wrap_pyfunction!(isbn_from_filename, module)?)?;
module.add_function(wrap_pyfunction!(same_work, module)?)?;
module.add_function(wrap_pyfunction!(compose_subjects, module)?)?;
module.add_function(wrap_pyfunction!(canonical_subject, module)?)?;
module.add_function(wrap_pyfunction!(citation_prompt, module)?)?;