You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

templates.py 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. import cgi
  2. import mimetypes
  3. import os
  4. import posixpath
  5. import shutil
  6. import stat
  7. import tempfile
  8. from importlib import import_module
  9. from os import path
  10. from urllib.request import urlretrieve
  11. import django
  12. from django.conf import settings
  13. from django.core.management.base import BaseCommand, CommandError
  14. from django.core.management.utils import handle_extensions
  15. from django.template import Context, Engine
  16. from django.utils import archive
  17. from django.utils.version import get_docs_version
  18. class TemplateCommand(BaseCommand):
  19. """
  20. Copy either a Django application layout template or a Django project
  21. layout template into the specified directory.
  22. :param style: A color style object (see django.core.management.color).
  23. :param app_or_project: The string 'app' or 'project'.
  24. :param name: The name of the application or project.
  25. :param directory: The directory to which the template should be copied.
  26. :param options: The additional variables passed to project or app templates
  27. """
  28. requires_system_checks = False
  29. # The supported URL schemes
  30. url_schemes = ['http', 'https', 'ftp']
  31. # Rewrite the following suffixes when determining the target filename.
  32. rewrite_template_suffixes = (
  33. # Allow shipping invalid .py files without byte-compilation.
  34. ('.py-tpl', '.py'),
  35. )
  36. def add_arguments(self, parser):
  37. parser.add_argument('name', help='Name of the application or project.')
  38. parser.add_argument('directory', nargs='?', help='Optional destination directory')
  39. parser.add_argument('--template', help='The path or URL to load the template from.')
  40. parser.add_argument(
  41. '--extension', '-e', dest='extensions',
  42. action='append', default=['py'],
  43. help='The file extension(s) to render (default: "py"). '
  44. 'Separate multiple extensions with commas, or use '
  45. '-e multiple times.'
  46. )
  47. parser.add_argument(
  48. '--name', '-n', dest='files',
  49. action='append', default=[],
  50. help='The file name(s) to render. Separate multiple file names '
  51. 'with commas, or use -n multiple times.'
  52. )
  53. def handle(self, app_or_project, name, target=None, **options):
  54. self.app_or_project = app_or_project
  55. self.paths_to_remove = []
  56. self.verbosity = options['verbosity']
  57. self.validate_name(name, app_or_project)
  58. # if some directory is given, make sure it's nicely expanded
  59. if target is None:
  60. top_dir = path.join(os.getcwd(), name)
  61. try:
  62. os.makedirs(top_dir)
  63. except FileExistsError:
  64. raise CommandError("'%s' already exists" % top_dir)
  65. except OSError as e:
  66. raise CommandError(e)
  67. else:
  68. top_dir = os.path.abspath(path.expanduser(target))
  69. if not os.path.exists(top_dir):
  70. raise CommandError("Destination directory '%s' does not "
  71. "exist, please create it first." % top_dir)
  72. extensions = tuple(handle_extensions(options['extensions']))
  73. extra_files = []
  74. for file in options['files']:
  75. extra_files.extend(map(lambda x: x.strip(), file.split(',')))
  76. if self.verbosity >= 2:
  77. self.stdout.write("Rendering %s template files with "
  78. "extensions: %s\n" %
  79. (app_or_project, ', '.join(extensions)))
  80. self.stdout.write("Rendering %s template files with "
  81. "filenames: %s\n" %
  82. (app_or_project, ', '.join(extra_files)))
  83. base_name = '%s_name' % app_or_project
  84. base_subdir = '%s_template' % app_or_project
  85. base_directory = '%s_directory' % app_or_project
  86. camel_case_name = 'camel_case_%s_name' % app_or_project
  87. camel_case_value = ''.join(x for x in name.title() if x != '_')
  88. context = Context({
  89. **options,
  90. base_name: name,
  91. base_directory: top_dir,
  92. camel_case_name: camel_case_value,
  93. 'docs_version': get_docs_version(),
  94. 'django_version': django.__version__,
  95. }, autoescape=False)
  96. # Setup a stub settings environment for template rendering
  97. if not settings.configured:
  98. settings.configure()
  99. django.setup()
  100. template_dir = self.handle_template(options['template'],
  101. base_subdir)
  102. prefix_length = len(template_dir) + 1
  103. for root, dirs, files in os.walk(template_dir):
  104. path_rest = root[prefix_length:]
  105. relative_dir = path_rest.replace(base_name, name)
  106. if relative_dir:
  107. target_dir = path.join(top_dir, relative_dir)
  108. if not path.exists(target_dir):
  109. os.mkdir(target_dir)
  110. for dirname in dirs[:]:
  111. if dirname.startswith('.') or dirname == '__pycache__':
  112. dirs.remove(dirname)
  113. for filename in files:
  114. if filename.endswith(('.pyo', '.pyc', '.py.class')):
  115. # Ignore some files as they cause various breakages.
  116. continue
  117. old_path = path.join(root, filename)
  118. new_path = path.join(top_dir, relative_dir,
  119. filename.replace(base_name, name))
  120. for old_suffix, new_suffix in self.rewrite_template_suffixes:
  121. if new_path.endswith(old_suffix):
  122. new_path = new_path[:-len(old_suffix)] + new_suffix
  123. break # Only rewrite once
  124. if path.exists(new_path):
  125. raise CommandError("%s already exists, overlaying a "
  126. "project or app into an existing "
  127. "directory won't replace conflicting "
  128. "files" % new_path)
  129. # Only render the Python files, as we don't want to
  130. # accidentally render Django templates files
  131. if new_path.endswith(extensions) or filename in extra_files:
  132. with open(old_path, 'r', encoding='utf-8') as template_file:
  133. content = template_file.read()
  134. template = Engine().from_string(content)
  135. content = template.render(context)
  136. with open(new_path, 'w', encoding='utf-8') as new_file:
  137. new_file.write(content)
  138. else:
  139. shutil.copyfile(old_path, new_path)
  140. if self.verbosity >= 2:
  141. self.stdout.write("Creating %s\n" % new_path)
  142. try:
  143. shutil.copymode(old_path, new_path)
  144. self.make_writeable(new_path)
  145. except OSError:
  146. self.stderr.write(
  147. "Notice: Couldn't set permission bits on %s. You're "
  148. "probably using an uncommon filesystem setup. No "
  149. "problem." % new_path, self.style.NOTICE)
  150. if self.paths_to_remove:
  151. if self.verbosity >= 2:
  152. self.stdout.write("Cleaning up temporary files.\n")
  153. for path_to_remove in self.paths_to_remove:
  154. if path.isfile(path_to_remove):
  155. os.remove(path_to_remove)
  156. else:
  157. shutil.rmtree(path_to_remove)
  158. def handle_template(self, template, subdir):
  159. """
  160. Determine where the app or project templates are.
  161. Use django.__path__[0] as the default because the Django install
  162. directory isn't known.
  163. """
  164. if template is None:
  165. return path.join(django.__path__[0], 'conf', subdir)
  166. else:
  167. if template.startswith('file://'):
  168. template = template[7:]
  169. expanded_template = path.expanduser(template)
  170. expanded_template = path.normpath(expanded_template)
  171. if path.isdir(expanded_template):
  172. return expanded_template
  173. if self.is_url(template):
  174. # downloads the file and returns the path
  175. absolute_path = self.download(template)
  176. else:
  177. absolute_path = path.abspath(expanded_template)
  178. if path.exists(absolute_path):
  179. return self.extract(absolute_path)
  180. raise CommandError("couldn't handle %s template %s." %
  181. (self.app_or_project, template))
  182. def validate_name(self, name, app_or_project):
  183. a_or_an = 'an' if app_or_project == 'app' else 'a'
  184. if name is None:
  185. raise CommandError('you must provide {an} {app} name'.format(
  186. an=a_or_an,
  187. app=app_or_project,
  188. ))
  189. # Check it's a valid directory name.
  190. if not name.isidentifier():
  191. raise CommandError(
  192. "'{name}' is not a valid {app} name. Please make sure the "
  193. "name is a valid identifier.".format(
  194. name=name,
  195. app=app_or_project,
  196. )
  197. )
  198. # Check it cannot be imported.
  199. try:
  200. import_module(name)
  201. except ImportError:
  202. pass
  203. else:
  204. raise CommandError(
  205. "'{name}' conflicts with the name of an existing Python "
  206. "module and cannot be used as {an} {app} name. Please try "
  207. "another name.".format(
  208. name=name,
  209. an=a_or_an,
  210. app=app_or_project,
  211. )
  212. )
  213. def download(self, url):
  214. """
  215. Download the given URL and return the file name.
  216. """
  217. def cleanup_url(url):
  218. tmp = url.rstrip('/')
  219. filename = tmp.split('/')[-1]
  220. if url.endswith('/'):
  221. display_url = tmp + '/'
  222. else:
  223. display_url = url
  224. return filename, display_url
  225. prefix = 'django_%s_template_' % self.app_or_project
  226. tempdir = tempfile.mkdtemp(prefix=prefix, suffix='_download')
  227. self.paths_to_remove.append(tempdir)
  228. filename, display_url = cleanup_url(url)
  229. if self.verbosity >= 2:
  230. self.stdout.write("Downloading %s\n" % display_url)
  231. try:
  232. the_path, info = urlretrieve(url, path.join(tempdir, filename))
  233. except IOError as e:
  234. raise CommandError("couldn't download URL %s to %s: %s" %
  235. (url, filename, e))
  236. used_name = the_path.split('/')[-1]
  237. # Trying to get better name from response headers
  238. content_disposition = info.get('content-disposition')
  239. if content_disposition:
  240. _, params = cgi.parse_header(content_disposition)
  241. guessed_filename = params.get('filename') or used_name
  242. else:
  243. guessed_filename = used_name
  244. # Falling back to content type guessing
  245. ext = self.splitext(guessed_filename)[1]
  246. content_type = info.get('content-type')
  247. if not ext and content_type:
  248. ext = mimetypes.guess_extension(content_type)
  249. if ext:
  250. guessed_filename += ext
  251. # Move the temporary file to a filename that has better
  252. # chances of being recognized by the archive utils
  253. if used_name != guessed_filename:
  254. guessed_path = path.join(tempdir, guessed_filename)
  255. shutil.move(the_path, guessed_path)
  256. return guessed_path
  257. # Giving up
  258. return the_path
  259. def splitext(self, the_path):
  260. """
  261. Like os.path.splitext, but takes off .tar, too
  262. """
  263. base, ext = posixpath.splitext(the_path)
  264. if base.lower().endswith('.tar'):
  265. ext = base[-4:] + ext
  266. base = base[:-4]
  267. return base, ext
  268. def extract(self, filename):
  269. """
  270. Extract the given file to a temporarily and return
  271. the path of the directory with the extracted content.
  272. """
  273. prefix = 'django_%s_template_' % self.app_or_project
  274. tempdir = tempfile.mkdtemp(prefix=prefix, suffix='_extract')
  275. self.paths_to_remove.append(tempdir)
  276. if self.verbosity >= 2:
  277. self.stdout.write("Extracting %s\n" % filename)
  278. try:
  279. archive.extract(filename, tempdir)
  280. return tempdir
  281. except (archive.ArchiveException, IOError) as e:
  282. raise CommandError("couldn't extract file %s to %s: %s" %
  283. (filename, tempdir, e))
  284. def is_url(self, template):
  285. """Return True if the name looks like a URL."""
  286. if ':' not in template:
  287. return False
  288. scheme = template.split(':', 1)[0].lower()
  289. return scheme in self.url_schemes
  290. def make_writeable(self, filename):
  291. """
  292. Make sure that the file is writeable.
  293. Useful if our source is read-only.
  294. """
  295. if not os.access(filename, os.W_OK):
  296. st = os.stat(filename)
  297. new_permissions = stat.S_IMODE(st.st_mode) | stat.S_IWUSR
  298. os.chmod(filename, new_permissions)