Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ tensor = ["legume-numeric/tensor"]
ndarray = ["dep:ndarray", "legume-numeric/ndarray", "zarrs/ndarray", "zarrs/transpose"]
aux = []
# Full-screen terminal views (ratatui): the CLI's interactive sessions, and
# `data_beans::interactive` for downstream crates building their own.
tui = ["dep:ratatui"]
# `data_beans::interactive` for downstream crates building their own. The
# views run in legume-tui sessions, which a dropped connection leaves running.
tui = ["dep:ratatui", "dep:legume-tui"]
alg = ["aux"]
sim = ["alg"]
# Pulls in libhdf5 — opt-in so installs on hosts without HDF5 headers still
Expand Down Expand Up @@ -87,3 +88,10 @@ num_cpus = "1.16.0"
sysinfo = { version = "0.38", default-features = false, features = ["system"] }
statrs = { version = "0.18", default-features = false }
ratatui = { version = "0.30", default-features = false, features = ["crossterm"], optional = true }
# Detachable sessions for the views; no pictures, so no graphics.
legume-tui = { version = "0.3.2", optional = true, default-features = false, features = ["clap", "logs"] }

[dev-dependencies]
# The crate's harness, driving the views through pseudo-terminals in
# tests/detach_session.rs.
legume-tui = { version = "0.3.2", default-features = false, features = ["testing"] }
123 changes: 17 additions & 106 deletions src/aux/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
//! every existing caller (`data_beans::aux::logging::new_progress_bar`, senna,
//! graph-embedding-util) keeps working unchanged, and adds [`init_logger`],
//! which wraps `env_logger` in `indicatif_log_bridge` so `log` output renders
//! above the bars instead of corrupting them, and [`hold_logs`], which keeps
//! records back while a full-screen view owns the terminal.

use std::sync::Mutex;
//! above the bars instead of corrupting them. With the `tui` feature the
//! logger sits behind legume-tui's log gate, so a view's
//! `legume_tui::logs::hold` keeps records back while it owns the terminal.

pub use legume_numeric::matrix::progress::{new_progress_bar, new_spinner, MULTI_PROGRESS};

Expand All @@ -20,114 +19,26 @@ pub use legume_numeric::matrix::progress::{new_progress_bar, new_spinner, MULTI_
/// `legume_numeric::matrix::common_io::{VERBOSE,QUIET}_LOG_FILTER`; an external `RUST_LOG`
/// still overrides the default filter.
pub fn init_logger(verbose: bool) {
let default_filter = if verbose {
init_logger_with(if verbose {
legume_numeric::matrix::common_io::VERBOSE_LOG_FILTER
} else {
legume_numeric::matrix::common_io::QUIET_LOG_FILTER
};
});
}

/// [`init_logger`] with `default_filter` (`error`, `info`) where no
/// `RUST_LOG` is set. For the `data-beans` binary's own default; not part
/// of the library's surface.
#[doc(hidden)]
pub fn init_logger_with(default_filter: &str) {
let logger =
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_filter))
.build();
let max_level = logger.filter();
let wrapped = indicatif_log_bridge::LogWrapper::new(MULTI_PROGRESS.clone(), logger);
let _ =
log::set_boxed_logger(Box::new(Holding(wrapped))).map(|()| log::set_max_level(max_level));
}

/// Records held back while a full-screen view owns the terminal: level,
/// target and message. `None` when not holding.
static HELD: Mutex<Option<Vec<(log::Level, String, String)>>> = Mutex::new(None);

/// The logger [`init_logger`] installs: forwards records, or keeps them in
/// [`HELD`] while [`hold_logs`] is on.
struct Holding<L>(L);

impl<L: log::Log> log::Log for Holding<L> {
fn enabled(&self, metadata: &log::Metadata) -> bool {
self.0.enabled(metadata)
}

fn log(&self, record: &log::Record) {
if let Some(held) = HELD.lock().unwrap_or_else(|e| e.into_inner()).as_mut() {
if self.0.enabled(record.metadata()) {
let entry = (
record.level(),
record.target().into(),
record.args().to_string(),
);
held.push(entry);
}
return;
}
self.0.log(record);
}

fn flush(&self) {
self.0.flush();
}
}

