Eclectic Media Git klaus / ac503a0
major refactoring to use werkzeug as backend not finished yet: raw and errors posativ 8 years ago
12 changed file(s) with 504 addition(s) and 402 deletion(s). Raw diff Collapse all Expand all
00 *.pyc
1 /bin/
0 #!/usr/bin/env python
1
2 from sys import argv, exit
3 from klaus import make_app
4
5 from werkzeug.serving import run_simple
6
7 if __name__ == '__main__':
8
9 if len(argv) < 1:
10 print "%s REPO 1 [REPO 2, REPO 3]"
11 exit(2)
12
13 app = make_app(argv[1:])
14 run_simple('127.0.0.1', 5000, app, use_reloader=True)
0 # -*- encoding: utf-8 -*-
1
02 import sys
13 import os
2 import re
3 import stat
4 import time
5 import urlparse
64 import mimetypes
75 from future_builtins import map
8 from functools import wraps
9
10 from dulwich.objects import Commit, Blob
6 # from functools import wraps
117
128 from jinja2 import Environment, FileSystemLoader
139
14 from pygments import highlight
15 from pygments.lexers import get_lexer_for_filename, get_lexer_by_name, \
16 guess_lexer, ClassNotFound
17 from pygments.formatters import HtmlFormatter
10 from werkzeug.wrappers import Request, Response
11 from werkzeug.routing import Map, Rule
12 from werkzeug.exceptions import HTTPException, NotFound, InternalServerError
13 from werkzeug.wsgi import SharedDataMiddleware
1814
19 from nano import NanoApplication, HttpError
20 from repo import Repo
15 from klaus import views
16 from klaus.utils import query_string_to_dict
17 from klaus.utils import force_unicode, timesince, shorten_sha1, pygmentize, \
18 guess_is_binary, guess_is_image, extract_author_name
2119
2220
2321 KLAUS_ROOT = os.path.join(os.path.dirname(__file__))
2927 KLAUS_VERSION = ''
3028
3129
32 def query_string_to_dict(query_string):
33 """ Transforms a POST/GET string into a Python dict """
34 return dict((k, v[0]) for k, v in urlparse.parse_qs(query_string).iteritems())
30 urlmap = Map([
31 Rule('/', endpoint='repo_list'),
32 Rule('/<repo>/blob/<commit_id>/', defaults={'path': ''}, endpoint='blob'),
33 Rule('/<repo>/blob/<commit_id>/<path:path>', endpoint='blob'),
34 Rule('/<repo>/raw/<commit_id>/', defaults={'path': ''}, endpoint='raw'),
35 Rule('/<repo>/raw/<commit_id>/<path:path>', endpoint='raw'),
36 Rule('/<repo>/commit/<commit_id>/', endpoint='commit'),
37 Rule('/<repo>/tree/<commit_id>/', defaults={'path': ''}, endpoint='history'),
38 Rule('/<repo>/tree/<commit_id>/<path:path>', endpoint='history')
39 ])
3540
36 class KlausApplication(NanoApplication):
37 def __init__(self, *args, **kwargs):
38 super(KlausApplication, self).__init__(*args, **kwargs)
39 self.jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR),
41
42 class Klaus(object):
43
44 def __init__(self, repos):
45
46 self.repos = repos
47 self.jinja_env = Environment(loader=FileSystemLoader('klaus/templates/'),
4048 extensions=['jinja2.ext.autoescape'],
4149 autoescape=True)
42 self.jinja_env.globals['build_url'] = self.build_url
4350 self.jinja_env.globals['KLAUS_VERSION'] = KLAUS_VERSION
44
45 def route(self, pattern):
46 """
47 Extends `NanoApplication.route` by multiple features:
48
49 - Overrides the WSGI `HTTP_HOST` by `self.custom_host` (if set)
50 - Tries to use the keyword arguments returned by the view function
51 to render the template called `<class>.html` (<class> being the
52 name of `self`'s class). Raising `Response` can be used to skip
53 this behaviour, directly returning information to Nano.
54 """
55 super_decorator = super(KlausApplication, self).route(pattern)
56 def decorator(callback):
57 @wraps(callback)
58 def wrapper(env, **kwargs):
59 if hasattr(self, 'custom_host'):
60 env['HTTP_HOST'] = self.custom_host
61 try:
62 return self.render_template(callback.__name__ + '.html',
63 **callback(env, **kwargs))
64 except Response as e:
65 if len(e.args) == 1:
66 return e.args[0]
67 return e.args
68 return super_decorator(wrapper)
69 return decorator
7051
7152 def render_template(self, template_name, **kwargs):
7253 return self.jinja_env.get_template(template_name).render(**kwargs)
7354
74 app = application = KlausApplication(debug=True, default_content_type='text/html')
75 # KLAUS_REPOS=/foo/bar/,/spam.git/ --> {'bar': '/foo/bar/', 'spam': '/spam/'}
76 app.repos = dict(
77 (repo.rstrip(os.sep).split(os.sep)[-1].replace('.git', ''), repo)
78 for repo in (sys.argv[1:] or os.environ.get('KLAUS_REPOS', '').split())
79 )
55 def dispatch(self, request, start_response):
56 adapter = urlmap.bind_to_environ(request.environ)
57 response = {'environ': request.environ, 'adapter': adapter,
58 'build': lambda k, **x: adapter.build(k, values=x)}
8059
81 def pygmentize(code, filename=None, language=None):
82 if language:
83 lexer = get_lexer_by_name(language)
84 else:
8560 try:
86 lexer = get_lexer_for_filename(filename)
87 except ClassNotFound:
88 lexer = guess_lexer(code)
61 endpoint, values = adapter.match()
62 if hasattr(endpoint, '__call__'):
63 handler = endpoint
64 else:
65 handler = getattr(views, endpoint)
66 return handler(self, request, response, **values)
67 except NotFound, e:
68 return Response('Not Found', 404)
69 except HTTPException, e:
70 return e
71 except InternalServerError, e:
72 return Response(e, 500)
8973
90 return highlight(code, lexer, KlausFormatter())
74 def wsgi_app(self, environ, start_response):
75 request = Request(environ)
76 request.GET = query_string_to_dict(environ.get('QUERY_STRING', ''))
77
78 response = self.dispatch(request, start_response)
79 return response(environ, start_response)
80
81 def __call__(self, environ, start_response):
82 return self.wsgi_app(environ, start_response)
9183
9284
93 class KlausFormatter(HtmlFormatter):
94 def __init__(self):
95 HtmlFormatter.__init__(self, linenos='table', lineanchors='L',
96 anchorlinenos=True)
85 def make_app(repos):
9786
98 def _format_lines(self, tokensource):
99 for tag, line in HtmlFormatter._format_lines(self, tokensource):
100 if tag == 1:
101 # sourcecode line
102 line = '<span class=line>%s</span>' % line
103 yield tag, line
87 repos = dict(
88 (repo.rstrip(os.sep).split(os.sep)[-1].replace('.git', ''), repo)
89 for repo in (sys.argv[1:] or os.environ.get('KLAUS_REPOS', '').split())
90 )
10491
92 app = Klaus(repos)
10593
106 def timesince(when, now=time.time):
107 """ Returns the difference between `when` and `now` in human readable form. """
108 delta = now() - when
109 result = []
110 break_next = False
111 for unit, seconds, break_immediately in [
112 ('year', 365*24*60*60, False),
113 ('month', 30*24*60*60, False),
114 ('week', 7*24*60*60, False),
115 ('day', 24*60*60, True),
116 ('hour', 60*60, False),
117 ('minute', 60, True),
118 ('second', 1, False),
119 ]:
120 if delta > seconds:
121 n = int(delta/seconds)
122 delta -= n*seconds
123 result.append((n, unit))
124 if break_immediately:
125 break
126 if not break_next:
127 break_next = True
128 continue
129 if break_next:
130 break
94 app.jinja_env.filters['u'] = force_unicode
95 app.jinja_env.filters['timesince'] = timesince
96 app.jinja_env.filters['shorten_sha1'] = shorten_sha1
97 app.jinja_env.filters['shorten_message'] = lambda msg: msg.split('\n')[0]
98 app.jinja_env.filters['pygmentize'] = pygmentize
99 app.jinja_env.filters['is_binary'] = guess_is_binary
100 app.jinja_env.filters['is_image'] = guess_is_image
101 app.jinja_env.filters['shorten_author'] = extract_author_name
131102
132 if len(result) > 1:
133 n, unit = result[0]
134 if unit == 'month':
135 if n == 1:
136 # 1 month, 3 weeks --> 7 weeks
137 result = [(result[1][0] + 4, 'week')]
138 else:
139 # 2 months, 1 week -> 2 months
140 result = result[:1]
141 elif unit == 'hour' and n > 5:
142 result = result[:1]
103 app = SharedDataMiddleware(app, {
104 '/static/': os.path.join(os.path.dirname(__file__), 'static/')
105 })
143106
144 return ', '.join('%d %s%s' % (n, unit, 's' if n != 1 else '')
145 for n, unit in result[:2])
146
147 def guess_is_binary(data):
148 if isinstance(data, basestring):
149 return '\0' in data
150 else:
151 return any(map(guess_is_binary, data))
152
153 def guess_is_image(filename):
154 mime, encoding = mimetypes.guess_type(filename)
155 if mime is None:
156 return False
157 return mime.startswith('image/')
158
159 def force_unicode(s):
160 """ Does all kind of magic to turn `s` into unicode """
161 if isinstance(s, unicode):
162 return s
163 try:
164 return s.decode('utf-8')
165 except UnicodeDecodeError as exc:
166 pass
167 try:
168 return s.decode('iso-8859-1')
169 except UnicodeDecodeError:
170 pass
171 try:
172 import chardet
173 encoding = chardet.detect(s)['encoding']
174 if encoding is not None:
175 return s.decode(encoding)
176 except (ImportError, UnicodeDecodeError):
177 raise exc
178
179 def extract_author_name(email):
180 """
181 Extracts the name from an email address...
182 >>> extract_author_name("John <john@example.com>")
183 "John"
184
185 ... or returns the address if none is given.
186 >>> extract_author_name("noname@example.com")
187 "noname@example.com"
188 """
189 match = re.match('^(.*?)<.*?>$', email)
190 if match:
191 return match.group(1).strip()
192 return email
193
194 def shorten_sha1(sha1):
195 if re.match('[a-z\d]{20,40}', sha1):
196 sha1 = sha1[:10]
197 return sha1
198
199 app.jinja_env.filters['u'] = force_unicode
200 app.jinja_env.filters['timesince'] = timesince
201 app.jinja_env.filters['shorten_sha1'] = shorten_sha1
202 app.jinja_env.filters['shorten_message'] = lambda msg: msg.split('\n')[0]
203 app.jinja_env.filters['pygmentize'] = pygmentize
204 app.jinja_env.filters['is_binary'] = guess_is_binary
205 app.jinja_env.filters['is_image'] = guess_is_image
206 app.jinja_env.filters['shorten_author'] = extract_author_name
207
208 def subpaths(path):
209 """
210 Yields a `(last part, subpath)` tuple for all possible sub-paths of `path`.
211
212 >>> list(subpaths("foo/bar/spam"))
213 [('foo', 'foo'), ('bar', 'foo/bar'), ('spam', 'foo/bar/spam')]
214 """
215 seen = []
216 for part in path.split('/'):
217 seen.append(part)
218 yield part, '/'.join(seen)
219
220 def get_repo(name):
221 try:
222 return Repo(name, app.repos[name])
223 except KeyError:
224 raise HttpError(404, 'No repository named "%s"' % name)
225
226 class Response(Exception):
227 pass
228
229 class BaseView(dict):
230 def __init__(self, env):
231 dict.__init__(self)
232 self['environ'] = env
233 self.GET = query_string_to_dict(env.get('QUERY_STRING', ''))
234 self.view()
235
236 def direct_response(self, *args):
237 raise Response(*args)
238
239 def route(pattern, name=None):
240 def decorator(cls):
241 cls.__name__ = name or cls.__name__.lower()
242 app.route(pattern)(cls)
243 return cls
244 return decorator
245
246 @route('/', 'repo_list')
247 class RepoList(BaseView):
248 """ Shows a list of all repos and can be sorted by last update. """
249 def view(self):
250 self['repos'] = repos = []
251 for name in app.repos.iterkeys():
252 repo = get_repo(name)
253 refs = [repo[ref_hash] for ref_hash in repo.get_refs().itervalues()]
254 refs.sort(key=lambda obj:getattr(obj, 'commit_time', None),
255 reverse=True)
256 last_updated_at = None
257 if refs:
258 last_updated_at = refs[0].commit_time
259 repos.append((name, last_updated_at))
260 if 'by-last-update' in self.GET:
261 repos.sort(key=lambda x: x[1], reverse=True)
262 else:
263 repos.sort(key=lambda x: x[0])
264
265 class BaseRepoView(BaseView):
266 def __init__(self, env, repo, commit_id, path=None):
267 self['repo'] = repo = get_repo(repo)
268 self['commit_id'] = commit_id
269 self['commit'], isbranch = self.get_commit(repo, commit_id)
270 self['branch'] = commit_id if isbranch else 'master'
271 self['branches'] = repo.get_branch_names(exclude=[commit_id])
272 self['path'] = path
273 if path:
274 self['subpaths'] = list(subpaths(path))
275 self['build_url'] = self.build_url
276 super(BaseRepoView, self).__init__(env)
277
278 def get_commit(self, repo, id):
279 try:
280 commit, isbranch = repo.get_branch_or_commit(id)
281 if not isinstance(commit, Commit):
282 raise KeyError
283 except KeyError:
284 raise HttpError(404, '"%s" has no commit "%s"' % (repo.name, id))
285 return commit, isbranch
286
287 def build_url(self, view=None, **kwargs):
288 """ Builds url relative to the current repo + commit """
289 if view is None:
290 view = self.__class__.__name__
291 default_kwargs = {
292 'repo': self['repo'].name,
293 'commit_id': self['commit_id']
294 }
295 if view == 'history' and kwargs.get('path') is None:
296 kwargs['path'] = ''
297 return app.build_url(view, **dict(default_kwargs, **kwargs))
298
299
300 class TreeViewMixin(object):
301 def view(self):
302 self['tree'] = self.listdir()
303
304 def listdir(self):
305 """
306 Returns a list of directories and files in the current path of the
307 selected commit
308 """
309 dirs, files = [], []
310 tree, root = self.get_tree()
311 for entry in tree.iteritems():
312 name, entry = entry.path, entry.in_path(root)
313 if entry.mode & stat.S_IFDIR:
314 dirs.append((name.lower(), name, entry.path))
315 else:
316 files.append((name.lower(), name, entry.path))
317 files.sort()
318 dirs.sort()
319 if root:
320 dirs.insert(0, (None, '..', os.path.split(root)[0]))
321 return {'dirs' : dirs, 'files' : files}
322
323 def get_tree(self):
324 """ Gets the Git tree of the selected commit and path """
325 root = self['path']
326 tree = self['repo'].get_tree(self['commit'], root)
327 if isinstance(tree, Blob):
328 root = os.path.split(root)[0]
329 tree = self['repo'].get_tree(self['commit'], root)
330 return tree, root
331
332 @route('/:repo:/tree/:commit_id:/(?P<path>.*)', 'history')
333 class TreeView(TreeViewMixin, BaseRepoView):
334 """
335 Shows a list of files/directories for the current path as well as all
336 commit history for that path in a paginated form.
337 """
338 def view(self):
339 super(TreeView, self).view()
340 try:
341 page = int(self.GET.get('page'))
342 except (TypeError, ValueError):
343 page = 0
344
345 self['page'] = page
346
347 if page:
348 self['history_length'] = 30
349 self['skip'] = (self['page']-1) * 30 + 10
350 if page > 7:
351 self['previous_pages'] = [0, 1, 2, None] + range(page)[-3:]
352 else:
353 self['previous_pages'] = xrange(page)
354 else:
355 self['history_length'] = 10
356 self['skip'] = 0
357
358 class BaseBlobView(BaseRepoView):
359 def view(self):
360 self['blob'] = self['repo'].get_tree(self['commit'], self['path'])
361 self['directory'], self['filename'] = os.path.split(self['path'].strip('/'))
362
363 @route('/:repo:/blob/:commit_id:/(?P<path>.*)', 'view_blob')
364 class BlobView(BaseBlobView, TreeViewMixin):
365 """ Shows a single file, syntax highlighted """
366 def view(self):
367 BaseBlobView.view(self)
368 TreeViewMixin.view(self)
369 self['raw_url'] = self.build_url('raw_blob', path=self['path'])
370 self['too_large'] = sum(map(len, self['blob'].chunked)) > 100*1024
371
372
373 @route('/:repo:/raw/:commit_id:/(?P<path>.*)', 'raw_blob')
374 class RawBlob(BaseBlobView):
375 """
376 Shows a single file in raw form
377 (as if it were a normal filesystem file served through a static file server)
378 """
379 def view(self):
380 super(RawBlob, self).view()
381 mime, encoding = self.get_mimetype_and_encoding()
382 headers = {'Content-Type': mime}
383 if encoding:
384 headers['Content-Encoding'] = encoding
385 body = self['blob'].chunked
386 if len(body) == 1 and not body[0]:
387 body = []
388 self.direct_response('200 yo', headers, body)
389
390
391 def get_mimetype_and_encoding(self):
392 if guess_is_binary(self['blob'].chunked):
393 mime, encoding = mimetypes.guess_type(self['filename'])
394 if mime is None:
395 mime = 'application/octet-stream'
396 return mime, encoding
397 else:
398 return 'text/plain', 'utf-8'
399
400
401 @route('/:repo:/commit/:commit_id:/', 'view_commit')
402 class CommitView(BaseRepoView):
403 """ Shows a single commit diff """
404 def view(self):
405 pass
406
407
408 @route('/static/(?P<path>.+)', 'static')
409 class StaticFilesView(BaseView):
410 """
411 Serves assets (everything under /static/).
412
413 Don't use this in production! Use a static file server instead.
414 """
415 def __init__(self, env, path):
416 self['path'] = path
417 super(StaticFilesView, self).__init__(env)
418
419 def view(self):
420 path = './static/' + self['path']
421 relpath = os.path.join(KLAUS_ROOT, path)
422 if os.path.isfile(relpath):
423 self.direct_response(open(relpath))
424 else:
425 raise HttpError(404, 'Not Found')
107 return app
186186 repo = _cache[path] = RepoWrapper(path)
187187 repo.name = name
188188 return repo
189
190 def get_repo(klaus, name):
191 return Repo(name, klaus.repos[name])
192
11
22 {% block breadcrumbs %}
33 <span>
4 <a href="{{ build_url('history', commit_id='master') }}">{{ repo.name }}</a>
4 <a href="{{ build('history', commit_id='master') }}">{{ repo.name }}</a>
55 <span class=slash>/</span>
6 <a href="{{ build_url('history') }}">{{ commit_id|shorten_sha1 }}</a>
6 <a href="{{ build('history') }}">{{ commit_id|shorten_sha1 }}</a>
77 </span>
88
99 {% if subpaths %}
1212 {% if loop.last %}
1313 <a href="">{{ name|u }}</a>
1414 {% else %}
15 <a href="{{ build_url('history', path=subpath) }}">{{ name|u }}</a>
15 <a href="{{ build('history', path=subpath) }}">{{ name|u }}</a>
1616 <span class=slash>/</span>
1717 {% endif %}
1818 {% endfor %}
2525 <span>{{ commit_id|shorten_sha1 }}</span>
2626 <ul>
2727 {% for branch in branches %}
28 <li><a href="{{ build_url(commit_id=branch, path=path) }}">{{ branch }}</a></li>
28 <li><a href="{{ build(view, commit_id=branch, path=path) }}">{{ branch }}</a></li>
2929 {% endfor %}
3030 </ul>
3131 </div>
4040 Commit History
4141 {% endif %}
4242 <span>
43 @<a href="{{ build_url(commit_id=branch) }}">{{ branch }}</a>
43 @<a href="{{ build(view, commit_id=branch) }}">{{ branch }}</a>
4444 </span>
4545 </h2>
4646
5050 {% for commit in history %}
5151 {% if not loop.last or history|length < history_length %}
5252 <li>
53 <a class=commit href="{{ build_url('view_commit', commit_id=commit.id) }}">
53 <a class=commit href="{{ build('commit', commit_id=commit.id) }}">
5454 <span class=line1>
5555 <span>{{ commit.message|u|shorten_message }}</span>
5656 </span>
99 <ul class=repolist>
1010 {% for name, last_update_at in repos %}
1111 <li>
12 <a href="{{ build_url('history', repo=name, commit_id='master', path='') }}">
12 <a href="{{ build('history', repo=name, commit_id='master', path='') }}">
1313 <span class=name>{{ name }}</span>
1414 <span class=last-updated>
1515 {% if last_update_at is not none %}
00 <div class=tree>
1 <h2>Tree @<a href="{{ build_url('view_commit') }}">{{ commit_id|shorten_sha1 }}</a></h2>
1 <h2>Tree @<a href="{{ build('commit') }}">{{ commit_id|shorten_sha1 }}</a></h2>
22 <ul>
33 {% for _, name, fullpath in tree.dirs %}
4 <li><a href="{{ build_url('history', path=fullpath) }}" class=dir>{{ name|u }}</a></li>
4 <li><a href="{{ build('history', path=fullpath) }}" class=dir>{{ name|u }}</a></li>
55 {% endfor %}
66 {% for _, name, fullpath in tree.files %}
7 <li><a href="{{ build_url('view_blob', path=fullpath) }}">{{ name|u }}</a></li>
7 <li><a href="{{ build('blob', path=fullpath) }}">{{ name|u }}</a></li>
88 {% endfor %}
99 </ul>
1010 </div>
66 <h2>
77 {{ filename|u }}
88 <span>
9 @<a href="{{ build_url('view_commit') }}">{{ commit_id|shorten_sha1 }}</a>
9 @<a href="{{ build('commit') }}">{{ commit_id|shorten_sha1 }}</a>
1010 (<a href="{{ raw_url }}">raw</a>
11 &middot; <a href="{{ build_url('history', path=path) }}">history</a>)
11 &middot; <a href="{{ build('history', path=path) }}">history</a>)
1212 </span>
1313 </h2>
1414 {% if blob.chunked|is_binary %}
3030 {% if file.new_filename == '/dev/null' %}
3131 <del>{{ file.old_filename|u }}</del>
3232 {% else %}
33 <a href="{{ build_url('view_blob', path=file.new_filename) }}">
33 <a href="{{ build('blob', path=file.new_filename) }}">
3434 {{ file.new_filename|u }}
3535 </a>
3636 {% endif %}
0 # -*- encoding: utf-8 -*-
1
2 import re
3 import time
4 import mimetypes
5 import urlparse
6
7 from pygments import highlight
8 from pygments.lexers import get_lexer_for_filename, get_lexer_by_name, \
9 guess_lexer, ClassNotFound
10 from pygments.formatters import HtmlFormatter
11
12
13 def query_string_to_dict(query_string):
14 """ Transforms a POST/GET string into a Python dict """
15 return dict((k, v[0]) for k, v in urlparse.parse_qs(query_string).iteritems())
16
17
18 class KlausFormatter(HtmlFormatter):
19 def __init__(self):
20 HtmlFormatter.__init__(self, linenos='table', lineanchors='L',
21 anchorlinenos=True)
22
23 def _format_lines(self, tokensource):
24 for tag, line in HtmlFormatter._format_lines(self, tokensource):
25 if tag == 1:
26 # sourcecode line
27 line = '<span class=line>%s</span>' % line
28 yield tag, line
29
30
31 def pygmentize(code, filename=None, language=None):
32 if language:
33 lexer = get_lexer_by_name(language)
34 else:
35 try:
36 lexer = get_lexer_for_filename(filename)
37 except ClassNotFound:
38 lexer = guess_lexer(code)
39
40 return highlight(code, lexer, KlausFormatter())
41
42
43 def timesince(when, now=time.time):
44 """ Returns the difference between `when` and `now` in human readable form. """
45 delta = now() - when
46 result = []
47 break_next = False
48 for unit, seconds, break_immediately in [
49 ('year', 365*24*60*60, False),
50 ('month', 30*24*60*60, False),
51 ('week', 7*24*60*60, False),
52 ('day', 24*60*60, True),
53 ('hour', 60*60, False),
54 ('minute', 60, True),
55 ('second', 1, False),
56 ]:
57 if delta > seconds:
58 n = int(delta/seconds)
59 delta -= n*seconds
60 result.append((n, unit))
61 if break_immediately:
62 break
63 if not break_next:
64 break_next = True
65 continue
66 if break_next:
67 break
68
69 if len(result) > 1:
70 n, unit = result[0]
71 if unit == 'month':
72 if n == 1:
73 # 1 month, 3 weeks --> 7 weeks
74 result = [(result[1][0] + 4, 'week')]
75 else:
76 # 2 months, 1 week -> 2 months
77 result = result[:1]
78 elif unit == 'hour' and n > 5:
79 result = result[:1]
80
81 return ', '.join('%d %s%s' % (n, unit, 's' if n != 1 else '')
82 for n, unit in result[:2])
83
84
85 def guess_is_binary(data):
86 if isinstance(data, basestring):
87 return '\0' in data
88 else:
89 return any(map(guess_is_binary, data))
90
91
92 def guess_is_image(filename):
93 mime, encoding = mimetypes.guess_type(filename)
94 if mime is None:
95 return False
96 return mime.startswith('image/')
97
98
99 def force_unicode(s):
100 """ Does all kind of magic to turn `s` into unicode """
101 if isinstance(s, unicode):
102 return s
103 try:
104 return s.decode('utf-8')
105 except UnicodeDecodeError as exc:
106 pass
107 try:
108 return s.decode('iso-8859-1')
109 except UnicodeDecodeError:
110 pass
111 try:
112 import chardet
113 encoding = chardet.detect(s)['encoding']
114 if encoding is not None:
115 return s.decode(encoding)
116 except (ImportError, UnicodeDecodeError):
117 raise exc
118
119
120 def extract_author_name(email):
121 """
122 Extracts the name from an email address...
123 >>> extract_author_name("John <john@example.com>")
124 "John"
125
126 ... or returns the address if none is given.
127 >>> extract_author_name("noname@example.com")
128 "noname@example.com"
129 """
130 match = re.match('^(.*?)<.*?>$', email)
131 if match:
132 return match.group(1).strip()
133 return email
134
135
136 def shorten_sha1(sha1):
137 if re.match('[a-z\d]{20,40}', sha1):
138 sha1 = sha1[:10]
139 return sha1
140
141
142 def subpaths(path):
143 """
144 Yields a `(last part, subpath)` tuple for all possible sub-paths of `path`.
145
146 >>> list(subpaths("foo/bar/spam"))
147 [('foo', 'foo'), ('bar', 'foo/bar'), ('spam', 'foo/bar/spam')]
148 """
149 seen = []
150 for part in path.split('/'):
151 seen.append(part)
152 yield part, '/'.join(seen)
0 # -*- encoding: utf-8 -*-
1
2 from werkzeug.wrappers import Request, Response
3 from werkzeug.exceptions import HTTPException, NotFound, InternalServerError
4
5 import os
6 import stat
7 from dulwich.objects import Commit, Blob
8
9 from klaus.repo import get_repo
10 from klaus.utils import subpaths, query_string_to_dict
11
12
13 def repo_list(klaus, request, response):
14 """ Shows a list of all repos and can be sorted by last update. """
15
16 response['repos'] = repos = []
17
18 for name in klaus.repos.iterkeys():
19
20 try:
21 repo = get_repo(klaus, name)
22 except KeyError:
23 raise InternalServerError
24
25 refs = [repo[ref_hash] for ref_hash in repo.get_refs().itervalues()]
26 refs.sort(key=lambda obj:getattr(obj, 'commit_time', None),
27 reverse=True)
28 last_updated_at = None
29 if refs:
30 last_updated_at = refs[0].commit_time
31 repos.append((name, last_updated_at))
32 if 'by-last-update' in request.GET:
33 repos.sort(key=lambda x: x[1], reverse=True)
34 else:
35 repos.sort(key=lambda x: x[0])
36
37 return Response(klaus.render_template('repo_list.html', **response), 200,
38 content_type='text/html')
39
40
41 def history(klaus, request, response, repo, commit_id, path):
42
43 response.update(TreeView(klaus, request, response, repo, commit_id, path))
44 defaults = {'repo': repo, 'commit_id': commit_id, 'path': path}
45
46 build = response['adapter'].build
47 response['build'] = lambda v, **kw: build(v, dict(defaults, **kw))
48 response['view'] = 'history'
49
50 return Response(klaus.render_template('history.html', **response), 200,
51 content_type='text/html')
52
53
54 def commit(klaus, request, response, repo, commit_id):
55 # XXX not really DRY
56 response.update(CommitView(klaus, request, response, repo, commit_id, ''))
57 defaults = {'repo': repo, 'commit_id': commit_id, 'path': ''}
58
59 build = response['adapter'].build
60 response['build'] = lambda v, **kw: build(v, dict(defaults, **kw))
61 response['view'] = 'commit'
62
63 return Response(klaus.render_template('view_commit.html', **response), 200,
64 content_type='text/html')
65
66
67 def blob(klaus, request, response, repo, commit_id, path):
68
69 response.update(BlobView(klaus, request, response, repo, commit_id, path))
70 defaults = {'repo': repo, 'commit_id': commit_id, 'path': path}
71
72 build = response['adapter'].build
73 response['build'] = lambda v, **kw: build(v, dict(defaults, **kw))
74 response['view'] = 'blob'
75
76 return Response(klaus.render_template('view_blob.html', **response), 200,
77 content_type='text/html')
78
79
80 class BaseView(dict):
81 def __init__(self, request, response):
82 dict.__init__(self)
83
84 self.GET = request.GET
85 self.view()
86
87 def direct_response(self, *args):
88 # XXX
89 raise Response(*args)
90
91
92 class BaseRepoView(BaseView):
93 def __init__(self, klaus, request, response, repo, commit_id, path):
94
95 self.update(response)
96 self['repo'] = repo = get_repo(klaus, repo)
97 self['commit_id'] = commit_id
98 self['commit'], isbranch = self.get_commit(repo, commit_id)
99 self['branch'] = commit_id if isbranch else 'master'
100 self['branches'] = repo.get_branch_names(exclude=[commit_id])
101 self['path'] = path
102
103 if path:
104 self['subpaths'] = list(subpaths(path))
105
106 super(BaseRepoView, self).__init__(request, response)
107
108 def get_commit(self, repo, id):
109 try:
110 commit, isbranch = repo.get_branch_or_commit(id)
111 if not isinstance(commit, Commit):
112 raise KeyError
113 except KeyError:
114 # XXX remove HttpError
115 raise HttpError(404, '"%s" has no commit "%s"' % (repo.name, id))
116 return commit, isbranch
117
118
119 class TreeViewMixin(object):
120
121 def view(self):
122 self['tree'] = self.listdir()
123
124 def listdir(self):
125 """
126 Returns a list of directories and files in the current path of the
127 selected commit
128 """
129 dirs, files = [], []
130 tree, root = self.get_tree()
131 for entry in tree.iteritems():
132 name, entry = entry.path, entry.in_path(root)
133 if entry.mode & stat.S_IFDIR:
134 dirs.append((name.lower(), name, entry.path))
135 else:
136 files.append((name.lower(), name, entry.path))
137 files.sort()
138 dirs.sort()
139 if root:
140 dirs.insert(0, (None, '..', os.path.split(root)[0]))
141 return {'dirs' : dirs, 'files' : files}
142
143 def get_tree(self):
144 """ Gets the Git tree of the selected commit and path """
145 root = self['path']
146 tree = self['repo'].get_tree(self['commit'], root)
147 if isinstance(tree, Blob):
148 root = os.path.split(root)[0]
149 tree = self['repo'].get_tree(self['commit'], root)
150 return tree, root
151
152 # @route('/:repo:/tree/:commit_id:/(?P<path>.*)', 'history')
153 class TreeView(TreeViewMixin, BaseRepoView):
154 """
155 Shows a list of files/directories for the current path as well as all
156 commit history for that path in a paginated form.
157 """
158 def view(self):
159 super(TreeView, self).view()
160 try:
161 page = int(self.GET.get('page'))
162 except (TypeError, ValueError):
163 page = 0
164
165 self['page'] = page
166
167 if page:
168 self['history_length'] = 30
169 self['skip'] = (self['page']-1) * 30 + 10
170 if page > 7:
171 self['previous_pages'] = [0, 1, 2, None] + range(page)[-3:]
172 else:
173 self['previous_pages'] = xrange(page)
174 else:
175 self['history_length'] = 10
176 self['skip'] = 0
177
178 class BaseBlobView(BaseRepoView):
179 def view(self):
180 self['blob'] = self['repo'].get_tree(self['commit'], self['path'])
181 self['directory'], self['filename'] = os.path.split(self['path'].strip('/'))
182
183 # @route('/:repo:/blob/:commit_id:/(?P<path>.*)', 'view_blob')
184 class BlobView(BaseBlobView, TreeViewMixin):
185 """ Shows a single file, syntax highlighted """
186 def view(self):
187 BaseBlobView.view(self)
188 TreeViewMixin.view(self)
189 self['raw_url'] = self['build']('raw', path=self['path'], repo=self['repo'],
190 commit_id=self['commit_id'])
191 self['too_large'] = sum(map(len, self['blob'].chunked)) > 100*1024
192
193
194 # @route('/:repo:/raw/:commit_id:/(?P<path>.*)', 'raw_blob')
195 class RawBlob(BaseBlobView):
196 """
197 Shows a single file in raw form
198 (as if it were a normal filesystem file served through a static file server)
199 """
200 def view(self):
201 super(RawBlob, self).view()
202 mime, encoding = self.get_mimetype_and_encoding()
203 headers = {'Content-Type': mime}
204 if encoding:
205 headers['Content-Encoding'] = encoding
206 body = self['blob'].chunked
207 if len(body) == 1 and not body[0]:
208 body = []
209 self.direct_response('200 yo', headers, body)
210
211
212 def get_mimetype_and_encoding(self):
213 if guess_is_binary(self['blob'].chunked):
214 mime, encoding = mimetypes.guess_type(self['filename'])
215 if mime is None:
216 mime = 'application/octet-stream'
217 return mime, encoding
218 else:
219 return 'text/plain', 'utf-8'
220
221
222 # @route('/:repo:/commit/:commit_id:/', 'view_commit')
223 class CommitView(BaseRepoView):
224 """ Shows a single commit diff """
225 def view(self):
226 pass
227
228
229 # @route('/static/(?P<path>.+)', 'static')
230 class StaticFilesView(BaseView):
231 """
232 Serves assets (everything under /static/).
233
234 Don't use this in production! Use a static file server instead.
235 """
236 def __init__(self, env, path):
237 self['path'] = path
238 super(StaticFilesView, self).__init__(env)
239
240 def view(self):
241 path = './static/' + self['path']
242 relpath = os.path.join(KLAUS_ROOT, path)
243 if os.path.isfile(relpath):
244 self.direct_response(open(relpath))
245 else:
246 raise HttpError(404, 'Not Found')