411 lines
15 KiB
Python
411 lines
15 KiB
Python
import argparse
|
|
import mimetypes
|
|
import os
|
|
import posixpath
|
|
import shutil
|
|
import stat
|
|
import tempfile
|
|
from importlib import import_module
|
|
from urllib.request import build_opener
|
|
|
|
import django
|
|
from django.conf import settings
|
|
from django.core.management.base import BaseCommand, CommandError
|
|
from django.core.management.utils import (
|
|
find_formatters,
|
|
handle_extensions,
|
|
run_formatters,
|
|
)
|
|
from django.template import Context, Engine
|
|
from django.utils import archive
|
|
from django.utils.http import parse_header_parameters
|
|
from django.utils.version import get_docs_version
|
|
|
|
|
|
class TemplateCommand(BaseCommand):
|
|
"""
|
|
Copy either a Django application layout template or a Django project
|
|
layout template into the specified directory.
|
|
|
|
:param style: A color style object (see django.core.management.color).
|
|
:param app_or_project: The string 'app' or 'project'.
|
|
:param name: The name of the application or project.
|
|
:param directory: The directory to which the template should be copied.
|
|
:param options: The additional variables passed to project or app templates
|
|
"""
|
|
|
|
requires_system_checks = []
|
|
# The supported URL schemes
|
|
url_schemes = ["http", "https", "ftp"]
|
|
# Rewrite the following suffixes when determining the target filename.
|
|
rewrite_template_suffixes = (
|
|
# Allow shipping invalid .py files without byte-compilation.
|
|
(".py-tpl", ".py"),
|
|
)
|
|
|
|
def add_arguments(self, parser):
|
|
parser.add_argument("name", help="Name of the application or project.")
|
|
parser.add_argument(
|
|
"directory", nargs="?", help="Optional destination directory"
|
|
)
|
|
parser.add_argument(
|
|
"--template", help="The path or URL to load the template from."
|
|
)
|
|
parser.add_argument(
|
|
"--extension",
|
|
"-e",
|
|
dest="extensions",
|
|
action="append",
|
|
default=["py"],
|
|
help='The file extension(s) to render (default: "py"). '
|
|
"Separate multiple extensions with commas, or use "
|
|
"-e multiple times.",
|
|
)
|
|
parser.add_argument(
|
|
"--name",
|
|
"-n",
|
|
dest="files",
|
|
action="append",
|
|
default=[],
|
|
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.written_files = []
|
|
self.app_or_project = app_or_project
|
|
self.a_or_an = "an" if app_or_project == "app" else "a"
|
|
self.paths_to_remove = []
|
|
self.verbosity = options["verbosity"]
|
|
|
|
self.validate_name(name)
|
|
|
|
# if some directory is given, make sure it's nicely expanded
|
|
if target is None:
|
|
top_dir = os.path.join(os.getcwd(), name)
|
|
try:
|
|
os.makedirs(top_dir)
|
|
except FileExistsError:
|
|
raise CommandError("'%s' already exists" % top_dir)
|
|
except OSError as e:
|
|
raise CommandError(e)
|
|
else:
|
|
top_dir = os.path.abspath(os.path.expanduser(target))
|
|
if app_or_project == "app":
|
|
self.validate_name(os.path.basename(top_dir), "directory")
|
|
if not os.path.exists(top_dir):
|
|
raise CommandError(
|
|
"Destination directory '%s' does not "
|
|
"exist, please create it first." % top_dir
|
|
)
|
|
|
|
# Find formatters, which are external executables, before input
|
|
# from the templates can sneak into the path.
|
|
formatter_paths = find_formatters()
|
|
|
|
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"
|
|
% (app_or_project, ", ".join(extensions))
|
|
)
|
|
self.stdout.write(
|
|
"Rendering %s template files with filenames: %s"
|
|
% (app_or_project, ", ".join(extra_files))
|
|
)
|
|
base_name = "%s_name" % app_or_project
|
|
base_subdir = "%s_template" % app_or_project
|
|
base_directory = "%s_directory" % app_or_project
|
|
camel_case_name = "camel_case_%s_name" % app_or_project
|
|
camel_case_value = "".join(x for x in name.title() if x != "_")
|
|
|
|
context = Context(
|
|
{
|
|
**options,
|
|
base_name: name,
|
|
base_directory: top_dir,
|
|
camel_case_name: camel_case_value,
|
|
"docs_version": get_docs_version(),
|
|
"django_version": django.__version__,
|
|
},
|
|
autoescape=False,
|
|
)
|
|
|
|
# Setup a stub settings environment for template rendering
|
|
if not settings.configured:
|
|
settings.configure()
|
|
django.setup()
|
|
|
|
template_dir = self.handle_template(options["template"], base_subdir)
|
|
prefix_length = len(template_dir) + 1
|
|
|
|
for root, dirs, files in os.walk(template_dir):
|
|
|
|
path_rest = root[prefix_length:]
|
|
relative_dir = path_rest.replace(base_name, name)
|
|
if relative_dir:
|
|
target_dir = os.path.join(top_dir, relative_dir)
|
|
os.makedirs(target_dir, exist_ok=True)
|
|
|
|
for dirname in dirs[:]:
|
|
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:
|
|
if filename.endswith((".pyo", ".pyc", ".py.class")):
|
|
# Ignore some files as they cause various breakages.
|
|
continue
|
|
old_path = os.path.join(root, filename)
|
|
new_path = os.path.join(
|
|
top_dir, relative_dir, filename.replace(base_name, name)
|
|
)
|
|
for old_suffix, new_suffix in self.rewrite_template_suffixes:
|
|
if new_path.endswith(old_suffix):
|
|
new_path = new_path[: -len(old_suffix)] + new_suffix
|
|
break # Only rewrite once
|
|
|
|
if os.path.exists(new_path):
|
|
raise CommandError(
|
|
"%s already exists. Overlaying %s %s into an existing "
|
|
"directory won't replace conflicting files."
|
|
% (
|
|
new_path,
|
|
self.a_or_an,
|
|
app_or_project,
|
|
)
|
|
)
|
|
|
|
# Only render the Python files, as we don't want to
|
|
# accidentally render Django templates files
|
|
if new_path.endswith(extensions) or filename in extra_files:
|
|
with open(old_path, encoding="utf-8") as template_file:
|
|
content = template_file.read()
|
|
template = Engine().from_string(content)
|
|
content = template.render(context)
|
|
with open(new_path, "w", encoding="utf-8") as new_file:
|
|
new_file.write(content)
|
|
else:
|
|
shutil.copyfile(old_path, new_path)
|
|
|
|
self.written_files.append(new_path)
|
|
if self.verbosity >= 2:
|
|
self.stdout.write("Creating %s" % new_path)
|
|
try:
|
|
self.apply_umask(old_path, new_path)
|
|
self.make_writeable(new_path)
|
|
except OSError:
|
|
self.stderr.write(
|
|
"Notice: Couldn't set permission bits on %s. You're "
|
|
"probably using an uncommon filesystem setup. No "
|
|
"problem." % new_path,
|
|
self.style.NOTICE,
|
|
)
|
|
|
|
if self.paths_to_remove:
|
|
if self.verbosity >= 2:
|
|
self.stdout.write("Cleaning up temporary files.")
|
|
for path_to_remove in self.paths_to_remove:
|
|
if os.path.isfile(path_to_remove):
|
|
os.remove(path_to_remove)
|
|
else:
|
|
shutil.rmtree(path_to_remove)
|
|
|
|
run_formatters(self.written_files, **formatter_paths)
|
|
|
|
def handle_template(self, template, subdir):
|
|
"""
|
|
Determine where the app or project templates are.
|
|
Use django.__path__[0] as the default because the Django install
|
|
directory isn't known.
|
|
"""
|
|
if template is None:
|
|
return os.path.join(django.__path__[0], "conf", subdir)
|
|
else:
|
|
if template.startswith("file://"):
|
|
template = template[7:]
|
|
expanded_template = os.path.expanduser(template)
|
|
expanded_template = os.path.normpath(expanded_template)
|
|
if os.path.isdir(expanded_template):
|
|
return expanded_template
|
|
if self.is_url(template):
|
|
# downloads the file and returns the path
|
|
absolute_path = self.download(template)
|
|
else:
|
|
absolute_path = os.path.abspath(expanded_template)
|
|
if os.path.exists(absolute_path):
|
|
return self.extract(absolute_path)
|
|
|
|
raise CommandError(
|
|
"couldn't handle %s template %s." % (self.app_or_project, template)
|
|
)
|
|
|
|
def validate_name(self, name, name_or_dir="name"):
|
|
if name is None:
|
|
raise CommandError(
|
|
"you must provide {an} {app} name".format(
|
|
an=self.a_or_an,
|
|
app=self.app_or_project,
|
|
)
|
|
)
|
|
# Check it's a valid directory name.
|
|
if not name.isidentifier():
|
|
raise CommandError(
|
|
"'{name}' is not a valid {app} {type}. Please make sure the "
|
|
"{type} is a valid identifier.".format(
|
|
name=name,
|
|
app=self.app_or_project,
|
|
type=name_or_dir,
|
|
)
|
|
)
|
|
# Check it cannot be imported.
|
|
try:
|
|
import_module(name)
|
|
except ImportError:
|
|
pass
|
|
else:
|
|
raise CommandError(
|
|
"'{name}' conflicts with the name of an existing Python "
|
|
"module and cannot be used as {an} {app} {type}. Please try "
|
|
"another {type}.".format(
|
|
name=name,
|
|
an=self.a_or_an,
|
|
app=self.app_or_project,
|
|
type=name_or_dir,
|
|
)
|
|
)
|
|
|
|
def download(self, url):
|
|
"""
|
|
Download the given URL and return the file name.
|
|
"""
|
|
|
|
def cleanup_url(url):
|
|
tmp = url.rstrip("/")
|
|
filename = tmp.split("/")[-1]
|
|
if url.endswith("/"):
|
|
display_url = tmp + "/"
|
|
else:
|
|
display_url = url
|
|
return filename, display_url
|
|
|
|
prefix = "django_%s_template_" % self.app_or_project
|
|
tempdir = tempfile.mkdtemp(prefix=prefix, suffix="_download")
|
|
self.paths_to_remove.append(tempdir)
|
|
filename, display_url = cleanup_url(url)
|
|
|
|
if self.verbosity >= 2:
|
|
self.stdout.write("Downloading %s" % display_url)
|
|
|
|
the_path = os.path.join(tempdir, filename)
|
|
opener = build_opener()
|
|
opener.addheaders = [("User-Agent", f"Django/{django.__version__}")]
|
|
try:
|
|
with opener.open(url) as source, open(the_path, "wb") as target:
|
|
headers = source.info()
|
|
target.write(source.read())
|
|
except OSError as e:
|
|
raise CommandError(
|
|
"couldn't download URL %s to %s: %s" % (url, filename, e)
|
|
)
|
|
|
|
used_name = the_path.split("/")[-1]
|
|
|
|
# Trying to get better name from response headers
|
|
content_disposition = headers["content-disposition"]
|
|
if content_disposition:
|
|
_, params = parse_header_parameters(content_disposition)
|
|
guessed_filename = params.get("filename") or used_name
|
|
else:
|
|
guessed_filename = used_name
|
|
|
|
# Falling back to content type guessing
|
|
ext = self.splitext(guessed_filename)[1]
|
|
content_type = headers["content-type"]
|
|
if not ext and content_type:
|
|
ext = mimetypes.guess_extension(content_type)
|
|
if ext:
|
|
guessed_filename += ext
|
|
|
|
# Move the temporary file to a filename that has better
|
|
# chances of being recognized by the archive utils
|
|
if used_name != guessed_filename:
|
|
guessed_path = os.path.join(tempdir, guessed_filename)
|
|
shutil.move(the_path, guessed_path)
|
|
return guessed_path
|
|
|
|
# Giving up
|
|
return the_path
|
|
|
|
def splitext(self, the_path):
|
|
"""
|
|
Like os.path.splitext, but takes off .tar, too
|
|
"""
|
|
base, ext = posixpath.splitext(the_path)
|
|
if base.lower().endswith(".tar"):
|
|
ext = base[-4:] + ext
|
|
base = base[:-4]
|
|
return base, ext
|
|
|
|
def extract(self, filename):
|
|
"""
|
|
Extract the given file to a temporary directory and return
|
|
the path of the directory with the extracted content.
|
|
"""
|
|
prefix = "django_%s_template_" % self.app_or_project
|
|
tempdir = tempfile.mkdtemp(prefix=prefix, suffix="_extract")
|
|
self.paths_to_remove.append(tempdir)
|
|
if self.verbosity >= 2:
|
|
self.stdout.write("Extracting %s" % filename)
|
|
try:
|
|
archive.extract(filename, tempdir)
|
|
return tempdir
|
|
except (archive.ArchiveException, OSError) as e:
|
|
raise CommandError(
|
|
"couldn't extract file %s to %s: %s" % (filename, tempdir, e)
|
|
)
|
|
|
|
def is_url(self, template):
|
|
"""Return True if the name looks like a URL."""
|
|
if ":" not in template:
|
|
return False
|
|
scheme = template.split(":", 1)[0].lower()
|
|
return scheme in self.url_schemes
|
|
|
|
def apply_umask(self, old_path, new_path):
|
|
current_umask = os.umask(0)
|
|
os.umask(current_umask)
|
|
current_mode = stat.S_IMODE(os.stat(old_path).st_mode)
|
|
os.chmod(new_path, current_mode & ~current_umask)
|
|
|
|
def make_writeable(self, filename):
|
|
"""
|
|
Make sure that the file is writeable.
|
|
Useful if our source is read-only.
|
|
"""
|
|
if not os.access(filename, os.W_OK):
|
|
st = os.stat(filename)
|
|
new_permissions = stat.S_IMODE(st.st_mode) | stat.S_IWUSR
|
|
os.chmod(filename, new_permissions)
|