templates.py 13 KB

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