Skip to content

Commit 64d5c7c

Browse files
authored
Automatically run Django's collectstatic command (#108)
The classic Heroku Python buildpack automatically runs the Django `collectstatic` command: https://github.com/heroku/heroku-buildpack-python/blob/main/bin/steps/collectstatic This adds equivalent support, with a couple of improvements: - This implementation performs more checks to see whether the app is actually using the static files feature before trying to run it (reducing the number of cases where users would need to manually disable it). - The collectstatic symlink feature has been enabled, as requested in heroku/heroku-buildpack-python#1060. - Symlinked `manage.py` files are now supported, as requested in heroku/heroku-buildpack-python#972. - The error messages are finer grained/more useful. - There are many more tests (including now testing legacy vs latest Django versions, to check the CLI arguments used work for both ends of the spectrum). There is currently no way to force disable the feature (beyond removing `django.contrib.staticfiles` from `INSTALLED_APPS` in the app's Django config, or removing the `manage.py` script). Supporting this depends on us deciding how best to handle buildpack options, so will be added later, in #109. The build log output and error messages are fairly reasonable already (and a significant improvement over the classic buildpack), and will be further polished as part of the future build output overhaul. The implementation uses the new `utils::run_command_and_capture_output` added in #106. See: * https://docs.djangoproject.com/en/4.2/howto/static-files/ * https://docs.djangoproject.com/en/4.2/ref/contrib/staticfiles/ * https://docs.djangoproject.com/en/4.2/ref/settings/#settings-staticfiles Fixes #5. GUS-W-9538294.
1 parent 20d51b1 commit 64d5c7c

File tree

33 files changed

+548
-3
lines changed

33 files changed

+548
-3
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Django's `collectstatic` command is now automatically run for Django apps that use static files. ([#108](https://github.com/heroku/buildpacks-python/pull/108))
13+
1014
## [0.6.0] - 2023-08-25
1115

1216
### Changed

src/django.rs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
use crate::utils::{self, CapturedCommandError, StreamedCommandError};
2+
use indoc::indoc;
3+
use libcnb::Env;
4+
use libherokubuildpack::log::log_info;
5+
use std::io;
6+
use std::path::Path;
7+
use std::process::Command;
8+
9+
const MANAGEMENT_SCRIPT_NAME: &str = "manage.py";
10+
11+
pub(crate) fn is_django_installed(dependencies_layer_dir: &Path) -> io::Result<bool> {
12+
dependencies_layer_dir.join("bin/django-admin").try_exists()
13+
}
14+
15+
pub(crate) fn run_django_collectstatic(
16+
app_dir: &Path,
17+
command_env: &Env,
18+
) -> Result<(), DjangoCollectstaticError> {
19+
if !has_management_script(app_dir)
20+
.map_err(DjangoCollectstaticError::CheckManagementScriptExists)?
21+
{
22+
log_info(indoc! {"
23+
Skipping automatic static file generation since no Django 'manage.py'
24+
script (or symlink to one) was found in the root directory of your
25+
application."
26+
});
27+
return Ok(());
28+
}
29+
30+
if !has_collectstatic_command(app_dir, command_env)
31+
.map_err(DjangoCollectstaticError::CheckCollectstaticCommandExists)?
32+
{
33+
log_info(indoc! {"
34+
Skipping automatic static file generation since the 'django.contrib.staticfiles'
35+
feature is not enabled in your app's Django configuration."
36+
});
37+
return Ok(());
38+
}
39+
40+
log_info("Running 'manage.py collectstatic'");
41+
utils::run_command_and_stream_output(
42+
Command::new("python")
43+
.args([
44+
MANAGEMENT_SCRIPT_NAME,
45+
"collectstatic",
46+
"--link",
47+
// Using `--noinput` instead of `--no-input` since the latter requires Django 1.9+.
48+
"--noinput",
49+
])
50+
.current_dir(app_dir)
51+
.env_clear()
52+
.envs(command_env),
53+
)
54+
.map_err(DjangoCollectstaticError::CollectstaticCommand)
55+
}
56+
57+
fn has_management_script(app_dir: &Path) -> io::Result<bool> {
58+
app_dir.join(MANAGEMENT_SCRIPT_NAME).try_exists()
59+
}
60+
61+
fn has_collectstatic_command(
62+
app_dir: &Path,
63+
command_env: &Env,
64+
) -> Result<bool, CapturedCommandError> {
65+
utils::run_command_and_capture_output(
66+
Command::new("python")
67+
.args([MANAGEMENT_SCRIPT_NAME, "help", "collectstatic"])
68+
.current_dir(app_dir)
69+
.env_clear()
70+
.envs(command_env),
71+
)
72+
.map_or_else(
73+
|error| match error {
74+
// We need to differentiate between the command not existing (due to the staticfiles app
75+
// not being installed) and the Django config or mange.py script being broken. Ideally
76+
// we'd inspect the output of `manage.py help --commands` but that command unhelpfully
77+
// exits zero even if the app's `DJANGO_SETTINGS_MODULE` wasn't a valid module.
78+
CapturedCommandError::NonZeroExitStatus(output)
79+
if String::from_utf8_lossy(&output.stderr).contains("Unknown command") =>
80+
{
81+
Ok(false)
82+
}
83+
_ => Err(error),
84+
},
85+
|_| Ok(true),
86+
)
87+
}
88+
89+
/// Errors that can occur when running the Django collectstatic command.
90+
#[derive(Debug)]
91+
pub(crate) enum DjangoCollectstaticError {
92+
CheckCollectstaticCommandExists(CapturedCommandError),
93+
CheckManagementScriptExists(io::Error),
94+
CollectstaticCommand(StreamedCommandError),
95+
}
96+
97+
#[cfg(test)]
98+
mod tests {
99+
use super::*;
100+
101+
#[test]
102+
fn has_management_script_django_project() {
103+
assert!(has_management_script(Path::new(
104+
"tests/fixtures/django_staticfiles_latest_django"
105+
))
106+
.unwrap());
107+
}
108+
109+
#[test]
110+
fn has_management_script_empty() {
111+
assert!(!has_management_script(Path::new("tests/fixtures/empty")).unwrap());
112+
}
113+
114+
#[test]
115+
fn has_management_script_io_error() {
116+
assert!(has_management_script(Path::new("tests/fixtures/empty/.gitkeep")).is_err());
117+
}
118+
}

