Skip to content
Open
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
7 changes: 7 additions & 0 deletions craft_cli/_rs/emitter.pyi
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from enum import Enum
from pathlib import Path

from craft_cli._rs.errors import CraftError
from craft_cli._rs.progress import Progresser

from .streams import StreamHandle
Expand Down Expand Up @@ -119,6 +120,12 @@ class Emitter:
can be provided via the ``prefix`` parameter.
"""

def error(self, error: CraftError) -> None:
"""Show an error and terminate this Emitter."""

def report_error(self, error: CraftError) -> None:
"""Show an error, but continue execution."""

def ended_ok(self) -> None:
"""Stop gracefully."""

Expand Down
46 changes: 46 additions & 0 deletions craft_cli/_rs/errors.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Base error type supported by an Emitter."""

class CraftError(BaseException):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are slightly reworked doc strings, and the reportable field was removed as the only time it was ever accessed in downstream code was to set it to False.

"""A program error to report with context."""

message: str
"""The main message to the user, to be shown as the first line of the error."""

details: str | None
"""Deeper, verbose error details that may help with diagnosing the deeper issue."""

docs_url: str | None
"""A URL to point the user towards for troubleshooting.

Supersedes `docs_slug`."""

docs_slug: str | None
"""A slug to append to user documentation.

This field is meant to be appended to a base URL provided by the method handling this error.

Is superseded by `docs_url`."""

show_logpath: bool
"""Whether to display the path to the log alongside this message.

It is generally recommended to leave this on, but some extremely simple error cases may
display better without the log path."""

retcode: int
"""The code to return when the application finishes.

This error class does not exit the program itself. Instead, this field is meant to be used
by a consumer inspecting the error message."""

def __init__(
self,
message: str,
*,
details: str | None = None,
resolution: str | None = None,
docs_url: str | None = None,
docs_slug: str | None = None,
show_logpath: bool = True,
retcode: int = 1,
) -> None: ...
79 changes: 4 additions & 75 deletions craft_cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,84 +16,13 @@

"""Error classes."""

__all__ = [
"CraftError",
]

from typing import Any, cast

from craft_cli._rs.errors import CraftError

class CraftError(Exception):
"""Signal a program error with a lot of information to report."""

message: str
"""The main message to the user, to be shown as first line (and probably only that,
according to the different modes); note that in some cases the log location will be
attached to this message."""

details: str | None
"""The full error details received from a third party which originated the error
situation."""

resolution: str | None
"""An extra line indicating to the user how the error may be fixed or avoided (to be
shown together with ``message``)."""

docs_url: str | None
"""An URL to point the user to documentation (to be shown together with ``message``)."""

doc_slug: str | None
"""The slug to the user documentation. Needs a base url to form a full address.
Note that ``docs_url`` has preference if it is set."""

logpath_report: bool
"""Whether the location of the log filepath should be presented in the screen as the
final message."""

reportable: bool
"""If an error report should be sent to some error-handling backend (like Sentry)."""

retcode: int
"""The code to return when the application finishes."""

def __init__(
self,
message: str,
*,
details: str | None = None,
resolution: str | None = None,
docs_url: str | None = None,
logpath_report: bool = True,
reportable: bool = True,
retcode: int = 1,
doc_slug: str | None = None,
) -> None:
super().__init__(message)
self.details = details
self.resolution = resolution
self.docs_url = docs_url
self.logpath_report = logpath_report
self.reportable = reportable
self.retcode = retcode
self.doc_slug = doc_slug
if doc_slug and not doc_slug.startswith("/"):
self.doc_slug = "/" + doc_slug

def __eq__(self, other: object) -> bool:
if isinstance(other, CraftError):
return all(
[
self.args == other.args,
self.details == other.details,
self.resolution == other.resolution,
self.docs_url == other.docs_url,
self.logpath_report == other.logpath_report,
self.reportable == other.reportable,
self.retcode == other.retcode,
self.doc_slug == other.doc_slug,
]
)
return NotImplemented
__all__ = [
"CraftError",
]


class CraftCommandError(CraftError):
Expand Down
57 changes: 54 additions & 3 deletions src/emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use pyo3::{
};

