diff --git a/Cargo.lock b/Cargo.lock index 861decef..aa789ae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,31 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bon" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -52,6 +77,7 @@ dependencies = [ name = "craft-cli" version = "0.0.0" dependencies = [ + "bon", "console", "dirs", "indicatif", @@ -61,6 +87,40 @@ dependencies = [ "regex", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dirs" version = "6.0.0" @@ -105,6 +165,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indicatif" version = "0.18.3" @@ -255,6 +321,16 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -400,6 +476,12 @@ dependencies = [ "syn", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.114" diff --git a/Cargo.toml b/Cargo.toml index b077f3f1..b84cabb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = ["."] pyo3 = "0.27.2" [dependencies] +bon = "3.9.1" console = "0.16.2" dirs = "6.0.0" indicatif = { version = "0.18.0", features = ["improved_unicode"] } diff --git a/craft_cli/_rs/emitter.pyi b/craft_cli/_rs/emitter.pyi index 0c59096b..d3646d34 100644 --- a/craft_cli/_rs/emitter.pyi +++ b/craft_cli/_rs/emitter.pyi @@ -1,6 +1,8 @@ from enum import Enum from pathlib import Path +from craft_cli._rs.progress import Progresser + from .streams import StreamHandle class Verbosity(Enum): @@ -132,3 +134,34 @@ class Emitter: def clear_prefix(self) -> None: """Clear the current prefix.""" + + def progress_bar( + self, + text: str, + total: int, + *, + units: str | None = None, + show_eta: bool = False, + show_progress: bool = False, + show_percentage: bool = False, + ) -> Progresser: + """Render an incremental progress bar. + + This method must be used as a context manager. + + :param text: A brief message to prefix before the progress bar. + :param total: The total size of the progress bar. Must be a positive number. + :param units: Units to display to the left of the progress bar, like "X/Y + units". Implies `show_progress = True`. If set to None, the total count will + not be showed at all. If set to "bytes", the total will automatically be + adjusted to an appropriate magnitude (e.g., MiB -> GiB). All other values are + used as-is. Defaults to None. + :param show_eta: Whether or not to display an estimated ETA to the right of the + progress bar. + :param show_progress: Whether or not to show progress to the right of the progress + bar, like "X/Y". + :param show_percentage: Whether or not to display a percentage of completion to the + right of the progress bar. + + :return: A Progresser context manager. + """ diff --git a/craft_cli/_rs/progress.pyi b/craft_cli/_rs/progress.pyi new file mode 100644 index 00000000..5b40b9bb --- /dev/null +++ b/craft_cli/_rs/progress.pyi @@ -0,0 +1,31 @@ +from typing_extensions import Self + +class Progresser: + def __enter__(self) -> Self: ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + _traceback: object, + ) -> None: ... + def tick(self) -> None: + """Increase progress by one.""" + + def inc(self, delta: int) -> None: + """Increase progress by `delta`. + + Must be a positive number. + + :raises OverflowError: If a negative number is provided. + """ + + def println(self, text: str) -> None: + """Display a message alongside current progress. + + This method must be used instead of any emitting methods from an `Emitter`. + It will share verbosity level with the `Emitter` that spawned it, and all + messages will be permanent. + """ + + def progress(self) -> int: + """How much progress is complete so far.""" diff --git a/src/emitter.rs b/src/emitter.rs index df553f71..4f2092d7 100644 --- a/src/emitter.rs +++ b/src/emitter.rs @@ -9,7 +9,8 @@ use pyo3::{ use crate::{ logs::LogListener, - printer::{Message, MessageType, Target}, + printer::{Event, Target, Text}, + progress::Progresser, streams::StreamHandle, utils, }; @@ -153,12 +154,11 @@ impl Emitter { format!("Logging execution to {}", self.log_filepath), ]; for message in messages { - crate::printer::printer().send(Message { - text: message, - model: MessageType::Text, + crate::printer::printer().send(Event::Text(Text { + message, target: Some(Target::Stderr), permanent: true, - })?; + }))?; } } @@ -179,14 +179,13 @@ impl Emitter { }; let text = maybe_timestamped.to_string(); - let message = Message { - text, - model: MessageType::Text, + let event = Event::Text(Text { + message: text, target, permanent: true, - }; + }); - crate::printer::printer().send(message)?; + crate::printer::printer().send(event)?; Ok(()) } @@ -202,16 +201,15 @@ impl Emitter { Verbosity::Brief | Verbosity::Quiet | Verbosity::Verbose => None, _ => Some(Target::Stderr), }; - let text = timestamped.to_string(); + let message = timestamped.to_string(); - let message = Message { - text, - model: MessageType::Text, + let event = Event::Text(Text { + message, target, permanent: true, - }; + }); - crate::printer::printer().send(message)?; + crate::printer::printer().send(event)?; Ok(()) } @@ -227,16 +225,15 @@ impl Emitter { Verbosity::Trace => Some(Target::Stderr), _ => None, }; - let text = timestamped.to_string(); + let message = timestamped.to_string(); - let message = Message { - text, - model: MessageType::Text, + let event = Event::Text(Text { + message, target, permanent: true, - }; + }); - crate::printer::printer().send(message)?; + crate::printer::printer().send(event)?; Ok(()) } @@ -273,16 +270,15 @@ impl Emitter { } }; - let final_text = maybe_timestamped.to_owned(); + let message = maybe_timestamped.to_owned(); - let message = Message { - text: final_text, - model: MessageType::Text, + let event = Event::Text(Text { + message, target, permanent, - }; + }); - crate::printer::printer().send(message)?; + crate::printer::printer().send(event)?; // If we're in streaming brief mode and the last message was ephemeral, set this message as the new prefix if matches!(self.verbosity, Verbosity::Brief) && !permanent && self.streaming_brief { @@ -305,14 +301,13 @@ impl Emitter { _ => Some(Target::Stdout), }; - let message = Message { - text, - model: MessageType::Text, + let event = Event::Text(Text { + message: text, target, permanent: true, - }; + }); - crate::printer::printer().send(message)?; + crate::printer::printer().send(event)?; Ok(()) } @@ -330,23 +325,53 @@ impl Emitter { Verbosity::Debug | Verbosity::Trace => (timestamped.as_ref(), Some(Target::Stderr)), _ => (prefixed.as_str(), Some(Target::Stderr)), }; - let text = maybe_timestamped.to_string(); + let message = maybe_timestamped.to_string(); - let message = Message { - text, - model: MessageType::Text, + let event = Event::Text(Text { + message, target, permanent: true, - }; + }); - crate::printer::printer().send(message)?; + crate::printer::printer().send(event)?; Ok(()) } - #[expect(unused)] /// Render an incremental progress bar. - fn progress_bar(&mut self, text: &str, total: u64) -> PyResult<()> { - unimplemented!() + #[pyo3(signature = ( + text, + total, + *, + units = None, + show_eta = false, + show_progress = false, + show_percentage = false + ))] + fn progress_bar( + &mut self, + text: String, + total: u64, + units: Option, + show_eta: bool, + show_progress: bool, + show_percentage: bool, + ) -> PyResult { + let target = if self.verbosity != Verbosity::Quiet { + Some(Target::Stderr) + } else { + None + }; + + Progresser::builder() + .message(text) + .total(total) + .maybe_units(units) + .show_eta(show_eta) + .show_progress(show_progress) + .show_percentage(show_percentage) + .target(target) + .should_timestamp(self.verbosity >= Verbosity::Debug) + .build() } /// Stop gracefully. diff --git a/src/lib.rs b/src/lib.rs index 2072fa5e..e4f01b13 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ mod craft_cli_utils; mod emitter; mod logs; mod printer; +mod progress; mod streams; mod test_utils; mod utils; diff --git a/src/logs.rs b/src/logs.rs index 8e23faa5..acf059c4 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -9,7 +9,7 @@ use pyo3::{ use crate::{ emitter::Verbosity, - printer::{Message, Target}, + printer::{Event, Target, Text}, utils, }; @@ -40,21 +40,20 @@ impl LogListener { } // Call `record.getMessage()` from Python and parse it into a Rust string - let mut text: String = record.call_method0(intern!(py, "getMessage"))?.extract()?; + let mut message: String = record.call_method0(intern!(py, "getMessage"))?.extract()?; let permanent = self.verbosity >= Verbosity::Verbose; if permanent { - text = utils::apply_timestamp(&text).into(); + message = utils::apply_timestamp(&message).into(); } let target = self.decide_target(&levelno)?; - let message = Message { - text, + let event = Event::Log(Text { + message, target, - model: crate::printer::MessageType::Text, permanent, - }; + }); - crate::printer::printer().send(message)?; + crate::printer::printer().send(event)?; Ok(()) } } diff --git a/src/printer.rs b/src/printer.rs index bfe5670e..00dabfcd 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -4,7 +4,7 @@ use std::{ fs, io::Write as _, sync::{ - LazyLock, Mutex, MutexGuard, OnceLock, + Arc, LazyLock, Mutex, MutexGuard, OnceLock, mpsc::{self, RecvTimeoutError}, }, thread::{self, JoinHandle}, @@ -56,37 +56,77 @@ impl From for indicatif::ProgressDrawTarget { } } -/// Types of message for printing. -#[derive(Clone, Copy, Debug)] -pub enum MessageType { - /// Just plain text to display. - Text, +/// A simple text message. See [Event::Text] for usage details. +#[derive(Clone, Debug)] +pub struct Text { + /// The message to emit. + pub(crate) message: String, - // Pending implementation of incremental progress bars using indicatif - #[expect(unused)] - /// Signals to create a progress bar. - ProgBar(u64), + /// The destination for this message. + pub(crate) target: Option, + + /// Whether or not this message should be permanent. + /// + /// Setting this to true does not guarantee its permanence; verbosity + /// levels take precedence. The flag is only used when the permanence + /// of the message isn't enforced at a given verbosity level. + pub(crate) permanent: bool, } -/// A single message to be sent, and what type of message it is. +/// A new progress bar. See [Event::NewProgressBar] for usage details. #[derive(Clone, Debug)] -pub struct Message { - /// The message to be printed. - pub(crate) text: String, +pub struct NewProgressBar { + pub(crate) bar: Arc>, +} - // Pending implementation of incremental progress bars using indicatif - #[expect(unused)] - /// The type of message to send. - pub(crate) model: MessageType, +/// Types of message for printing. +#[derive(Clone, Debug)] +pub enum Event { + /// A simple text message. + /// + /// Text events will emit to the specified `target`, and will always be + /// sent to the log file. + Text(Text), - /// Where the message should be sent. - pub(crate) target: Option, + /// A streamed message from a [StreamHandle][crate::streams::StreamHandle]. + /// + /// Behaves identically to [Event::Text], except it will be silently converted + /// to [Event::PrintProgressBar] if a progress bar is active. + Stream(Text), - /// Whether or not this message should persist after the next message. + /// A logging record event. /// - /// Depending on the exact type of message that follows, a non-permanent - /// message may still remain. Namely, after an error. - pub(crate) permanent: bool, + /// Behaves identically to [Event::Text], except it will be silently converted + /// to [Event::PrintProgressBar] if a progress bar is active. + Log(Text), + + /// Create a new progress bar. + /// + /// Fails if any other progress bars are already running. + NewProgressBar(NewProgressBar), + + /// Update progress on the progress bar. + /// + /// Fails if there is no progress bar to update. + UpdateProgressBar(u64), + + /// Finish the progress bar. + /// + /// Fails if there is no progress bar to finish. + FinishProgressBar, + + /// Aborts the progress bar. + /// + /// This does not print the bar at 100% at the end and will not clear the last + /// instance of it in the terminal. Useful for error states. + /// + /// Fails if there is no progress bar to finish. + AbortProgressBar, + + /// Print a message above the progress bar. + /// + /// Fails if there is no progress bar. + PrintProgressBar(String), } /// An internal printer object meant to print from a separate thread. @@ -95,7 +135,7 @@ struct InnerPrinter { /// /// If this channel is found to be closed, the program is over and this struct /// should begin to destruct itself. - channel: mpsc::Receiver, + channel: mpsc::Receiver, /// A handle on stdout. stdout: console::Term, @@ -110,7 +150,7 @@ struct InnerPrinter { impl InnerPrinter { /// Instantiate a new `InnerPrinter`. - pub fn new(channel: mpsc::Receiver) -> Self { + pub fn new(channel: mpsc::Receiver) -> Self { let result = Self { stdout: console::Term::stdout(), stderr: console::Term::stderr(), @@ -133,26 +173,63 @@ impl InnerPrinter { let main_style = indicatif::ProgressStyle::with_template("{spinner} {msg} ({elapsed})").unwrap(); let mut spinner: Option = None; - let mut maybe_prv_msg: Option = None; + let mut maybe_prv_msg: Option = None; + let mut progress_bar: Option>> = None; loop { // Wait the standard 3 seconds for a message - match self.await_message(SPIN_TIMEOUT) { - Ok(msg) => { + match self.await_event(SPIN_TIMEOUT) { + Ok(event) => { // If we were spinning, stop if let Some(s) = spinner.take() && let Some(mut prv_msg) = maybe_prv_msg.take() { s.finish_and_clear(); let dur = indicatif::HumanDuration(s.elapsed()); - prv_msg.text = format!("{} (took {:#})", prv_msg.text, dur); + prv_msg.message = format!("{} (took {:#})", prv_msg.message, dur); self.needs_overwrite = false; self.handle_message(&prv_msg)?; } - // Store the most recently received message in case we need to - // begin displaying a spin loader - maybe_prv_msg = Some(msg.clone()); - self.handle_message(&msg)?; + match event { + Event::Text(text) | Event::Log(text) | Event::Stream(text) => { + // Store the most recently received message in case we need to + // begin displaying a spin loader + maybe_prv_msg = Some(text.clone()); + self.handle_message(&text)?; + } + Event::NewProgressBar(npb) => { + self.handle_overwrite()?; + _ = progress_bar.insert(npb.bar); + } + Event::UpdateProgressBar(delta) => match progress_bar { + None => unreachable!(), + Some(ref bar) => bar + .lock() + .expect("Unable to communicate with progress bar") + .inc(delta), + }, + Event::FinishProgressBar => match progress_bar { + None => unreachable!(), + Some(ref bar) => bar + .lock() + .expect("Unable to communicate with progress bar") + .finish(), + }, + Event::AbortProgressBar => match progress_bar { + None => unreachable!(), + Some(ref bar) => bar + .lock() + .expect("Unable to communicate with progress bar") + .abandon(), + }, + Event::PrintProgressBar(message) => match progress_bar { + None => unreachable!(), + Some(ref bar) => bar + .lock() + .expect("Unable to communicate with progress bar") + .println(message), + }, + } } // Break out of this loop if the channel is closed Err(RecvTimeoutError::Disconnected) => break, @@ -175,7 +252,7 @@ impl InnerPrinter { let new_spinner = indicatif::ProgressBar::with_draw_target(None, target) .with_style(main_style.clone()) - .with_message(msg.text.clone()) + .with_message(msg.message.clone()) .with_elapsed(SPIN_TIMEOUT); self.stdout.clear_last_lines(1).unwrap(); new_spinner.enable_steady_tick(Duration::from_millis(100)); @@ -188,13 +265,13 @@ impl InnerPrinter { } /// Helper method for receiving a message from `self.channel` - fn await_message(&self, timeout: Duration) -> ::std::result::Result { + fn await_event(&self, timeout: Duration) -> ::std::result::Result { self.channel.recv_timeout(timeout) } /// Routing method for sending a message to the proper printing logic for a given /// message type. - fn handle_message(&mut self, msg: &Message) -> PyResult<()> { + fn handle_message(&mut self, msg: &Text) -> PyResult<()> { let res = match msg.target { None => return Ok(()), Some(target) => match target { @@ -215,22 +292,22 @@ impl InnerPrinter { } /// Print a simple message to stdout. - fn print(&mut self, message: &Message) -> PyResult<()> { + fn print(&mut self, message: &Text) -> PyResult<()> { self.handle_overwrite()?; - self.stdout.write_line(&message.text)?; + self.stdout.write_line(&message.message)?; Ok(()) } /// Print a simple message to stderr. - fn error(&mut self, message: &Message) -> PyResult<()> { + fn error(&mut self, message: &Text) -> PyResult<()> { self.handle_overwrite()?; - self.stderr.write_line(&message.text)?; + self.stderr.write_line(&message.message)?; Ok(()) } #[expect(unused)] /// Handle an incremental progress bar. - fn progress_bar(&mut self, message: &Message) -> PyResult<()> { + fn progress_bar(&mut self, message: &Text) -> PyResult<()> { unimplemented!() } } @@ -262,13 +339,16 @@ pub struct Printer { handle: OnceLock>>, /// A channel to send messages to the `InnerPrinter` instance. - channel: OnceLock>, + channel: OnceLock>, /// A file handle to write to for logging operations. log_handle: Option, /// A prefix to prepend to every message. prefix: Option, + + /// Whether or not a progress bar is currently running. + in_progress: bool, } impl Printer { @@ -324,20 +404,103 @@ impl Printer { } /// Send a message to the `InnerPrinter` for displaying - pub fn send(&mut self, mut msg: Message) -> PyResult<()> { - self.log(&msg.text)?; - // Skip after logging if there's nowhere to even send it - if msg.target.is_none() { - return Ok(()); + pub fn send(&mut self, event: Event) -> PyResult<()> { + let prepared_event = self.prepare_event(event)?; + match prepared_event { + None => Ok(()), + Some(event) => match self.channel.get() { + Some(chan) => { + chan.send(event).unwrap(); + Ok(()) + } + None => panic!("Receiver closed early?"), + }, } - match self.channel.get() { - Some(chan) => { - self.apply_prefix(&mut msg); - chan.send(msg).unwrap() + } + + fn prepare_event(&mut self, mut event: Event) -> PyResult> { + match event { + Event::Text(ref mut text) => { + let should_emit = self.prepare_text(text)?; + + // If a progress bar is running, throw an error to use `println` instead. + if self.in_progress { + return Err(PyRuntimeError::new_err( + "Messages cannot be emitted normally when displaying a progress bar. Use `println` instead.", + )); + } + + match should_emit { + true => Ok(Some(event)), + false => Ok(None), + } + } + Event::NewProgressBar(_) => { + if self.in_progress { + return Err(PyRuntimeError::new_err( + "Attempted to replace an existing progress bar.", + )); + } + self.in_progress = true; + Ok(Some(event)) + } + Event::FinishProgressBar | Event::AbortProgressBar => { + if !self.in_progress { + return Err(PyRuntimeError::new_err( + "No progress bar available to update.", + )); + } + self.in_progress = false; + Ok(Some(event)) + } + Event::UpdateProgressBar(_) => { + if !self.in_progress { + return Err(PyRuntimeError::new_err( + "No progress bar available to update.", + )); + } + Ok(Some(event)) + } + Event::PrintProgressBar(ref text) => { + self.log(text)?; + if !self.in_progress { + return Err(PyRuntimeError::new_err( + "No progress bar available to update.", + )); + } + Ok(Some(event)) + } + Event::Stream(ref mut text) | Event::Log(ref mut text) => { + let should_emit = self.prepare_text(text)?; + + // Although normally a Text-based event should fail while a progress bar + // is active, Logs and Streams can't really be held to the same rules as + // they can happen outside of an Emitter user's control (e.g. an external + // library creating a log record). Therefore, they should be sent via + // ProgressBar::println(). However, skip any that wouldn't have been printed + // anyways to preserve verbosity rules. + if self.in_progress && should_emit { + let new_event = Event::PrintProgressBar(text.message.clone()); + return Ok(Some(new_event)); + } + + match should_emit { + true => Ok(Some(event)), + false => Ok(None), + } } - None => panic!("Receiver closed early?"), } - Ok(()) + } + + /// Prepare a text-based event and return if it should be emitted. + fn prepare_text(&mut self, text: &mut Text) -> PyResult { + self.log(&text.message)?; + // Skip after logging if there's nowhere to even send it + if text.target.is_none() { + return Ok(false); + } + self.apply_prefix(text); + Ok(true) } /// Initialize the logger, if wanted. @@ -382,10 +545,10 @@ impl Printer { } /// Apply the current prefix to a message, if any. - fn apply_prefix(&self, message: &mut Message) { + fn apply_prefix(&self, text: &mut Text) { if let Some(prefix) = &self.prefix { - let text = format!("{prefix} :: {}", message.text); - message.text = text; + let prefixed = format!("{prefix} :: {}", text.message); + text.message = prefixed; } } } diff --git a/src/progress.rs b/src/progress.rs new file mode 100644 index 00000000..24fd4ec4 --- /dev/null +++ b/src/progress.rs @@ -0,0 +1,124 @@ +use std::sync::{Arc, Mutex}; + +use bon::bon; +use pyo3::{Bound, PyAny, PyErr, PyRefMut, PyResult, pyclass, pymethods, types::PyType}; + +use crate::{ + printer::{Event, NewProgressBar, Target}, + utils, +}; + +#[pyclass] +pub struct Progresser { + bar: Arc>, + should_timestamp: bool, +} + +#[bon] +impl Progresser { + #[builder] + pub fn new( + message: String, + total: u64, + units: Option, + should_timestamp: bool, + #[builder(required)] target: Option, + #[builder(default)] show_eta: bool, + #[builder(default)] show_progress: bool, + #[builder(default)] show_percentage: bool, + ) -> PyResult { + let mut template_str = String::from("{msg} [{wide_bar}]"); + + if let Some(units) = units { + match units.as_str() { + "bytes" => template_str.push_str(" {bytes}/{total_bytes}"), + _ => { + let sanitized_units = units.replace("{", "{{").replace("}", "}}"); + template_str + .push_str(&format!(" {{human_pos}}/{{human_len}} {sanitized_units}")) + } + } + } else if show_progress { + template_str.push_str(" {human_pos}/{human_len}"); + } + + if show_percentage { + template_str.push_str(" {percent}%") + } + + if show_eta { + template_str.push_str(" ETA: {eta}") + } + + let style = indicatif::ProgressStyle::with_template(&template_str) + // This should only fail if the code above created a bad template string for indicatif. + // See https://docs.rs/indicatif/latest/indicatif/index.html#templates + .expect("An invalid progress bar was rendered."); + + let bar = Arc::new(Mutex::new( + indicatif::ProgressBar::with_draw_target( + Some(total), + target + .map(Target::into) + .unwrap_or(indicatif::ProgressDrawTarget::hidden()), + ) + .with_style(style) + .with_message(message), + )); + + crate::printer::printer().send(Event::NewProgressBar(NewProgressBar { + bar: Arc::clone(&bar), + }))?; + + Ok(Self { + bar, + should_timestamp, + }) + } +} + +#[pymethods] +impl Progresser { + pub fn tick(&self) -> PyResult<()> { + crate::printer::printer().send(Event::UpdateProgressBar(1)) + } + + pub fn inc(&self, delta: u64) -> PyResult<()> { + crate::printer::printer().send(Event::UpdateProgressBar(delta)) + } + + pub fn println(&self, mut text: String) -> PyResult<()> { + if self.should_timestamp { + text = utils::apply_timestamp(&text).to_string(); + } + crate::printer::printer().send(Event::PrintProgressBar(text)) + } + + pub fn progress(&self) -> u64 { + self.bar + .lock() + .expect("Failed to communicate with progress bar") + .position() + } + + #[pyo3(name = "__enter__")] + fn enter(slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> { + slf + } + + #[pyo3(name = "__exit__")] + fn exit( + &mut self, + exc_type: Option>, + exc_value: Option>, + _traceback: Option>, + ) -> PyResult<()> { + if let (Some(exc_type), Some(exc_value)) = (exc_type, exc_value) { + crate::printer::printer().send(Event::AbortProgressBar)?; + let err = PyErr::from_type(exc_type, exc_value.unbind()); + return Err(err); + } + + crate::printer::printer().send(Event::FinishProgressBar) + } +} diff --git a/src/streams.rs b/src/streams.rs index 4736ff3e..6d5966dd 100644 --- a/src/streams.rs +++ b/src/streams.rs @@ -17,7 +17,7 @@ use pyo3::{ use crate::{ emitter::Verbosity, - printer::{Message, MessageType, Target}, + printer::{Event, Target, Text}, utils, }; @@ -234,20 +234,19 @@ impl PipeListener { for part in parts { let parsed = String::from_utf8_lossy(part); - let text = match self.verbosity { + let message = match self.verbosity { Verbosity::Debug | Verbosity::Trace => utils::apply_timestamp(&parsed), _ => parsed, } .to_string(); - let message = Message { - text, - model: MessageType::Text, + let event = Event::Stream(Text { + message, target, permanent, - }; + }); - crate::printer::printer().send(message)?; + crate::printer::printer().send(event)?; } self.remaining_content = last.to_vec();