/// Hold log records back (`true`) while a full-screen view owns the
/// terminal, where a printed line would scroll it and tear the picture; on
/// `false`, write what was held, in order. Only affects the logger that
/// [`init_logger`] installed.
pub fn hold_logs(on: bool) {
let held = {
let mut slot = HELD.lock().unwrap_or_else(|e| e.into_inner());
if on {
slot.get_or_insert_with(Vec::new);
return;
}
slot.take()
};
for (level, target, message) in held.into_iter().flatten() {
log::logger().log(
&log::Record::builder()
.level(level)
.target(&target)
.args(format_args!("{message}"))
.build(),
);
}
}

#[cfg(test)]
mod tests {
use super::*;
use log::Log;
use std::sync::atomic::{AtomicUsize, Ordering};

struct Counting(AtomicUsize);

impl Log for Counting {
fn enabled(&self, _: &log::Metadata) -> bool {
true
}
fn log(&self, _: &log::Record) {
self.0.fetch_add(1, Ordering::Relaxed);
}
fn flush(&self) {}
}

#[test]
fn held_records_wait_for_release() {
let logger = Holding(Counting(AtomicUsize::new(0)));
let record = |msg| {
logger.log(
&log::Record::builder()
.level(log::Level::Warn)
.args(format_args!("{msg}"))
.build(),
)
};
hold_logs(true);
record("held");
assert_eq!(logger.0 .0.load(Ordering::Relaxed), 0, "nothing written");
let held = HELD.lock().unwrap().as_ref().map(|h| h.len());
assert_eq!(held, Some(1));
hold_logs(false);
assert!(HELD.lock().unwrap().is_none(), "released");
record("through");
assert_eq!(logger.0 .0.load(Ordering::Relaxed), 1);
}
#[cfg(feature = "tui")]
let installed = log::set_logger(legume_tui::logs::gate(wrapped));
#[cfg(not(feature = "tui"))]
let installed = log::set_boxed_logger(Box::new(wrapped));
let _ = installed.map(|()| log::set_max_level(max_level));
}
51 changes: 32 additions & 19 deletions src/handlers/analysis.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use crate::handlers::explore::{open_data, side_stats, values_reader};
use crate::handlers::explore::{off_screen, open_data, open_session, side_stats, values_reader};
use crate::hdf5_io::*;
use crate::interactive::stat_tui::{explore, Purpose, Side, StatExplorer};
use crate::interactive::stat_tui::{explore_in, Purpose, Side, StatExplorer};
use crate::interactive::tui_available;
use crate::interactive::ui::Tui;
use crate::qc::*;
use crate::sparse_io::*;
use crate::sparse_io_vector::*;
Expand Down Expand Up @@ -115,6 +116,9 @@ pub struct RunStatArgs {
Not available with --column-group-file."
)]
pub interactive: bool,

#[command(flatten)]
pub session: legume_tui::cli::SessionArgs,
}