use crate::{
errors::CraftError,
logs::LogListener,
printer::{Event, Target, Text},
progress::Progresser,
Expand Down Expand Up @@ -57,9 +58,6 @@ struct Emitter {
/// The original filepath of the log file.
log_filepath: String,

// Used by `report_error` on the Python side, which was left in Python due to
// the retrieved errors all still being in Python.
#[expect(unused)]
/// The base URL for error messages.
docs_base_url: Option<String>,

Expand Down Expand Up @@ -337,6 +335,59 @@ impl Emitter {
Ok(())
}

fn error(&mut self, error: &Bound<'_, CraftError>) -> PyResult<()> {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

&Bound<'_, CraftError> is a Python object that is "bound" to the GIL. The type argument of CraftError asserts at runtime that the Python object is mappable to a CraftError. This is how to access types that were instantiated in Python therefore cannot become owned by Rust.

self.report_error(error)?;
// Stop the emitter
self.finish()
}

fn report_error(&mut self, error: &Bound<'_, CraftError>) -> PyResult<()> {
if self.streaming_brief {
self.clear_prefix();
}

// Build the message
let message = {
let error = error.borrow();
let mut message = error.message.clone();

if self.verbosity != Verbosity::Quiet
&& let Some(ref details) = error.details
{
message.push_str(&format!("\n{details}"));
}

if let Some(ref resolution) = error.resolution {
message.push_str(&format!("\nRecommended resolution: {resolution}"));
}

// Give priority to `docs_url` in the event that it and `docs_slug` are both set.
if let Some(ref docs_url) = error.docs_url {
message.push_str(&format!("\nFor more information, visit {docs_url}"));
} else if let Some(ref docs_base_url) = self.docs_base_url
&& let Some(ref doc_slug) = error.docs_slug
{
message.push_str(&format!(
"\nFor more information, visit {docs_base_url}{doc_slug}",
));
}

if error.show_logpath {
message.push_str(&format!("\nFull execution log: {}", self.log_filepath));
}

message
};

let event = Event::Text(Text {
message,
target: Some(Target::Stderr),
permanent: true,
});

crate::printer::printer().send(event)
}

/// Render an incremental progress bar.
#[pyo3(signature = (
text,
Expand Down
94 changes: 94 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
use pyo3::{
Bound, PyResult, PyTypeInfo as _,
exceptions::PyBaseException,
pyclass, pymethods, pymodule,
types::{PyAnyMethods, PyDict, PyString, PySuper, PyTypeMethods},
};

#[derive(PartialEq)]
#[pyclass(extends = PyBaseException, subclass, get_all, set_all, eq)]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads: "this class extends BaseException, is subclassable in Python, has getters & setters for each field, and is comparable with __eq__."

pub struct CraftError {
pub message: String,
pub details: Option<String>,
pub resolution: Option<String>,
pub docs_url: Option<String>,
pub docs_slug: Option<String>,
pub show_logpath: bool,
pub retcode: i8,
}

#[pymethods]
impl CraftError {
#[new]
#[pyo3(signature = (
message,
*,
details = None,
resolution = None,
docs_url = None,
docs_slug = None,
show_logpath = true,
retcode = 1
))]
fn new(
message: String,
details: Option<String>,
resolution: Option<String>,
docs_url: Option<String>,
docs_slug: Option<String>,
show_logpath: bool,
retcode: i8,
) -> Self {
Self {
message,
details,
resolution,
docs_url,
docs_slug,
show_logpath,
retcode,
}
}

#[pyo3(name = "__init__", signature = (_message, **_kwargs))]
fn init(
slf: &Bound<'_, Self>,
_message: &Bound<'_, PyString>,
_kwargs: Option<&Bound<'_, PyDict>>,
) -> PyResult<()> {
// See https://pyo3.rs/main/class.html#initializer for an explanation on why this method
// needs to exist.
Comment on lines +59 to +60
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TL;DR of this link is that because we extended a base Python type (PyBaseException) instead of a type we defined ourselves in Rust with #[pyclass], the __init__ method of the base class was fighting against us.

It seems like PyO3 was inserting the exact same arguments given to __new__ into BaseException.__init__(), which was failing since it takes no kwargs. This explicitly calls it with no arguments at all.

// Call "super(self.__class__, self).__init__()"
PySuper::new(&PyBaseException::type_object(slf.py()), slf)?.call_method0("__init__")?;
Ok(())
}

#[pyo3(name = "__repr__")]
fn repr(slf: &Bound<'_, Self>) -> PyResult<String> {
Ok(format!(
"{}({:?})",
slf.get_type().qualname()?,
slf.borrow().message
))
}

#[pyo3(name = "__str__")]
fn str(&self) -> String {
self.message.clone()
}
}

#[pymodule(submodule)]
pub mod errors {
use crate::utils::fix_imports;
use pyo3::types::PyModule;
use pyo3::{Bound, PyResult};

#[pymodule_export]
use super::CraftError;

#[pymodule_init]
fn init(m: &Bound<'_, PyModule>) -> PyResult<()> {
fix_imports(m, "craft_cli._rs.errors")
}
}
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use pyo3::pymodule;

mod craft_cli_utils;
mod emitter;
mod errors;
mod logs;
mod printer;
mod progress;
Expand All @@ -24,4 +25,7 @@ mod craft_cli_extensions {

#[pymodule_export]
use crate::logs::LogListener;

#[pymodule_export]
use crate::errors::errors;
}
10 changes: 10 additions & 0 deletions src/printer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,9 @@ pub struct Printer {

/// Whether or not a progress bar is currently running.
in_progress: bool,

/// Whether this printer has been stopped.
stopped: bool,
}

impl Printer {
Expand Down Expand Up @@ -400,11 +403,18 @@ impl Printer {
return Err(*e.downcast::<PyErr>().unwrap());
}

self.stopped = true;

Ok(())
}

/// Send a message to the `InnerPrinter` for displaying
pub fn send(&mut self, event: Event) -> PyResult<()> {
if self.stopped {
return Err(PyRuntimeError::new_err(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@medubelko look for this pattern of "return Err(" when reviewing these PRs, any instance of this in Rust has the potential to be shown to the user if not caught (and many aren't caught right now, heh).

But this is the only one in this PR anyways

"Attempted to print after the printer was stopped",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks fine, assuming there's a well-formed message for this (for PyRuntimeError?).

));
}
let prepared_event = self.prepare_event(event)?;
match prepared_event {
None => Ok(()),
Expand Down
Loading