From f44a91902bb29eaae3518ac1f13d3d4e8b3e450b Mon Sep 17 00:00:00 2001 From: TobeTek Date: Fri, 9 Feb 2024 00:09:31 +0100 Subject: [PATCH] Refs #34976 -- Print directory structure after startapp and startproject commands Print the newly created directory structure after running the startapp and startproject commands. Refs #34976 Use `make_style` to style directories Support --no-color flag in get_directory_tree --- django/core/management/base.py | 2 + django/core/management/templates.py | 3 + django/core/management/utils.py | 55 ++++++++++++++ tests/admin_scripts/tests.py | 47 ++++++++++++ tests/user_commands/tests.py | 107 ++++++++++++++++++++++++++++ 5 files changed, 214 insertions(+) diff --git a/django/core/management/base.py b/django/core/management/base.py index 4c47e1c6e5..895703c134 100644 --- a/django/core/management/base.py +++ b/django/core/management/base.py @@ -271,6 +271,7 @@ class BaseCommand: def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False): self.stdout = OutputWrapper(stdout or sys.stdout) self.stderr = OutputWrapper(stderr or sys.stderr) + self.no_color = no_color if no_color and force_color: raise CommandError("'no_color' and 'force_color' can't be used together.") if no_color: @@ -442,6 +443,7 @@ class BaseCommand: if options["force_color"]: self.style = color_style(force_color=True) elif options["no_color"]: + self.no_color = options["no_color"] self.style = no_style() self.stderr.style_func = None if options.get("stdout"): diff --git a/django/core/management/templates.py b/django/core/management/templates.py index 633eed781d..aa3d7dc134 100644 --- a/django/core/management/templates.py +++ b/django/core/management/templates.py @@ -13,6 +13,7 @@ from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.core.management.utils import ( find_formatters, + get_directory_tree, handle_extensions, run_formatters, ) @@ -230,6 +231,8 @@ class TemplateCommand(BaseCommand): shutil.rmtree(path_to_remove) run_formatters([top_dir], **formatter_paths) + for subpath in get_directory_tree(top_dir, no_color=self.no_color): + self.stdout.write(subpath) def handle_template(self, template, subdir): """ diff --git a/django/core/management/utils.py b/django/core/management/utils.py index fca61f2c23..b9bcbf425d 100644 --- a/django/core/management/utils.py +++ b/django/core/management/utils.py @@ -6,6 +6,7 @@ from pathlib import Path from subprocess import run from django.apps import apps as installed_apps +from django.core.management.color import make_style, no_style, supports_color from django.utils.crypto import get_random_string from django.utils.encoding import DEFAULT_LOCALE_ENCODING @@ -173,3 +174,57 @@ def run_formatters(written_files, black_path=(sentinel := object())): [black_path, "--fast", "--", *written_files], capture_output=True, ) + + +def get_directory_tree(path, max_depth=10, prefix="", depth=0, **options): + """ + Get the folder structure of a directory and its content, + akin to the `tree` command. + Adapted from: https://stackoverflow.com/a/59109706 + + Example: + >>> for line in get_directory_tree(Path.home()): + ... print(line) + """ + SPACE_PREFIX = " " + BRANCH_PREFIX = "| " + ELBOW_PREFIX = "|___ " + + path = Path(path) + if (supports_color() and not options.get("no_color", False)) or ( + options.get("force_color", False) + ): + directory_style = make_style("notice=blue,bold;") + else: + directory_style = no_style() + + if depth == 0: + yield directory_style.NOTICE(path.name) + + if not path.is_dir(): + raise CommandError("'%s' is not a directory" % (path)) + + if depth >= max_depth: + return + + dir_contents = list(path.iterdir()) + + for index, subpath in enumerate(sorted(dir_contents)): + # Make folders appear bolder in terminal + subpath_name = ( + directory_style.NOTICE(subpath.name) if subpath.is_dir() else subpath.name + ) + + is_last_element = index == len(dir_contents) - 1 + + yield prefix + ELBOW_PREFIX + subpath_name + + if subpath.is_dir(): + extension = SPACE_PREFIX if is_last_element else BRANCH_PREFIX + yield from get_directory_tree( + subpath, + max_depth, + prefix=prefix + extension, + depth=depth + 1, + **options, + ) diff --git a/tests/admin_scripts/tests.py b/tests/admin_scripts/tests.py index 688aaa0a2f..c30d368014 100644 --- a/tests/admin_scripts/tests.py +++ b/tests/admin_scripts/tests.py @@ -2405,6 +2405,31 @@ class StartProject(LiveServerTestCase, AdminScriptTestCase): self.assertOutput(err, "usage:") self.assertOutput(err, "You must provide a project name.") + def test_output_directory_structure(self): + + dirs = { + "testproject": self.test_dir, + "testproject1": os.path.join(self.test_dir, "otherdirectory"), + } + for name_project, dir in dirs.items(): + with self.subTest(name_project=name_project, dir=dir): + args = ["startproject", name_project] + if not os.path.exists(dir): + os.mkdir(dir) + args.append(dir) + out, err = self.run_django_admin(args) + + # Confirm the directory structure is as expected + self.assertIn(name_project, out) + self.assertIn("|___ manage.py", out) + self.assertIn("|___ testproject", out) + self.assertIn(" |___ __init__.py", out) + self.assertIn(" |___ asgi.py", out) + self.assertIn(" |___ settings.py", out) + self.assertIn(" |___ urls.py", out) + self.assertIn(" |___ wsgi.py", out) + self.assertNoOutput(err) + def test_simple_project(self): "Make sure the startproject management command creates a project" args = ["startproject", "testproject"] @@ -2879,6 +2904,28 @@ class StartApp(AdminScriptTestCase): ) self.assertFalse(os.path.exists(testproject_dir)) + def test_output_directory_structure(self): + dirs = { + "foo": self.test_dir, + "bar": os.path.join(self.test_dir, "otherdir"), + } + for name_app, dir in dirs.items(): + with self.subTest(name_project=name_app, dir=dir): + args = ["startapp", name_app] + out, err = self.run_django_admin(args) + + # Confirm the directory structure is as expected + self.assertIn(name_app, out) + self.assertIn("|___ __init__.py", out) + self.assertIn("|___ admin.py", out) + self.assertIn("|___ apps.py", out) + self.assertIn("|___ migrations", out) + self.assertIn("| |___ __init__.py", out) + self.assertIn("|___ models.py", out) + self.assertIn("|___ tests.py", out) + self.assertIn("|___ views.py", out) + self.assertNoOutput(err) + def test_importable_name(self): """ startapp validates that app name doesn't clash with existing Python diff --git a/tests/user_commands/tests.py b/tests/user_commands/tests.py index 65e176620d..c91a1107cd 100644 --- a/tests/user_commands/tests.py +++ b/tests/user_commands/tests.py @@ -1,6 +1,8 @@ import os +import tempfile from argparse import ArgumentDefaultsHelpFormatter from io import StringIO +from pathlib import Path from unittest import mock from admin_scripts.tests import AdminScriptTestCase @@ -11,6 +13,7 @@ from django.core.checks import Tags from django.core.management import BaseCommand, CommandError, find_commands from django.core.management.utils import ( find_command, + get_directory_tree, get_random_secret_key, is_ignored_path, normalize_path_patterns, @@ -535,3 +538,107 @@ class UtilsTests(SimpleTestCase): def test_normalize_path_patterns_truncates_wildcard_base(self): expected = [os.path.normcase(p) for p in ["foo/bar", "bar/*/"]] self.assertEqual(normalize_path_patterns(["foo/bar/*", "bar/*/"]), expected) + + def test_get_directory_tree(self): + from django.utils.termcolors import colorize + + def format_dir(x): + return colorize(x, fg="blue", opts=["bold"]) + + with tempfile.TemporaryDirectory() as tmpdir: + subdir = Path(tmpdir) / "foo/bar/" + subdir.mkdir(exist_ok=True, parents=True) + files = [ + "file1.txt", + "file2.txt", + "foo/foo1.txt", + "foo/foo2.txt", + "foo/bar/bar1.txt", + "foo/bar/bar2.txt", + ] + [Path(tmpdir).joinpath(file).touch() for file in files] + + expected = [ + format_dir(Path(tmpdir).name), + "|___ file1.txt", + "|___ file2.txt", + f"|___ {format_dir('foo')}", + f" |___ {format_dir('bar')}", + " | |___ bar1.txt", + " | |___ bar2.txt", + " |___ foo1.txt", + " |___ foo2.txt", + ] + self.assertListEqual( + list(get_directory_tree(tmpdir, force_color=True)), expected + ) + + def test_get_directory_tree_no_color(self): + with tempfile.TemporaryDirectory() as tmpdir: + subdir = Path(tmpdir) / "foo/bar/" + subdir.mkdir(exist_ok=True, parents=True) + files = [ + "file1.txt", + "file2.txt", + "foo/foo1.txt", + "foo/foo2.txt", + "foo/bar/bar1.txt", + "foo/bar/bar2.txt", + ] + [Path(tmpdir).joinpath(file).touch() for file in files] + + expected = [ + Path(tmpdir).name, + "|___ file1.txt", + "|___ file2.txt", + "|___ foo", + " |___ bar", + " | |___ bar1.txt", + " | |___ bar2.txt", + " |___ foo1.txt", + " |___ foo2.txt", + ] + self.assertListEqual( + list(get_directory_tree(tmpdir, no_color=True)), expected + ) + + def test_get_directory_tree_max_depth(self): + from django.utils.termcolors import colorize + + def format_dir(x): + return colorize(x, fg="blue", opts=["bold"]) + + with tempfile.TemporaryDirectory() as tmpdir: + subdir = Path(tmpdir) / "foo/bar/" + subdir.mkdir(exist_ok=True, parents=True) + files = [ + "file1.txt", + "file2.txt", + "foo/foo1.txt", + "foo/foo2.txt", + "foo/bar/bar1.txt", + "foo/bar/bar2.txt", + ] + [Path(tmpdir).joinpath(file).touch() for file in files] + + expected = [ + format_dir(Path(tmpdir).name), + "|___ file1.txt", + "|___ file2.txt", + f"|___ {format_dir('foo')}", + f" |___ {format_dir('bar')}", + " |___ foo1.txt", + " |___ foo2.txt", + ] + self.assertListEqual( + list(get_directory_tree(tmpdir, max_depth=2, force_color=True)), + expected, + ) + + def test_get_directory_tree_fail_not_dir(self): + with tempfile.TemporaryDirectory() as tmpdir: + tmpfile = Path(tmpdir).joinpath("file.txt") + tmpfile.touch() + + with self.assertRaises(CommandError): + list(get_directory_tree(tmpfile.name))