Skip to content

Commit 7d957ec

Browse files
authored
Improve the error messages for file I/O errors (#355)
This improves the error messages shown for file related I/O errors, by: - ensuring that the path is always included - adding additional suggestions to the error based on the context (which is now known, since specialised types are used for each of the common scenarios like checking if a file exists or reading a file to a string) We're able to make the errors richer than those provided by `fs-err`, since we know the context in which the errors have occurred. For example, in the error message for `ReadOptionalFileError`s we know not to say to check for a missing file or that the file is a directory, since `read_optional_file()` explicitly excludes those cases by design. If we used `io::Error` (or `fs-err`'s equivalent) we would have to rely on the user not using the wrong logging function for the wrong I/O error case, rather than relying on the type system to do that for us. These changes leave `log_io_error()` as being used only for the `Command` I/O cases. A later PR will switch adjust those usages too (but this PR is already large enough). Closes #270. GUS-W-12650236.
1 parent 833e7a7 commit 7d957ec

11 files changed

+278
-110
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
### Changed
1515

1616
- Improved the error messages shown when `.python-version` contains an invalid Python version or stray invisible characters (such as ASCII control codes). ([#353](https://github.com/heroku/buildpacks-python/pull/353) and [#354](https://github.com/heroku/buildpacks-python/pull/354))
17+
- Improved the error messages shown if I/O errors occur. ([#355](https://github.com/heroku/buildpacks-python/pull/355))
1718

1819
## [0.26.1] - 2025-04-08
1920

src/detect.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::io;
1+
use crate::utils::{self, FileExistsError};
22
use std::path::Path;
33

44
/// Filenames that if found in a project mean it should be treated as a Python project,
@@ -40,11 +40,10 @@ const KNOWN_PYTHON_PROJECT_FILES: [&str; 23] = [
4040

4141
/// Returns whether the specified project directory is that of a Python project, and so
4242
/// should pass buildpack detection.
43-
pub(crate) fn is_python_project_directory(app_dir: &Path) -> io::Result<bool> {
43+
pub(crate) fn is_python_project_directory(app_dir: &Path) -> Result<bool, FileExistsError> {
4444
// Until `Iterator::try_find` is stabilised, this is cleaner as a for loop.
4545
for filename in KNOWN_PYTHON_PROJECT_FILES {
46-
let path = app_dir.join(filename);
47-
if path.try_exists()? {
46+
if utils::file_exists(&app_dir.join(filename))? {
4847
return Ok(true);
4948
}
5049
}
@@ -71,7 +70,9 @@ mod tests {
7170

7271
#[test]
7372
fn is_python_project_directory_io_error() {
74-
assert!(is_python_project_directory(Path::new("tests/fixtures/empty/.gitkeep")).is_err());
73+
// We pass a path containing a NUL byte as an easy way to trigger an I/O error.
74+
let err = is_python_project_directory(Path::new("\0/invalid")).unwrap_err();
75+
assert_eq!(err.path, Path::new("\0/invalid/.python-version"));
7576
}
7677

7778
#[test]

src/django.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1+
use crate::FileExistsError;
12
use crate::utils::{self, CapturedCommandError, StreamedCommandError};
23
use indoc::indoc;
34
use libcnb::Env;
45
use libherokubuildpack::log::log_info;
5-
use std::io;
66
use std::path::Path;
77
use std::process::Command;
88

99
const MANAGEMENT_SCRIPT_NAME: &str = "manage.py";
1010

11-
pub(crate) fn is_django_installed(dependencies_layer_dir: &Path) -> io::Result<bool> {
12-
dependencies_layer_dir.join("bin/django-admin").try_exists()
11+
pub(crate) fn is_django_installed(dependencies_layer_dir: &Path) -> Result<bool, FileExistsError> {
12+
// The Django package includes a `django-admin` entrypoint script, which we can use
13+
// as a simple/fast way to check if Django is installed.
14+
utils::file_exists(&dependencies_layer_dir.join("bin/django-admin"))
1315
}
1416

1517
pub(crate) fn run_django_collectstatic(
@@ -54,8 +56,8 @@ pub(crate) fn run_django_collectstatic(
5456
.map_err(DjangoCollectstaticError::CollectstaticCommand)
5557
}
5658

57-
fn has_management_script(app_dir: &Path) -> io::Result<bool> {
58-
app_dir.join(MANAGEMENT_SCRIPT_NAME).try_exists()
59+
fn has_management_script(app_dir: &Path) -> Result<bool, FileExistsError> {
60+
utils::file_exists(&app_dir.join(MANAGEMENT_SCRIPT_NAME))
5961
}
6062

6163
fn has_collectstatic_command(app_dir: &Path, env: &Env) -> Result<bool, CapturedCommandError> {
@@ -87,7 +89,7 @@ fn has_collectstatic_command(app_dir: &Path, env: &Env) -> Result<bool, Captured
8789
#[derive(Debug)]
8890
pub(crate) enum DjangoCollectstaticError {
8991
CheckCollectstaticCommandExists(CapturedCommandError),
90-
CheckManagementScriptExists(io::Error),
92+
CheckManagementScriptExists(FileExistsError),
9193
CollectstaticCommand(StreamedCommandError),
9294
}
9395

@@ -110,6 +112,8 @@ mod tests {
110112

111113
#[test]
112114
fn has_management_script_io_error() {
113-
assert!(has_management_script(Path::new("tests/fixtures/empty/.gitkeep")).is_err());
115+
// We pass a path containing a NUL byte as an easy way to trigger an I/O error.
116+
let err = has_management_script(Path::new("\0/invalid")).unwrap_err();
117+
assert_eq!(err.path, Path::new("\0/invalid/manage.py"));
114118
}
115119
}

src/errors.rs

Lines changed: 84 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ use crate::python_version::{
1313
ResolvePythonVersionError,
1414
};
1515
use crate::python_version_file::ParsePythonVersionFileError;
16-
use crate::utils::{CapturedCommandError, DownloadUnpackArchiveError, StreamedCommandError};
16+
use crate::utils::{
17+
CapturedCommandError, DownloadUnpackArchiveError, FileExistsError, FindBundledPipError,
18+
ReadOptionalFileError, StreamedCommandError,
19+
};
1720
use indoc::{formatdoc, indoc};
1821
use libherokubuildpack::log::log_error;
1922
use std::io;
@@ -49,11 +52,12 @@ pub(crate) fn on_error(error: libcnb::Error<BuildpackError>) {
4952

5053
fn on_buildpack_error(error: BuildpackError) {
5154
match error {
52-
BuildpackError::BuildpackDetection(error) => on_buildpack_detection_error(&error),
55+
BuildpackError::BuildpackDetection(error) | BuildpackError::DjangoDetection(error) => {
56+
log_file_exists_error(error);
57+
}
5358
BuildpackError::Checks(error) => on_buildpack_checks_error(error),
5459
BuildpackError::DeterminePackageManager(error) => on_determine_package_manager_error(error),
5560
BuildpackError::DjangoCollectstatic(error) => on_django_collectstatic_error(error),
56-
BuildpackError::DjangoDetection(error) => on_django_detection_error(&error),
5761
BuildpackError::PipDependenciesLayer(error) => on_pip_dependencies_layer_error(error),
5862
BuildpackError::PipLayer(error) => on_pip_layer_error(error),
5963
BuildpackError::PoetryDependenciesLayer(error) => on_poetry_dependencies_layer_error(error),
@@ -64,14 +68,6 @@ fn on_buildpack_error(error: BuildpackError) {
6468
}
6569
}
6670

67-
fn on_buildpack_detection_error(error: &io::Error) {
68-
log_io_error(
69-
"Unable to complete buildpack detection",
70-
"determining if the Python buildpack should be run for this application",
71-
error,
72-
);
73-
}
74-
7571
fn on_buildpack_checks_error(error: ChecksError) {
7672
match error {
7773
ChecksError::ForbiddenEnvVar(name) => log_error(
@@ -89,11 +85,7 @@ fn on_buildpack_checks_error(error: ChecksError) {
8985

9086
fn on_determine_package_manager_error(error: DeterminePackageManagerError) {
9187
match error {
92-
DeterminePackageManagerError::CheckFileExists(io_error) => log_io_error(
93-
"Unable to determine the package manager",
94-
"determining which Python package manager to use for this project",
95-
&io_error,
96-
),
88+
DeterminePackageManagerError::CheckFileExists(error) => log_file_exists_error(error),
9789
DeterminePackageManagerError::MultipleFound(package_managers) => {
9890
let files_found = package_managers
9991
.into_iter()
@@ -141,16 +133,8 @@ fn on_determine_package_manager_error(error: DeterminePackageManagerError) {
141133

142134
fn on_requested_python_version_error(error: RequestedPythonVersionError) {
143135
match error {
144-
RequestedPythonVersionError::CheckRuntimeTxtExists(io_error) => log_io_error(
145-
"Unable to check for runtime.txt",
146-
"checking if a runtime.txt file exists",
147-
&io_error,
148-
),
149-
RequestedPythonVersionError::ReadPythonVersionFile(io_error) => log_io_error(
150-
"Unable to read .python-version",
151-
"reading the .python-version file",
152-
&io_error,
153-
),
136+
RequestedPythonVersionError::CheckRuntimeTxtExists(error) => log_file_exists_error(error),
137+
RequestedPythonVersionError::ReadPythonVersionFile(error) => log_read_file_error(error),
154138
RequestedPythonVersionError::ParsePythonVersionFile(error) => match error {
155139
ParsePythonVersionFileError::InvalidVersion(version) => log_error(
156140
"Invalid Python version in .python-version",
@@ -384,11 +368,7 @@ fn on_pip_layer_error(error: PipLayerError) {
384368
"},
385369
),
386370
},
387-
PipLayerError::LocateBundledPip(io_error) => log_io_error(
388-
"Unable to locate the bundled copy of pip",
389-
"locating the pip wheel file bundled inside the Python 'ensurepip' module",
390-
&io_error,
391-
),
371+
PipLayerError::LocateBundledPip(error) => log_find_bundled_pip_error(error),
392372
}
393373
}
394374

@@ -455,11 +435,7 @@ fn on_poetry_layer_error(error: PoetryLayerError) {
455435
"},
456436
),
457437
},
458-
PoetryLayerError::LocateBundledPip(io_error) => log_io_error(
459-
"Unable to locate the bundled copy of pip",
460-
"locating the pip wheel file bundled inside the Python 'ensurepip' module",
461-
&io_error,
462-
),
438+
PoetryLayerError::LocateBundledPip(error) => log_find_bundled_pip_error(error),
463439
}
464440
}
465441

@@ -501,14 +477,6 @@ fn on_poetry_dependencies_layer_error(error: PoetryDependenciesLayerError) {
501477
}
502478
}
503479

504-
fn on_django_detection_error(error: &io::Error) {
505-
log_io_error(
506-
"Unable to determine if this is a Django-based app",
507-
"checking if the 'django-admin' command exists",
508-
error,
509-
);
510-
}
511-
512480
fn on_django_collectstatic_error(error: DjangoCollectstaticError) {
513481
match error {
514482
DjangoCollectstaticError::CheckCollectstaticCommandExists(error) => match error {
@@ -537,11 +505,9 @@ fn on_django_collectstatic_error(error: DjangoCollectstaticError) {
537505
},
538506
),
539507
},
540-
DjangoCollectstaticError::CheckManagementScriptExists(io_error) => log_io_error(
541-
"Unable to inspect Django configuration",
542-
"checking if the 'manage.py' script exists",
543-
&io_error,
544-
),
508+
DjangoCollectstaticError::CheckManagementScriptExists(error) => {
509+
log_file_exists_error(error);
510+
}
545511
DjangoCollectstaticError::CollectstaticCommand(error) => match error {
546512
StreamedCommandError::Io(io_error) => log_io_error(
547513
"Unable to generate Django static files",
@@ -571,6 +537,8 @@ fn on_django_collectstatic_error(error: DjangoCollectstaticError) {
571537
}
572538
}
573539

540+
// This is now only used for Command I/O errors.
541+
// TODO: Replace this with specialised handling for Command I/O errors.
574542
fn log_io_error(header: &str, occurred_whilst: &str, io_error: &io::Error) {
575543
// We don't suggest opening a support ticket, since a subset of I/O errors can be caused
576544
// by issues in the application. In the future, perhaps we should try and split these out?
@@ -583,3 +551,70 @@ fn log_io_error(header: &str, occurred_whilst: &str, io_error: &io::Error) {
583551
"},
584552
);
585553
}
554+
555+
fn log_file_exists_error(FileExistsError { path, io_error }: FileExistsError) {
556+
let filepath = path.to_string_lossy();
557+
let filename = path
558+
.file_name()
559+
.unwrap_or(path.as_os_str())
560+
.to_string_lossy();
561+
562+
log_error(
563+
format!("Unable to check if {filename} exists"),
564+
formatdoc! {"
565+
An I/O error occurred while checking if this file exists:
566+
{filepath}
567+
568+
Details: {io_error}
569+
570+
Try building again to see if the error resolves itself.
571+
"},
572+
);
573+
}
574+
575+
fn log_read_file_error(ReadOptionalFileError { path, io_error }: ReadOptionalFileError) {
576+
let filepath = path.to_string_lossy();
577+
let filename = path
578+
.file_name()
579+
.unwrap_or(path.as_os_str())
580+
.to_string_lossy();
581+
582+
log_error(
583+
format!("Unable to read {filename}"),
584+
formatdoc! {"
585+
An I/O error occurred while reading the file:
586+
{filepath}
587+
588+
Details: {io_error}
589+
590+
Check the file's permissions and that it contains valid UTF-8.
591+
592+
Then try building again.
593+
"},
594+
);
595+
}
596+
597+
fn log_find_bundled_pip_error(
598+
FindBundledPipError {
599+
bundled_wheels_dir,
600+
io_error,
601+
}: FindBundledPipError,
602+
) {
603+
let bundled_wheels_dir = bundled_wheels_dir.to_string_lossy();
604+
605+
// TODO: Decide whether we want users to file a bug or open a support ticket.
606+
log_error(
607+
"Unable to locate the Python stdlib's bundled pip",
608+
formatdoc! {"
609+
Couldn't find the pip wheel file bundled inside the Python
610+
stdlib's `ensurepip` module, at:
611+
{bundled_wheels_dir}
612+
613+
Details: {io_error}
614+
615+
This is an internal error. Please report it as a bug, here:
616+
https://github.com/heroku/buildpacks-python/issues
617+
"
618+
},
619+
);
620+
}

src/layers/pip.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::packaging_tool_versions::PIP_VERSION;
22
use crate::python_version::PythonVersion;
3-
use crate::utils::StreamedCommandError;
3+
use crate::utils::{FindBundledPipError, StreamedCommandError};
44
use crate::{BuildpackError, PythonBuildpack, utils};
55
use libcnb::Env;
66
use libcnb::build::BuildContext;
@@ -11,7 +11,6 @@ use libcnb::layer::{
1111
use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope};
1212
use libherokubuildpack::log::log_info;
1313
use serde::{Deserialize, Serialize};
14-
use std::io;
1514
use std::path::Path;
1615
use std::process::Command;
1716

@@ -133,7 +132,7 @@ struct PipLayerMetadata {
133132
#[derive(Debug)]
134133
pub(crate) enum PipLayerError {
135134
InstallPipCommand(StreamedCommandError),
136-
LocateBundledPip(io::Error),
135+
LocateBundledPip(FindBundledPipError),
137136
}
138137

139138
impl From<PipLayerError> for libcnb::Error<BuildpackError> {

src/layers/poetry.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::packaging_tool_versions::POETRY_VERSION;
22
use crate::python_version::PythonVersion;
3-
use crate::utils::StreamedCommandError;
3+
use crate::utils::{FindBundledPipError, StreamedCommandError};
44
use crate::{BuildpackError, PythonBuildpack, utils};
55
use libcnb::Env;
66
use libcnb::build::BuildContext;
@@ -11,7 +11,6 @@ use libcnb::layer::{
1111
use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope};
1212
use libherokubuildpack::log::log_info;
1313
use serde::{Deserialize, Serialize};
14-
use std::io;
1514
use std::path::Path;
1615
use std::process::Command;
1716

@@ -134,7 +133,7 @@ struct PoetryLayerMetadata {
134133
#[derive(Debug)]
135134
pub(crate) enum PoetryLayerError {
136135
InstallPoetryCommand(StreamedCommandError),
137-
LocateBundledPip(io::Error),
136+
LocateBundledPip(FindBundledPipError),
138137
}
139138

140139
impl From<PoetryLayerError> for libcnb::Error<BuildpackError> {

src/main.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ use crate::python_version::{
2222
PythonVersionOrigin, RequestedPythonVersion, RequestedPythonVersionError,
2323
ResolvePythonVersionError,
2424
};
25+
use crate::utils::FileExistsError;
2526
use indoc::formatdoc;
2627
use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder};
2728
use libcnb::detect::{DetectContext, DetectResult, DetectResultBuilder};
2829
use libcnb::generic::{GenericMetadata, GenericPlatform};
2930
use libcnb::{Buildpack, Env, buildpack_main};
3031
use libherokubuildpack::log::{log_header, log_info, log_warning};
31-
use std::io;
3232

3333
struct PythonBuildpack;
3434

@@ -157,15 +157,15 @@ impl Buildpack for PythonBuildpack {
157157
#[derive(Debug)]
158158
pub(crate) enum BuildpackError {
159159
/// I/O errors when performing buildpack detection.
160-
BuildpackDetection(io::Error),
160+
BuildpackDetection(FileExistsError),
161161
/// Errors due to one of the environment checks failing.
162162
Checks(ChecksError),
163163
/// Errors determining which Python package manager to use for a project.
164164
DeterminePackageManager(DeterminePackageManagerError),
165165
/// Errors running the Django collectstatic command.
166166
DjangoCollectstatic(DjangoCollectstaticError),
167167
/// I/O errors when detecting whether Django is installed.
168-
DjangoDetection(io::Error),
168+
DjangoDetection(FileExistsError),
169169
/// Errors installing the project's dependencies into a layer using pip.
170170
PipDependenciesLayer(PipDependenciesLayerError),
171171
/// Errors installing pip into a layer.

0 commit comments

Comments
 (0)