src/errors.rs

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
use crate::django::DjangoCollectstaticError;
12
use crate::layers::pip_dependencies::PipDependenciesLayerError;
23
use crate::layers::python::PythonLayerError;
34
use crate::package_manager::DeterminePackageManagerError;
45
use crate::python_version::{PythonVersion, PythonVersionError, DEFAULT_PYTHON_VERSION};
56
use crate::runtime_txt::{ParseRuntimeTxtError, RuntimeTxtError};
6-
use crate::utils::{DownloadUnpackArchiveError, StreamedCommandError};
7+
use crate::utils::{CapturedCommandError, DownloadUnpackArchiveError, StreamedCommandError};
78
use crate::BuildpackError;
89
use indoc::{formatdoc, indoc};
910
use libherokubuildpack::log::log_error;
@@ -46,6 +47,8 @@ fn on_buildpack_error(error: BuildpackError) {
4647
&io_error,
4748
),
4849
BuildpackError::DeterminePackageManager(error) => on_determine_package_manager_error(error),
50+
BuildpackError::DjangoCollectstatic(error) => on_django_collectstatic_error(error),
51+
BuildpackError::DjangoDetection(error) => on_django_detection_error(&error),
4952
BuildpackError::PipDependenciesLayer(error) => on_pip_dependencies_layer_error(error),
5053
BuildpackError::PythonLayer(error) => on_python_layer_error(error),
5154
BuildpackError::PythonVersion(error) => on_python_version_error(error),
@@ -217,6 +220,76 @@ fn on_pip_dependencies_layer_error(error: PipDependenciesLayerError) {
217220
};
218221
}
219222

