templates.py 13 KB

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