/// Compute statistics across sparse matrix data
Expand All @@ -123,6 +127,14 @@ pub struct RunStatArgs {
/// data files. Statistics can be stratified by column groups if a group membership
/// file is provided.
pub fn run_stat(cmd_args: &RunStatArgs) -> anyhow::Result<()> {
let grouped = cmd_args.stat_dim == Some(StatDim::Row) && cmd_args.column_group_file.is_some();
let explore_after = !grouped && wants_explorer(cmd_args);
// The explorer's session first: hosted, the statistics are computed
// (and written) in the session, not here.
let session = explore_after
.then(|| open_session("stat", &cmd_args.session))
.transpose()?;
let mut tui = session.as_ref().map(Tui::new);
let output = cmd_args.output.clone();
dirname(&output).as_deref().map(mkdir).transpose()?;

Expand Down Expand Up @@ -207,31 +219,32 @@ pub fn run_stat(cmd_args: &RunStatArgs) -> anyhow::Result<()> {
StatDim::Row => Side::Rows,
StatDim::Column => Side::Columns,
};
let explore_after = wants_explorer(cmd_args);
// The explorer replaces printing to stdout; a file output is still written.
let save = !(explore_after && cmd_args.output.eq_ignore_ascii_case("stdout"));
let select_rows = rows_matching(cmd_args, &data)?;
let select_rows = select_rows.as_deref();
let first = side_stats(
&data,
side,
select_rows,
cmd_args.block_size,
save.then_some(&*cmd_args.output),
)?;
if explore_after {
let output = save.then_some(&*cmd_args.output);
// A file written before the explorer is up is written off its screen,
// so a `kill` meanwhile lets the write finish.
let first = off_screen(tui.as_mut().filter(|_| output.is_some()), || {
side_stats(&data, side, select_rows, cmd_args.block_size, output)
})?;
if let Some(tui) = tui.as_mut() {
let title = cmd_args.data_files.join(", ");
let data = &data;
let loader =
Box::new(move |side| side_stats(data, side, select_rows, cmd_args.block_size, None));
explore(StatExplorer::new(
&title,
side,
first,
Some(loader),
Some(values_reader(data)),
Purpose::Explore,
))?;
explore_in(
tui,
StatExplorer::new(
&title,
side,
first,
Some(loader),
Some(values_reader(data)),
Purpose::Explore,
),
)?;
}
Ok(())
}
Expand Down
1 change: 1 addition & 0 deletions src/handlers/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub(super) fn run_squeeze_if_needed(
auto_cutoff: false,
output: None,
row_align: RowAlignMode::Common,
session: Default::default(),
};
run_squeeze(&squeeze_args)?;
}
Expand Down
51 changes: 40 additions & 11 deletions src/handlers/explore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@

use crate::hdf5_io::resolve_backend_file;
use crate::interactive::stat_tui::{
explore, Dataset, Picked, Purpose, Side, StatExplorer, Values, ValuesReader,
explore_in, Dataset, Picked, Purpose, Side, StatExplorer, Values, ValuesReader,
};
use crate::interactive::tui_available;
use crate::interactive::ui::Tui;
use crate::interactive::{tui_available, TOOL};
use crate::qc::*;
use crate::sparse_io::*;
use crate::sparse_io_vector::*;
Expand All @@ -15,6 +16,29 @@ use legume_numeric::matrix::traits::RunningStatOps;
use log::info;
use std::sync::Arc;

/// The session of interactive command `screen` (`data-beans-<screen>-N`),
/// opened before any of its work. Hosted, this process goes no further:
/// it relays the terminal and exits with the command, which runs again in
/// the session's host. Always on something named, so a new one starts.
pub fn open_session(
screen: &str,
args: &legume_tui::cli::SessionArgs,
) -> anyhow::Result<legume_tui::Session> {
Ok(TOOL.session(screen, &args.options(true))?)
}

/// Run `f`, a command's work after its views, off the screen of `tui` when
/// there is one: a `kill` meanwhile lets it finish.
pub fn off_screen<T>(
tui: Option<&mut Tui<'_>>,
f: impl FnOnce() -> anyhow::Result<T>,
) -> anyhow::Result<T> {
match tui {
Some(tui) => tui.work(f)?,
None => f(),
}
}

/// Open `files` as one matrix. Several files keep their column names
/// distinct by appending each file's name.
pub fn open_data(files: &[Box<str>], preload: bool) -> anyhow::Result<SparseIoVec> {
Expand Down Expand Up @@ -101,6 +125,7 @@ pub fn values_reader(data: &SparseIoVec) -> ValuesReader<'_> {
/// command labelled `verb`. Returns the marked indices (ascending), or
/// `None` when the user quits without finishing.
pub fn pick_entries(
tui: &mut Tui<'_>,
data_file: &str,
side: Side,
verb: &'static str,
Expand All @@ -118,7 +143,7 @@ pub fn pick_entries(
Some(values_reader(&data)),
Purpose::Pick { verb },
);
let picked = explore(explorer)?.map(|p| p.side(side));
let picked = explore_in(tui, explorer)?.map(|p| p.side(side));
match &picked {
Some(entries) => info!("picked {} entries", entries.len()),
None => info!("nothing picked; cancelled"),
Expand All @@ -131,6 +156,7 @@ pub fn pick_entries(
/// labelled `verb`. Returns the marks of both sides, or `None` when the user
/// quits without finishing.
pub fn pick_rows_and_columns(
tui: &mut Tui<'_>,
data_file: &str,
verb: &'static str,
) -> anyhow::Result<Option<Picked>> {
Expand All @@ -141,14 +167,17 @@ pub fn pick_rows_and_columns(
let first = side_stats(&data, Side::Columns, None, None, None)?;
let data = &data;
let loader = Box::new(move |side| side_stats(data, side, None, None, None));
let picked = explore(StatExplorer::new(
data_file,
Side::Columns,
first,
Some(loader),
Some(values_reader(data)),
Purpose::PickBoth { verb },
))?;
let picked = explore_in(
tui,
StatExplorer::new(
data_file,
Side::Columns,
first,
Some(loader),
Some(values_reader(data)),
Purpose::PickBoth { verb },
),
)?;
match &picked {
Some(p) => info!(
"picked {} rows and {} columns",
Expand Down
Loading
Loading