223+
fn on_django_detection_error(error: &io::Error) {
224+
log_io_error(
225+
"Unable to determine if this is a Django-based app",
226+
"checking if the 'django-admin' command exists",
227+
error,
228+
);
229+
}
230+
231+
fn on_django_collectstatic_error(error: DjangoCollectstaticError) {
232+
match error {
233+
DjangoCollectstaticError::CheckCollectstaticCommandExists(error) => match error {
234+
CapturedCommandError::Io(io_error) => log_io_error(
235+
"Unable to inspect Django configuration",
236+
"running 'python manage.py help collectstatic' to inspect the Django configuration",
237+
&io_error,
238+
),
239+
CapturedCommandError::NonZeroExitStatus(output) => log_error(
240+
"Unable to inspect Django configuration",
241+
formatdoc! {"
242+
The 'python manage.py help collectstatic' Django management command
243+
(used to check whether Django's static files feature is enabled)
244+
failed ({exit_status}).
245+
246+
Details:
247+
248+
{stderr}
249+
250+
This indicates there is a problem with your application code or Django
251+
configuration. Try running the 'manage.py' script locally to see if the
252+
same error occurs.
253+
",
254+
exit_status = &output.status,
255+
stderr = String::from_utf8_lossy(&output.stderr)
256+
},
257+
),
258+
},
259+
DjangoCollectstaticError::CheckManagementScriptExists(io_error) => log_io_error(
260+
"Unable to inspect Django configuration",
261+
"checking if the 'manage.py' script exists",
262+
&io_error,
263+
),
264+
DjangoCollectstaticError::CollectstaticCommand(error) => match error {
265+
StreamedCommandError::Io(io_error) => log_io_error(
266+
"Unable to generate Django static files",
267+
"running 'python manage.py collectstatic' to generate Django static files",
268+
&io_error,
269+
),
270+
StreamedCommandError::NonZeroExitStatus(exit_status) => log_error(
271+
"Unable to generate Django static files",
272+
formatdoc! {"
273+
The 'python manage.py collectstatic --link --noinput' Django management
274+
command to generate static files failed ({exit_status}).
275+
276+
This is most likely due an issue in your application code or Django
277+
configuration. See the log output above for more information.
278+
279+
If you are using the WhiteNoise package to optimize the serving of static
280+
files with Django (recommended), check that your app is using the Django
281+
config options shown here:
282+
https://whitenoise.readthedocs.io/en/stable/django.html
283+
284+
Or, if you do not need to use static files in your app, disable the
285+
Django static files feature by removing 'django.contrib.staticfiles'
286+
from 'INSTALLED_APPS' in your app's Django configuration.
287+
"},
288+
),
289+
},
290+
};
291+
}
292+
220293
fn log_io_error(header: &str, occurred_whilst: &str, io_error: &io::Error) {
221294
// We don't suggest opening a support ticket, since a subset of I/O errors can be caused
222295
// by issues in the application. In the future, perhaps we should try and split these out?

src/main.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#![allow(clippy::large_enum_variant)]
66
#![allow(clippy::result_large_err)]
77

8+
mod django;
89
mod errors;
910
mod layers;
1011
mod package_manager;
@@ -13,6 +14,7 @@ mod python_version;
1314
mod runtime_txt;
1415
mod utils;
1516

17+
use crate::django::DjangoCollectstaticError;
1618
use crate::layers::pip_cache::PipCacheLayer;
1719
use crate::layers::pip_dependencies::{PipDependenciesLayer, PipDependenciesLayerError};
1820
use crate::layers::python::{PythonLayer, PythonLayerError};
@@ -80,7 +82,7 @@ impl Buildpack for PythonBuildpack {
8082

8183
// Create the layers for the application dependencies and package manager cache.
8284
// In the future support will be added for package managers other than pip.
83-
match package_manager {
85+
let (dependencies_layer_dir, dependencies_layer_env) = match package_manager {
8486
PackageManager::Pip => {
8587
log_header("Installing dependencies using Pip");
8688
let pip_cache_layer = context.handle_layer(
@@ -97,9 +99,18 @@ impl Buildpack for PythonBuildpack {
9799
pip_cache_dir: pip_cache_layer.path,
98100
},
99101
)?;
100-
pip_layer.env
102+
(pip_layer.path, pip_layer.env)
101103
}
102104
};
105+
command_env = dependencies_layer_env.apply(Scope::Build, &command_env);
106+
107+
if django::is_django_installed(&dependencies_layer_dir)
108+
.map_err(BuildpackError::DjangoDetection)?
109+
{
110+
log_header("Generating Django static files");
111+
django::run_django_collectstatic(&context.app_dir, &command_env)
112+
.map_err(BuildpackError::DjangoCollectstatic)?;
113+
}
103114

104115
BuildResultBuilder::new().build()
105116
}
@@ -115,6 +126,10 @@ pub(crate) enum BuildpackError {
115126
DetectIo(io::Error),
116127
/// Errors determining which Python package manager to use for a project.
117128
DeterminePackageManager(DeterminePackageManagerError),
129+
/// Errors running the Django collectstatic command.
130+
DjangoCollectstatic(DjangoCollectstaticError),
131+
/// IO errors when detecting whether Django is installed.
132+
DjangoDetection(io::Error),
118133
/// Errors installing the project's dependencies into a layer using Pip.
119134
PipDependenciesLayer(PipDependenciesLayerError),
120135
/// Errors installing Python and required packaging tools into a layer.

0 commit comments

Comments
 (0)