diff --git a/django/core/management/templates.py b/django/core/management/templates.py index 8607ca85421..976442021e1 100644 --- a/django/core/management/templates.py +++ b/django/core/management/templates.py @@ -1,3 +1,4 @@ +import argparse import cgi import mimetypes import os @@ -54,6 +55,14 @@ class TemplateCommand(BaseCommand): help='The file name(s) to render. Separate multiple file names ' 'with commas, or use -n multiple times.' ) + parser.add_argument( + '--exclude', '-x', + action='append', default=argparse.SUPPRESS, nargs='?', const='', + help=( + 'The directory name(s) to exclude, in addition to .git and ' + '__pycache__. Can be used multiple times.' + ), + ) def handle(self, app_or_project, name, target=None, **options): self.app_or_project = app_or_project @@ -82,8 +91,12 @@ class TemplateCommand(BaseCommand): extensions = tuple(handle_extensions(options['extensions'])) extra_files = [] + excluded_directories = ['.git', '__pycache__'] for file in options['files']: extra_files.extend(map(lambda x: x.strip(), file.split(','))) + if exclude := options.get('exclude'): + for directory in exclude: + excluded_directories.append(directory.strip()) if self.verbosity >= 2: self.stdout.write( 'Rendering %s template files with extensions: %s' @@ -126,7 +139,10 @@ class TemplateCommand(BaseCommand): os.makedirs(target_dir, exist_ok=True) for dirname in dirs[:]: - if dirname.startswith('.') or dirname == '__pycache__': + if 'exclude' not in options: + if dirname.startswith('.') or dirname == '__pycache__': + dirs.remove(dirname) + elif dirname in excluded_directories: dirs.remove(dirname) for filename in files: diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 1c8268e1921..4d66a3b3aa1 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -1304,6 +1304,14 @@ Specifies which files in the app template (in addition to those matching ``--extension``) should be rendered with the template engine. Defaults to an empty list. +.. django-admin-option:: --exclude DIRECTORIES, -x DIRECTORIES + +.. versionadded:: 4.0 + +Specifies which directories in the app template should be excluded, in addition +to ``.git`` and ``__pycache__``. If this option is not provided, directories +named ``__pycache__`` or starting with ``.`` will be excluded. + The :class:`template context ` used for all matching files is: @@ -1373,6 +1381,14 @@ Specifies which files in the project template (in addition to those matching ``--extension``) should be rendered with the template engine. Defaults to an empty list. +.. django-admin-option:: --exclude DIRECTORIES, -x DIRECTORIES + +.. versionadded:: 4.0 + +Specifies which directories in the project template should be excluded, in +addition to ``.git`` and ``__pycache__``. If this option is not provided, +directories named ``__pycache__`` or starting with ``.`` will be excluded. + The :class:`template context ` used is: - Any option passed to the ``startproject`` command (among the command's diff --git a/docs/releases/4.0.txt b/docs/releases/4.0.txt index 5849b08cd43..e64f6633ef3 100644 --- a/docs/releases/4.0.txt +++ b/docs/releases/4.0.txt @@ -278,6 +278,9 @@ Management Commands ` attribute allows suppressing unsupported default command options in the help output. +* The new :option:`startapp --exclude` and :option:`startproject --exclude` + options allow excluding directories from the template. + Migrations ~~~~~~~~~~ diff --git a/tests/admin_scripts/tests.py b/tests/admin_scripts/tests.py index bed9c61b31a..9467bf32c32 100644 --- a/tests/admin_scripts/tests.py +++ b/tests/admin_scripts/tests.py @@ -2232,6 +2232,70 @@ class StartProject(LiveServerTestCase, AdminScriptTestCase): hidden_dir = os.path.join(testproject_dir, '.hidden') self.assertIs(os.path.exists(hidden_dir), False) + def test_custom_project_template_hidden_directory_included(self): + """ + Template context variables in hidden directories are rendered, if not + excluded. + """ + template_path = os.path.join(custom_templates_dir, 'project_template') + project_name = 'custom_project_template_hidden_directories_included' + args = [ + 'startproject', + '--template', + template_path, + project_name, + 'project_dir', + '--exclude', + ] + testproject_dir = os.path.join(self.test_dir, 'project_dir') + os.mkdir(testproject_dir) + + _, err = self.run_django_admin(args) + self.assertNoOutput(err) + render_py_path = os.path.join(testproject_dir, '.hidden', 'render.py') + with open(render_py_path) as fp: + self.assertIn( + f'# The {project_name} should be rendered.', + fp.read(), + ) + + def test_custom_project_template_exclude_directory(self): + """ + Excluded directories (in addition to .git and __pycache__) are not + included in the project. + """ + template_path = os.path.join(custom_templates_dir, 'project_template') + project_name = 'custom_project_with_excluded_directories' + args = [ + 'startproject', + '--template', + template_path, + project_name, + 'project_dir', + '--exclude', + 'additional_dir', + '-x', + '.hidden', + ] + testproject_dir = os.path.join(self.test_dir, 'project_dir') + os.mkdir(testproject_dir) + + _, err = self.run_django_admin(args) + self.assertNoOutput(err) + excluded_directories = [ + '.hidden', + 'additional_dir', + '.git', + '__pycache__', + ] + for directory in excluded_directories: + self.assertIs( + os.path.exists(os.path.join(testproject_dir, directory)), + False, + ) + not_excluded = os.path.join(testproject_dir, project_name) + self.assertIs(os.path.exists(not_excluded), True) + class StartApp(AdminScriptTestCase):