PageRenderTime 27ms CodeModel.GetById 24ms RepoModel.GetById 0ms app.codeStats 0ms

/cspace/hgsite/site.py

https://bitbucket.org/rblank/hgsite
Python | 409 lines | 381 code | 19 blank | 9 comment | 25 complexity | 1ea953b8fc2c1df3be46c4bc9bac33ba MD5 | raw file
Possible License(s): GPL-3.0
  1. """\
  2. 'site' web command handling
  3. Copyright (C) 2012 Remy Blank
  4. """
  5. # This file is part of HgSite.
  6. #
  7. # This program is free software; you can redistribute it and/or modify it
  8. # under the terms of the GNU General Public License as published by the
  9. # Free Software Foundation, version 3. A copy of the license is provided
  10. # in the file COPYING.
  11. #
  12. # This program is distributed in the hope that it will be useful, but
  13. # WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
  15. # Public License for more details.
  16. import re
  17. import sys
  18. import time
  19. from urlparse import urlunparse
  20. from mercurial import config, error, revset, util
  21. from mercurial.hgweb import common, webcommands, webutil
  22. from .util import exists_in_wc, find_line_no, get_ext, guess_mimetype, \
  23. hex_node, href_builder, http_date
  24. # TODO: Hook into hgweb.__call__ instead of using a web command
  25. # TODO: Fix unicode issues (try accented chars in URL)
  26. # TODO: Hook into hgwebdir and allow replacing the repository index
  27. # TODO: Allow specifying caching by path regexp
  28. # The web command name used for registration
  29. webcmd = 'hgsite'
  30. # The prefix marking that the path starts with a node
  31. node_prefix = '~'
  32. # Exceptions that should result in a 404
  33. not_found_exceptions = (error.LookupError, error.RepoLookupError)
  34. class Config(config.config):
  35. """Per-repository configuration"""
  36. def __init__(self, req):
  37. super(Config, self).__init__()
  38. self.parse('.hgsite', req.ctx['.hgsite'].data())
  39. @util.propertycache
  40. def base_path(self):
  41. """Return the base path for site files."""
  42. return self.get('config', 'base_path', 'site').strip().strip('/')
  43. @util.propertycache
  44. def error_page(self):
  45. """Return the path to the error page."""
  46. return self.get('config', 'error_page', '').strip().lstrip('/')
  47. @util.propertycache
  48. def ext_search_order(self):
  49. """Return the extension search order."""
  50. value = self.get('config', 'ext_search_path', '')
  51. return dict((ext, i) for i, ext in
  52. enumerate(ext.strip() for ext in value.split(',')))
  53. @util.propertycache
  54. def site_index(self):
  55. """Return the entry point for the site."""
  56. return self.get('config', 'site_index', 'index').strip()
  57. @util.propertycache
  58. def render_info(self):
  59. """Get all rendering information."""
  60. patterns = {}
  61. config = {}
  62. for key, value in self['render'].iteritems():
  63. parts = key.split('.', 1)
  64. value = value.strip()
  65. if len(parts) == 1:
  66. patterns[key] = re.compile(value)
  67. config.setdefault(key, {})
  68. else:
  69. config.setdefault(parts[0], {})[parts[1]] = value
  70. return [(patterns[name], config[name].pop('renderer', 'static'),
  71. config[name]) for name in sorted(patterns)]
  72. def get_render_info(self, path):
  73. """Get rendering info for the given path."""
  74. for pattern, renderer, config in self.render_info:
  75. if pattern.match(path):
  76. return renderer, config
  77. return None, {}
  78. def site_path(self, path):
  79. """Return the repository path corresponding to a site path."""
  80. return '%s/%s' % (self.base_path, path.lstrip('/'))
  81. class Request(object):
  82. """A wrapper for a Mercurial WSGI request object."""
  83. def __init__(self, web, req):
  84. self.web = web
  85. self.repo = web.repo
  86. self._req = req
  87. self.status = common.HTTP_OK
  88. def __getattr__(self, name):
  89. return getattr(self._req, name)
  90. @util.propertycache
  91. def path_info(self):
  92. """Return the path info for this request."""
  93. if 'PATH_INFO' in self.env:
  94. parts = self.env['PATH_INFO'].strip('/').split('/')
  95. repo_parts = self.env.get('REPO_NAME', '').split('/')
  96. if parts[:len(repo_parts)] == repo_parts:
  97. parts = parts[len(repo_parts):]
  98. return '/'.join(parts)
  99. else:
  100. qs = self.env['QUERY_STRING'].split('&', 1)[0].split(';', 1)[0]
  101. return qs.strip('/')
  102. @util.propertycache
  103. def default_revision(self):
  104. """Return the default revision configured in `hgrc`."""
  105. expr = self.web.config('hgsite', 'default_revision', 'default').strip()
  106. if not expr:
  107. return ''
  108. match = revset.match(self.repo.ui, expr)
  109. revs = match(self.repo, xrange(len(self.repo)))
  110. if not revs:
  111. self.repo.ui.warn("[hgsite] default_revision yields empty set\n")
  112. raise self.not_found("revision")
  113. return revs[0]
  114. def abs_url(self, url):
  115. """Make the given URL absolute."""
  116. if url.startswith(('http://', 'https://')):
  117. return url
  118. scheme = self.env.get('wsgi.url_scheme')
  119. host = self.env.get('HTTP_HOST')
  120. if not host:
  121. host = self.env['SERVER_NAME']
  122. port = self.env['SERVER_PORT']
  123. if port != ('443' if scheme == 'https' else '80'):
  124. host += ':%s' % port
  125. return urlunparse((scheme, host, url, None, None, None))
  126. def redirect(self, url, status=302):
  127. """Generate a redirection response to the given URL."""
  128. self.header([
  129. ('Location', self.abs_url(url)),
  130. ('Pragma', 'no-cache'),
  131. ('Cache-Control', 'no-cache'),
  132. ('Expires', 'Fri, 01 Jan 1999 00:00:00 GMT')])
  133. self.respond(status, type='text/plain')
  134. return ['']
  135. def send(self, content, mimetype=None, encoding=None, headers=None,
  136. status=None):
  137. """Generate a response with the given content and metadata."""
  138. content_type = mimetype
  139. if content_type is not None and encoding is not None:
  140. content_type += ';charset=%s' % encoding
  141. if headers is not None:
  142. self.headers.extend(headers)
  143. if status is None:
  144. status = self.status
  145. self.respond(status, type=content_type, body=content)
  146. return []
  147. def not_found(self, label, what=None):
  148. """Return a '{something} not found' exception."""
  149. if what is None:
  150. msg = "The requested %s was not found" % label
  151. else:
  152. msg = "The requested %s was not found: %s" % (label, what)
  153. return common.ErrorResponse(common.HTTP_NOT_FOUND, msg)
  154. # Cache control headers
  155. cache_must_revalidate = [
  156. ('Cache-Control', 'must-revalidate'),
  157. ('Expires', 'Fri, 01 Jan 1999 00:00:00 GMT'),
  158. ]
  159. def browse_href_builder(base, ctx):
  160. """Return a URL builder to link to repository files in the file browser."""
  161. node = ctx.hex() if ctx.node() is not None else node_prefix
  162. href = href_builder(base.rstrip('/') + '/file/' + node)
  163. def build_href(path, line=None, n=1):
  164. """Build a URL to the given file, optionally linking to a line.
  165. `line` can be a line number, or a regexp to be matched against the
  166. lines of the file."""
  167. hash = ''
  168. if isinstance(line, basestring):
  169. try:
  170. data = ctx[path].data()
  171. line = find_line_no(line, data, n)
  172. except Exception:
  173. line = None
  174. if isinstance(line, (int, long)):
  175. hash = '#l%d' % line
  176. return href(path) + hash
  177. return build_href
  178. # Renderer registry
  179. _renderers = {}
  180. def renderer(*names, **kwargs):
  181. """Decorate a function to mark it as a renderer."""
  182. def decorate(f):
  183. f.__dict__.update(kwargs)
  184. if getattr(f, 'register', True):
  185. for name in names:
  186. _renderers[name] = f
  187. return f
  188. return decorate
  189. @renderer('static')
  190. def render_static(req, path, name, config, **kwargs):
  191. """Render static content."""
  192. mimetype = guess_mimetype(config.get('mimetype'), path,
  193. 'application/octet-stream')
  194. if req.url_node:
  195. headers = [('Cache-Control', 'public'),
  196. ('Expires', http_date(time.time() + 365 * 24 * 3600))]
  197. else:
  198. headers = cache_must_revalidate
  199. return req.send(req.ctx[path].data(), mimetype=mimetype,
  200. encoding=config.get('encoding'), headers=headers)
  201. @renderer('exec')
  202. def render_exec(req, path, name, config, **kwargs):
  203. """Render by calling a `render()` function in a module."""
  204. ns = {}
  205. exec req.ctx[path].data() in ns
  206. render = ns.get('render')
  207. if render is None:
  208. raise req.not_found("page", req.path)
  209. return render(req, path, name, config, **kwargs)
  210. def render(req, url_path, kwargs=None):
  211. """Render the given URL path in the given request."""
  212. path = webutil.cleanpath(req.repo, req.cfg.site_path(url_path.rstrip('/')))
  213. ext_order = req.cfg.ext_search_order
  214. def match_key(p):
  215. ext = get_ext(p)
  216. return not p == path, ext_order.get(ext, sys.maxint), ext
  217. match = req.ctx.match([r're:%s(?:\.[^/]*)?$' % re.escape(path)])
  218. try:
  219. file_path = min(req.ctx.walk(match), key=match_key)
  220. file_exists = True
  221. except ValueError:
  222. file_path = path
  223. file_exists = False
  224. name, config = req.cfg.get_render_info(file_path)
  225. renderer = _renderers.get(name)
  226. if renderer is None \
  227. or not file_exists and getattr(renderer, 'file_must_exist', True):
  228. raise req.not_found("page", req.url_path)
  229. return renderer(req, file_path, name, config, **(kwargs or {}))
  230. def prepare_request(req, node):
  231. """Prepare the request object to render at the given node."""
  232. # Get configuration
  233. repo_node = node if node is not None else req.default_revision
  234. allow_wc = req.web.configbool('hgsite', 'allow_wc', False)
  235. if repo_node == '' and not allow_wc:
  236. raise req.not_found("revision")
  237. try:
  238. req.ctx = req.repo[None if repo_node == '' else repo_node]
  239. except error.RepoLookupError:
  240. raise req.not_found("revision", node)
  241. try:
  242. req.cfg = Config(req)
  243. except (IOError, error.LookupError):
  244. return False
  245. # Add URL builders to the request object
  246. if node is None:
  247. req.href = href_builder(req.url)
  248. else:
  249. req.href = href_builder(req.url + node_prefix + node)
  250. req.rev_href = href_builder(req.url + node_prefix + hex_node(req.ctx))
  251. req.repo_href = href_builder(req.url)
  252. if repo_node != '':
  253. def build_static_href(path):
  254. """Return a site URL for a static resource."""
  255. fctx = req.ctx[req.cfg.site_path(path)]
  256. # Find the last changeset where the file was modified
  257. fctx = fctx.filectx(fctx.filerev())
  258. return req.repo_href(node_prefix + fctx.hex(), path)
  259. req.static_href = build_static_href
  260. else:
  261. req.static_href = req.rev_href
  262. def build_hist_href(node, *args, **kwargs):
  263. """Return a historical site URL at the given node."""
  264. return req.repo_href(node_prefix + node if node is not None else None,
  265. *args, **kwargs)
  266. req.hist_href = build_hist_href
  267. req.browse_href = browse_href_builder(req.url, req.ctx)
  268. req.repo_default = req.repo_href(req.web._default_cmd)
  269. return True
  270. def handle_default_webcmd(web, req, tmpl):
  271. """Handle the default web command."""
  272. req = Request(web, req)
  273. # Extract node and path from PATH_INFO
  274. if req.path_info.startswith(node_prefix):
  275. # PATH_INFO is '~{node}' or '~{node}/{path}'
  276. parts = req.path_info.split('/', 1)
  277. req.url_node = parts[0][len(node_prefix):]
  278. req.url_path = parts[1] if len(parts) > 1 else ''
  279. else:
  280. # PATH_INFO is '' or '{path}'
  281. req.url_node = None
  282. req.url_path = req.path_info
  283. try:
  284. if not prepare_request(req, req.url_node):
  285. # Configuration not found, do as if we didn't exist
  286. default_handler = getattr(webcommands, req.web._default_cmd)
  287. return default_handler(req.web, req._req, tmpl)
  288. return render(req, req.url_path or req.cfg.site_index)
  289. except Exception:
  290. exc_info = sys.exc_info()
  291. if not (prepare_request(req, None) and req.cfg.error_page):
  292. raise
  293. if isinstance(exc_info[1], common.ErrorResponse):
  294. req.status = exc_info[1].code
  295. elif isinstance(exc_info[1], not_found_exceptions):
  296. req.status = common.HTTP_NOT_FOUND
  297. else:
  298. req.status = common.HTTP_SERVER_ERROR
  299. kwargs = {'exc_info': exc_info}
  300. return render(req, req.cfg.error_page, kwargs)
  301. def handle_caching(orig, web, req, *args, **kwargs):
  302. """Customize page caching."""
  303. cmd = req.form['cmd'][0]
  304. if cmd == webcmd:
  305. return # Site caching is decided by renderers
  306. if cmd == 'file' and req.form.get('node', [''])[0] == node_prefix:
  307. return # Prevent caching of working copy file pages
  308. return orig(web, req, *args, **kwargs)
  309. def wc_filectx(orig, repo, req, *args, **kwargs):
  310. """Hack to allow viewing files from the working copy in the file browser.
  311. When `[hgsite] allow_wc` is True, and the node passed in the URL is
  312. `node_prefix`, temporarily substitute the node with None and call the
  313. original `filectx()`. It will return a `workingfilectx` instance pointing
  314. to the desired file.
  315. However, this `fctx` will raise an exception at rendering time, due to
  316. a call to `fctx.hex()`. By dynamically subclassing the instance, the call
  317. can be intercepted and a dummy value can be returned."""
  318. if (req.form['cmd'][0] != 'file'
  319. or req.form.get('node', ['']) != [node_prefix]
  320. or not repo.ui.configbool('hgsite', 'allow_wc', False)):
  321. return orig(repo, req, *args, **kwargs)
  322. # Temporarily substitute the node to get a `workingfilectx`
  323. req.form['node'] = [None]
  324. try:
  325. fctx = orig(repo, req, *args, **kwargs)
  326. if fctx.node() is None:
  327. if not exists_in_wc(repo, fctx.path()):
  328. raise error.LookupError(node_prefix, fctx.path(),
  329. 'not found')
  330. # Make fctx safe for rendering
  331. class SafeWorkingFileCtx(fctx.__class__):
  332. def hex(self):
  333. return ''
  334. fctx.__class__ = SafeWorkingFileCtx
  335. finally:
  336. req.form['node'] = [node_prefix]
  337. return fctx
  338. def hgweb_templater(orig, self, req, *args, **kwargs):
  339. """Override the default command if redirection is enabled."""
  340. tmpl = orig(self, req, *args, **kwargs)
  341. self._default_cmd = tmpl.cache.get('default', 'shortlog')
  342. tmpl.cache['default'] = webcmd
  343. return tmpl