Merge branch 'flask'
Conflicts:
tools/quickstart.py
Jonas Haag
12 years ago
0 | https://github.com/jonashaag/klaus | |
1 | Copyright (c) 2011-2012 Jonas Haag <jonas@lophus.org> and contributors (see Git logs). | |
0 | 2 | |
1 | Copyright (c) 2011-2012 Jonas Haag <jonas@lophus.org> and contributors. | |
2 | All rights reserved. | |
3 | License: 2-clause-BSD (Berkley Software Distribution) license | |
3 | Permission to use, copy, modify, and/or distribute this software for any | |
4 | purpose with or without fee is hereby granted, provided that the above | |
5 | copyright notice and this permission notice appear in all copies. | |
4 | 6 | |
5 | http://github.com/jonashaag/klaus | |
6 | ||
7 | For a full list of contributors have a look at the Git logs. | |
8 | ||
9 | The full text of the 2-clause BSD license follows. | |
10 | ||
11 | The 2-clause Berkley Software Distribution license | |
12 | ================================================== | |
13 | Redistribution and use in source and binary forms, with or without | |
14 | modification, are permitted provided that the following conditions are met: | |
15 | ||
16 | * Redistributions of source code must retain the above copyright | |
17 | notice, this list of conditions and the following disclaimer. | |
18 | * Redistributions in binary form must reproduce the above copyright | |
19 | notice, this list of conditions and the following disclaimer in the | |
20 | documentation and/or other materials provided with the distribution. | |
21 | ||
22 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | |
23 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | |
24 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE | |
25 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE | |
26 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR | |
27 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF | |
28 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS | |
29 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN | |
30 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) | |
31 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE | |
32 | POSSIBILITY OF SUCH DAMAGE. | |
7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES | |
8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF | |
9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR | |
10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES | |
11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN | |
12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF | |
13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. |
14 | 14 | .. _img3: https://github.com/jonashaag/klaus/raw/master/assets/blob-view.gif |
15 | 15 | |
16 | 16 | |
17 | Requirements | |
18 | ------------ | |
19 | * Python 2.7 | |
20 | * Jinja2_ | |
21 | * Pygments_ | |
22 | * dulwich_ (>= 0.7.1) | |
23 | * argparse (only for Python 2.6) | |
24 | * Nano_ (shipped as submodule, do a ``git submodule update --init`` to fetch) | |
25 | ||
26 | .. _Jinja2: http://jinja.pocoo.org/ | |
27 | .. _Pygments: http://pygments.org/ | |
28 | .. _dulwich: http://www.samba.org/~jelmer/dulwich/ | |
29 | .. _Nano: https://github.com/jonashaag/nano | |
30 | ||
31 | ||
32 | 17 | Installation |
33 | 18 | ------------ |
34 | *The same procedure as every year, James.* :: | |
19 | :: | |
35 | 20 | |
36 | virtualenv your-env | |
37 | source your-env/bin/activate | |
38 | ||
39 | git clone https://github.com/jonashaag/klaus | |
40 | cd klaus | |
41 | git submodule update --init | |
42 | pip install -r requirements.txt | |
21 | pip install klaus | |
43 | 22 | |
44 | 23 | |
45 | 24 | Usage |
46 | 25 | ----- |
47 | Using the ``quickstart.py`` script | |
48 | .................................. | |
49 | :: | |
50 | 26 | |
51 | tools/quickstart --help | |
52 | tools/quickstart.py <host> <port> /path/to/repo1 [../path/to/repo2 [...]] | |
27 | Using the ``klaus`` script | |
28 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ | |
29 | To run klaus using the default options:: | |
53 | 30 | |
54 | Example:: | |
31 | klaus [repo1 [repo2 ...]] | |
55 | 32 | |
56 | tools/quickstart.py 127.0.0.1 8080 ../klaus ../nano ../bjoern | |
33 | For more options, see:: | |
57 | 34 | |
58 | This will make klaus serve the *klaus*, *nano* and *bjoern* repos at | |
59 | ``127.0.0.1:8080`` using Python's built-in wsgiref_ server (or, if installed, | |
60 | the bjoern_ server). | |
35 | klaus --help | |
61 | 36 | |
62 | .. _wsgiref: http://docs.python.org/library/wsgiref.html | |
63 | .. _bjoern: https://github.com/jonashaag/bjoern | |
64 | 37 | |
65 | 38 | Using a real server |
66 | ................... | |
67 | The ``klaus.py`` module contains a WSGI ``application`` object. The repo list | |
68 | is read from the ``KLAUS_REPOS`` environment variable (space-separated paths). | |
39 | ^^^^^^^^^^^^^^^^^^^ | |
40 | The ``klaus`` module contains a ``make_app`` function which returns a WSGI app. | |
69 | 41 | |
70 | UWSGI example:: | |
42 | An example WSGI helper script is provided with klaus (see ``klaus/wsgi.py``), | |
43 | configuration being read from environment variables. Use it like this (uWSGI example):: | |
71 | 44 | |
72 | uwsgi ... -m klaus --env KLAUS_REPOS="/path/to/repo1 /path/to/repo2 ..." ... | |
45 | uwsgi -w klaus.wsgi \ | |
46 | --env KLAUS_SITE_TITLE="Klaus Demo" \ | |
47 | --env KLAUS_REPOS="/path/to/repo1 /path/to/repo2 ..." \ | |
48 | ... |
0 | refactor | |
1 | -------- | |
2 | * serve under sub-uri - posativ | |
3 | ||
4 | ||
0 | 5 | definitely |
1 | 6 | ---------- |
7 | * who is using klaus, related projects | |
8 | * commit: summary before diffs | |
9 | * tests | |
2 | 10 | * tag selector |
3 | 11 | |
4 | 12 | maybe |
6 | 14 | * file blame |
7 | 15 | * search function |
8 | 16 | * (p)ajax |
17 | * pygments css cmd flag | |
18 | * font |
0 | #!/usr/bin/env python2 | |
1 | # coding: utf-8 | |
2 | ||
3 | import os | |
4 | import argparse | |
5 | from dulwich.errors import NotGitRepository | |
6 | from dulwich.repo import Repo | |
7 | import klaus | |
8 | ||
9 | ||
10 | def git_repository(path): | |
11 | if not os.path.exists(path): | |
12 | raise argparse.ArgumentTypeError('%r: No such directory' % path) | |
13 | try: | |
14 | Repo(path) | |
15 | except NotGitRepository: | |
16 | raise argparse.ArgumentTypeError('%r: Not a Git repository' % path) | |
17 | return path | |
18 | ||
19 | ||
20 | def make_parser(): | |
21 | parser = argparse.ArgumentParser(epilog="Gemüse kaufen!") | |
22 | parser.add_argument('--host', help="default: 127.0.0.1", default='127.0.0.1') | |
23 | parser.add_argument('--port', help="default: 8080", default=8080, type=int) | |
24 | parser.add_argument('--sitename', help="site name showed in header. default: your hostname") | |
25 | ||
26 | parser.add_argument('repos', help='repositories to serve', | |
27 | metavar='DIR', nargs='*', type=git_repository) | |
28 | ||
29 | grp = parser.add_argument_group("Git Smart HTTP") | |
30 | grp.add_argument('--smarthttp', help="enable Git Smart HTTP serving", | |
31 | action='store_true') | |
32 | grp.add_argument('--htdigest', help="use credentials from FILE", | |
33 | metavar="FILE", type=argparse.FileType('r')) | |
34 | ||
35 | grp = parser.add_argument_group("Development flags", "DO NOT USE IN PRODUCTION!") | |
36 | grp.add_argument('--debug', help="Enable Werkzeug debugger and reloader", action='store_true') | |
37 | ||
38 | return parser | |
39 | ||
40 | ||
41 | def main(): | |
42 | args = make_parser().parse_args() | |
43 | ||
44 | if not args.sitename: | |
45 | args.sitename = '%s:%d' % (args.host, args.port) | |
46 | ||
47 | app = klaus.make_app(args.repos, | |
48 | args.sitename or args.host, | |
49 | args.smarthttp, | |
50 | args.htdigest) | |
51 | ||
52 | app.run(args.host, args.port, args.debug) | |
53 | ||
54 | ||
55 | if __name__ == '__main__': | |
56 | main() |
0 | # -*- coding: utf-8 -*- | |
1 | """ | |
2 | lodgeit.lib.diff | |
3 | ~~~~~~~~~~~~~~~~ | |
4 | ||
5 | Render a nice diff between two things. | |
6 | ||
7 | :copyright: 2007 by Armin Ronacher. | |
8 | :license: BSD | |
9 | """ | |
10 | import re | |
11 | from cgi import escape | |
12 | ||
13 | ||
14 | def prepare_udiff(udiff, **kwargs): | |
15 | """Prepare an udiff for a template.""" | |
16 | return DiffRenderer(udiff).prepare(**kwargs) | |
17 | ||
18 | ||
19 | class DiffRenderer(object): | |
20 | """Give it a unified diff and it renders you a beautiful | |
21 | html diff :-) | |
22 | """ | |
23 | _chunk_re = re.compile(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@') | |
24 | ||
25 | def __init__(self, udiff): | |
26 | """:param udiff: a text in udiff format""" | |
27 | self.lines = [escape(line) for line in udiff.splitlines()] | |
28 | ||
29 | def _extract_rev(self, line1, line2): | |
30 | def _extract(line): | |
31 | parts = line.split(None, 1) | |
32 | if parts[0].startswith(('a/', 'b/')): | |
33 | parts[0] = parts[0][2:] | |
34 | return parts[0], (len(parts) == 2 and parts[1] or None) | |
35 | try: | |
36 | if line1.startswith('--- ') and line2.startswith('+++ '): | |
37 | return _extract(line1[4:]), _extract(line2[4:]) | |
38 | except (ValueError, IndexError): | |
39 | pass | |
40 | return (None, None), (None, None) | |
41 | ||
42 | def _highlight_line(self, line, next): | |
43 | """Highlight inline changes in both lines.""" | |
44 | start = 0 | |
45 | limit = min(len(line['line']), len(next['line'])) | |
46 | while start < limit and line['line'][start] == next['line'][start]: | |
47 | start += 1 | |
48 | end = -1 | |
49 | limit -= start | |
50 | while -end <= limit and line['line'][end] == next['line'][end]: | |
51 | end -= 1 | |
52 | end += 1 | |
53 | if start or end: | |
54 | def do(l): | |
55 | last = end + len(l['line']) | |
56 | if l['action'] == 'add': | |
57 | tag = 'ins' | |
58 | else: | |
59 | tag = 'del' | |
60 | l['line'] = u'%s<%s>%s</%s>%s' % ( | |
61 | l['line'][:start], | |
62 | tag, | |
63 | l['line'][start:last], | |
64 | tag, | |
65 | l['line'][last:] | |
66 | ) | |
67 | do(line) | |
68 | do(next) | |
69 | ||
70 | def prepare(self, want_header=True): | |
71 | """Parse the diff an return data for the template.""" | |
72 | in_header = True | |
73 | header = [] | |
74 | lineiter = iter(self.lines) | |
75 | files = [] | |
76 | try: | |
77 | line = lineiter.next() | |
78 | while 1: | |
79 | # continue until we found the old file | |
80 | if not line.startswith('--- '): | |
81 | if in_header: | |
82 | header.append(line) | |
83 | line = lineiter.next() | |
84 | continue | |
85 | ||
86 | if header and all(x.strip() for x in header): | |
87 | if want_header: | |
88 | files.append({'is_header': True, 'lines': header}) | |
89 | header = [] | |
90 | ||
91 | in_header = False | |
92 | chunks = [] | |
93 | old, new = self._extract_rev(line, lineiter.next()) | |
94 | files.append({ | |
95 | 'is_header': False, | |
96 | 'old_filename': old[0], | |
97 | 'old_revision': old[1], | |
98 | 'new_filename': new[0], | |
99 | 'new_revision': new[1], | |
100 | 'chunks': chunks | |
101 | }) | |
102 | ||
103 | line = lineiter.next() | |
104 | while line: | |
105 | match = self._chunk_re.match(line) | |
106 | if not match: | |
107 | in_header = True | |
108 | break | |
109 | ||
110 | lines = [] | |
111 | chunks.append(lines) | |
112 | ||
113 | old_line, old_end, new_line, new_end = \ | |
114 | [int(x or 1) for x in match.groups()] | |
115 | old_line -= 1 | |
116 | new_line -= 1 | |
117 | old_end += old_line | |
118 | new_end += new_line | |
119 | line = lineiter.next() | |
120 | ||
121 | while old_line < old_end or new_line < new_end: | |
122 | if line: | |
123 | command, line = line[0], line[1:] | |
124 | else: | |
125 | command = ' ' | |
126 | affects_old = affects_new = False | |
127 | ||
128 | if command == '+': | |
129 | affects_new = True | |
130 | action = 'add' | |
131 | elif command == '-': | |
132 | affects_old = True | |
133 | action = 'del' | |
134 | else: | |
135 | affects_old = affects_new = True | |
136 | action = 'unmod' | |
137 | ||
138 | old_line += affects_old | |
139 | new_line += affects_new | |
140 | lines.append({ | |
141 | 'old_lineno': affects_old and old_line or u'', | |
142 | 'new_lineno': affects_new and new_line or u'', | |
143 | 'action': action, | |
144 | 'line': line | |
145 | }) | |
146 | line = lineiter.next() | |
147 | ||
148 | except StopIteration: | |
149 | pass | |
150 | ||
151 | # highlight inline changes | |
152 | for file in files: | |
153 | if file['is_header']: | |
154 | continue | |
155 | for chunk in file['chunks']: | |
156 | lineiter = iter(chunk) | |
157 | try: | |
158 | while True: | |
159 | line = lineiter.next() | |
160 | if line['action'] != 'unmod': | |
161 | nextline = lineiter.next() | |
162 | if nextline['action'] == 'unmod' or \ | |
163 | nextline['action'] == line['action']: | |
164 | continue | |
165 | self._highlight_line(line, nextline) | |
166 | except StopIteration: | |
167 | pass | |
168 | ||
169 | return files |
0 | import os | |
1 | import subprocess | |
2 | import jinja2 | |
3 | import flask | |
4 | import httpauth | |
5 | import dulwich.web | |
6 | from klaus import views, utils | |
7 | from klaus.repo import FancyRepo | |
8 | ||
9 | ||
10 | KLAUS_ROOT = os.path.dirname(__file__) | |
11 | ||
12 | try: | |
13 | KLAUS_VERSION = utils.check_output(['git', 'log', '--format=%h', '-n', '1']) | |
14 | except subprocess.CalledProcessError: | |
15 | KLAUS_VERSION = '0.2' | |
16 | ||
17 | ||
18 | class Klaus(flask.Flask): | |
19 | jinja_options = { | |
20 | 'extensions': ['jinja2.ext.autoescape'], | |
21 | 'undefined': jinja2.StrictUndefined | |
22 | } | |
23 | ||
24 | def __init__(self, repo_paths, sitename, use_smarthttp): | |
25 | self.repos = map(FancyRepo, repo_paths) | |
26 | self.repo_map = dict((repo.name, repo) for repo in self.repos) | |
27 | self.sitename = sitename | |
28 | self.use_smarthttp = use_smarthttp | |
29 | ||
30 | flask.Flask.__init__(self, __name__) | |
31 | ||
32 | self.setup_routes() | |
33 | ||
34 | def create_jinja_environment(self): | |
35 | """ Called by Flask.__init__ """ | |
36 | env = super(Klaus, self).create_jinja_environment() | |
37 | for func in [ | |
38 | 'force_unicode', | |
39 | 'timesince', | |
40 | 'shorten_sha1', | |
41 | 'shorten_message', | |
42 | 'extract_author_name', | |
43 | ]: | |
44 | env.filters[func] = getattr(utils, func) | |
45 | ||
46 | env.globals['KLAUS_VERSION'] = KLAUS_VERSION | |
47 | env.globals['USE_SMARTHTTP'] = self.use_smarthttp | |
48 | env.globals['SITENAME'] = self.sitename | |
49 | ||
50 | return env | |
51 | ||
52 | def setup_routes(self): | |
53 | for endpoint, rule in [ | |
54 | ('repo_list', '/'), | |
55 | ('blob', '/<repo>/blob/<commit_id>/'), | |
56 | ('blob', '/<repo>/blob/<commit_id>/<path:path>'), | |
57 | ('raw', '/<repo>/raw/<commit_id>/'), | |
58 | ('raw', '/<repo>/raw/<commit_id>/<path:path>'), | |
59 | ('commit', '/<repo>/commit/<commit_id>/'), | |
60 | ('history', '/<repo>/'), | |
61 | ('history', '/<repo>/tree/<commit_id>/'), | |
62 | ('history', '/<repo>/tree/<commit_id>/<path:path>'), | |
63 | ]: | |
64 | self.add_url_rule(rule, view_func=getattr(views, endpoint)) | |
65 | ||
66 | ||
67 | def make_app(repos, sitename, use_smarthttp=False, htdigest_file=None): | |
68 | """ | |
69 | Returns a WSGI with all the features (smarthttp, authentication) already | |
70 | patched in. | |
71 | """ | |
72 | app = Klaus( | |
73 | repos, | |
74 | sitename, | |
75 | use_smarthttp, | |
76 | ) | |
77 | app.wsgi_app = utils.SubUri(app.wsgi_app) | |
78 | ||
79 | if use_smarthttp: | |
80 | # `path -> Repo` mapping for Dulwich's web support | |
81 | dulwich_backend = dulwich.server.DictBackend( | |
82 | dict(('/'+repo.name, repo) for repo in app.repos) | |
83 | ) | |
84 | # Dulwich takes care of all Git related requests/URLs | |
85 | # and passes through everything else to klaus | |
86 | dulwich_wrapped_app = dulwich.web.make_wsgi_chain( | |
87 | backend=dulwich_backend, | |
88 | fallback_app=app.wsgi_app, | |
89 | ) | |
90 | ||
91 | # `receive-pack` is requested by the "client" on a push | |
92 | # (the "server" is asked to *receive* packs), i.e. we need to secure | |
93 | # it using authentication or deny access completely to make the repo | |
94 | # read-only. | |
95 | # | |
96 | # Git first sends requests to /<repo-name>/info/refs?service=git-receive-pack. | |
97 | # If this request is responded to using HTTP 401 Unauthorized, the user | |
98 | # is prompted for username and password. If we keep responding 401, Git | |
99 | # interprets this as an authentication failure. (We can't respond 403 | |
100 | # because this results in horrible, unhelpful Git error messages.) | |
101 | # | |
102 | # Git will never call /<repo-name>/git-receive-pack if authentication | |
103 | # failed for /info/refs, but since it's used to upload stuff to the server | |
104 | # we must secure it anyway for security reasons. | |
105 | PATTERN = r'^/[^/]+/(info/refs\?service=git-receive-pack|git-receive-pack)$' | |
106 | if htdigest_file: | |
107 | # .htdigest file given. Use it to read the push-er credentials from. | |
108 | app.wsgi_app = httpauth.DigestFileHttpAuthMiddleware( | |
109 | htdigest_file, | |
110 | wsgi_app=dulwich_wrapped_app, | |
111 | routes=[PATTERN], | |
112 | ) | |
113 | else: | |
114 | # no .htdigest file given. Disable push-ing. Semantically we should | |
115 | # use HTTP 403 here but since that results in freaky error messages | |
116 | # (see above) we keep asking for authentication (401) instead. | |
117 | # Git will print a nice error message after a few tries. | |
118 | app.wsgi_app = httpauth.AlwaysFailingAuthMiddleware( | |
119 | wsgi_app=dulwich_wrapped_app, | |
120 | routes=[PATTERN], | |
121 | ) | |
122 | ||
123 | return app |
0 | # -*- coding: utf-8 -*- | |
1 | """ | |
2 | lodgeit.lib.diff | |
3 | ~~~~~~~~~~~~~~~~ | |
4 | ||
5 | Render a nice diff between two things. | |
6 | ||
7 | :copyright: 2007 by Armin Ronacher. | |
8 | :license: BSD | |
9 | """ | |
10 | import re | |
11 | from cgi import escape | |
12 | ||
13 | ||
14 | def prepare_udiff(udiff, **kwargs): | |
15 | """Prepare an udiff for a template.""" | |
16 | return DiffRenderer(udiff).prepare(**kwargs) | |
17 | ||
18 | ||
19 | class DiffRenderer(object): | |
20 | """Give it a unified diff and it renders you a beautiful | |
21 | html diff :-) | |
22 | """ | |
23 | _chunk_re = re.compile(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@') | |
24 | ||
25 | def __init__(self, udiff): | |
26 | """:param udiff: a text in udiff format""" | |
27 | self.lines = [escape(line) for line in udiff.splitlines()] | |
28 | ||
29 | def _extract_rev(self, line1, line2): | |
30 | def _extract(line): | |
31 | parts = line.split(None, 1) | |
32 | if parts[0].startswith(('a/', 'b/')): | |
33 | parts[0] = parts[0][2:] | |
34 | return parts[0], (len(parts) == 2 and parts[1] or None) | |
35 | try: | |
36 | if line1.startswith('--- ') and line2.startswith('+++ '): | |
37 | return _extract(line1[4:]), _extract(line2[4:]) | |
38 | except (ValueError, IndexError): | |
39 | pass | |
40 | return (None, None), (None, None) | |
41 | ||
42 | def _highlight_line(self, line, next): | |
43 | """Highlight inline changes in both lines.""" | |
44 | start = 0 | |
45 | limit = min(len(line['line']), len(next['line'])) | |
46 | while start < limit and line['line'][start] == next['line'][start]: | |
47 | start += 1 | |
48 | end = -1 | |
49 | limit -= start | |
50 | while -end <= limit and line['line'][end] == next['line'][end]: | |
51 | end -= 1 | |
52 | end += 1 | |
53 | if start or end: | |
54 | def do(l): | |
55 | last = end + len(l['line']) | |
56 | if l['action'] == 'add': | |
57 | tag = 'ins' | |
58 | else: | |
59 | tag = 'del' | |
60 | l['line'] = u'%s<%s>%s</%s>%s' % ( | |
61 | l['line'][:start], | |
62 | tag, | |
63 | l['line'][start:last], | |
64 | tag, | |
65 | l['line'][last:] | |
66 | ) | |
67 | do(line) | |
68 | do(next) | |
69 | ||
70 | def prepare(self, want_header=True): | |
71 | """Parse the diff an return data for the template.""" | |
72 | in_header = True | |
73 | header = [] | |
74 | lineiter = iter(self.lines) | |
75 | files = [] | |
76 | try: | |
77 | line = lineiter.next() | |
78 | while 1: | |
79 | # continue until we found the old file | |
80 | if not line.startswith('--- '): | |
81 | if in_header: | |
82 | header.append(line) | |
83 | line = lineiter.next() | |
84 | continue | |
85 | ||
86 | if header and all(x.strip() for x in header): | |
87 | if want_header: | |
88 | files.append({'is_header': True, 'lines': header}) | |
89 | header = [] | |
90 | ||
91 | in_header = False | |
92 | chunks = [] | |
93 | old, new = self._extract_rev(line, lineiter.next()) | |
94 | files.append({ | |
95 | 'is_header': False, | |
96 | 'old_filename': old[0], | |
97 | 'old_revision': old[1], | |
98 | 'new_filename': new[0], | |
99 | 'new_revision': new[1], | |
100 | 'chunks': chunks | |
101 | }) | |
102 | ||
103 | line = lineiter.next() | |
104 | while line: | |
105 | match = self._chunk_re.match(line) | |
106 | if not match: | |
107 | in_header = True | |
108 | break | |
109 | ||
110 | lines = [] | |
111 | chunks.append(lines) | |
112 | ||
113 | old_line, old_end, new_line, new_end = \ | |
114 | [int(x or 1) for x in match.groups()] | |
115 | old_line -= 1 | |
116 | new_line -= 1 | |
117 | old_end += old_line | |
118 | new_end += new_line | |
119 | line = lineiter.next() | |
120 | ||
121 | while old_line < old_end or new_line < new_end: | |
122 | if line: | |
123 | command, line = line[0], line[1:] | |
124 | else: | |
125 | command = ' ' | |
126 | affects_old = affects_new = False | |
127 | ||
128 | if command == '+': | |
129 | affects_new = True | |
130 | action = 'add' | |
131 | elif command == '-': | |
132 | affects_old = True | |
133 | action = 'del' | |
134 | else: | |
135 | affects_old = affects_new = True | |
136 | action = 'unmod' | |
137 | ||
138 | old_line += affects_old | |
139 | new_line += affects_new | |
140 | lines.append({ | |
141 | 'old_lineno': affects_old and old_line or u'', | |
142 | 'new_lineno': affects_new and new_line or u'', | |
143 | 'action': action, | |
144 | 'line': line | |
145 | }) | |
146 | line = lineiter.next() | |
147 | ||
148 | except StopIteration: | |
149 | pass | |
150 | ||
151 | # highlight inline changes | |
152 | for file in files: | |
153 | if file['is_header']: | |
154 | continue | |
155 | for chunk in file['chunks']: | |
156 | lineiter = iter(chunk) | |
157 | try: | |
158 | while True: | |
159 | line = lineiter.next() | |
160 | if line['action'] != 'unmod': | |
161 | nextline = lineiter.next() | |
162 | if nextline['action'] == 'unmod' or \ | |
163 | nextline['action'] == line['action']: | |
164 | continue | |
165 | self._highlight_line(line, nextline) | |
166 | except StopIteration: | |
167 | pass | |
168 | ||
169 | return files |
0 | import os | |
1 | ||
2 | LANGUAGES = [] | |
3 | ||
4 | try: | |
5 | import markdown | |
6 | LANGUAGES.append((['.md', '.mkdn'], markdown.markdown)) | |
7 | except ImportError: | |
8 | pass | |
9 | ||
10 | try: | |
11 | from docutils.core import publish_parts | |
12 | from docutils.writers.html4css1 import Writer | |
13 | ||
14 | def render_rest(content): | |
15 | ||
16 | # start by h2 and ignore invalid directives and so on (most likely from Sphinx) | |
17 | settings = {'initial_header_level': '2', 'report_level':'quiet'} | |
18 | return publish_parts(content, | |
19 | writer=Writer(), | |
20 | settings_overrides=settings).get('html_body') | |
21 | ||
22 | LANGUAGES.append((['.rst', '.rest'], render_rest)) | |
23 | except ImportError: | |
24 | pass | |
25 | ||
26 | ||
27 | def get_renderer(filename): | |
28 | _, ext = os.path.splitext(filename) | |
29 | for extensions, renderer in LANGUAGES: | |
30 | if ext in extensions: | |
31 | return renderer | |
32 | ||
33 | ||
34 | def can_render(filename): | |
35 | return get_renderer(filename) is not None | |
36 | ||
37 | ||
38 | def render(filename, content=None): | |
39 | if content is None: | |
40 | content = open(filename).read() | |
41 | ||
42 | return get_renderer(filename)(content) |
0 | import os | |
1 | import cStringIO | |
2 | ||
3 | import dulwich, dulwich.patch | |
4 | ||
5 | from klaus.utils import check_output | |
6 | from klaus.diff import prepare_udiff | |
7 | ||
8 | ||
9 | class FancyRepo(dulwich.repo.Repo): | |
10 | # TODO: factor out stuff into dulwich | |
11 | @property | |
12 | def name(self): | |
13 | return self.path.rstrip(os.sep).split(os.sep)[-1].replace('.git', '') | |
14 | ||
15 | def get_last_updated_at(self): | |
16 | refs = [self[ref_hash] for ref_hash in self.get_refs().itervalues()] | |
17 | refs.sort(key=lambda obj:getattr(obj, 'commit_time', None), | |
18 | reverse=True) | |
19 | if refs: | |
20 | return refs[0].commit_time | |
21 | return None | |
22 | ||
23 | def get_branch_or_commit(self, id): | |
24 | """ | |
25 | Returns a `(commit_object, is_branch)` tuple for the commit or branch | |
26 | identified by `id`. | |
27 | """ | |
28 | try: | |
29 | return self[id], False | |
30 | except KeyError: | |
31 | return self.get_branch(id), True | |
32 | ||
33 | def get_branch(self, name): | |
34 | """ Returns the commit object pointed to by the branch `name`. """ | |
35 | return self['refs/heads/'+name] | |
36 | ||
37 | def get_default_branch(self): | |
38 | """ | |
39 | Tries to guess the default repo branch name. | |
40 | """ | |
41 | for candidate in ['master', 'trunk', 'default', 'gh-pages']: | |
42 | try: | |
43 | self.get_branch(candidate) | |
44 | return candidate | |
45 | except KeyError: | |
46 | pass | |
47 | return self.get_branch_names()[0] | |
48 | ||
49 | def get_branch_names(self, exclude=()): | |
50 | """ Returns a sorted list of branch names. """ | |
51 | branches = [] | |
52 | for ref in self.get_refs(): | |
53 | if ref.startswith('refs/heads/'): | |
54 | name = ref[len('refs/heads/'):] | |
55 | if name not in exclude: | |
56 | branches.append(name) | |
57 | branches.sort() | |
58 | return branches | |
59 | ||
60 | def get_tag_names(self): | |
61 | """ Returns a sorted list of tag names. """ | |
62 | tags = [] | |
63 | for ref in self.get_refs(): | |
64 | if ref.startswith('refs/tags/'): | |
65 | tags.append(ref[len('refs/tags/'):]) | |
66 | tags.sort() | |
67 | return tags | |
68 | ||
69 | def history(self, commit, path=None, max_commits=None, skip=0): | |
70 | """ | |
71 | Returns a list of all commits that infected `path`, starting at branch | |
72 | or commit `commit`. `skip` can be used for pagination, `max_commits` | |
73 | to limit the number of commits returned. | |
74 | ||
75 | Similar to `git log [branch/commit] [--skip skip] [-n max_commits]`. | |
76 | """ | |
77 | # XXX The pure-Python/dulwich code is very slow compared to `git log` | |
78 | # at the time of this writing (mid-2012). | |
79 | # For instance, `git log .tx` in the Django root directory takes | |
80 | # about 0.15s on my machine whereas the history() method needs 5s. | |
81 | # Therefore we use `git log` here until dulwich gets faster. | |
82 | # For the pure-Python implementation, see the 'purepy-hist' branch. | |
83 | ||
84 | cmd = ['git', 'log', '--format=%H'] | |
85 | if skip: | |
86 | cmd.append('--skip=%d' % skip) | |
87 | if max_commits: | |
88 | cmd.append('--max-count=%d' % max_commits) | |
89 | cmd.append(commit) | |
90 | if path: | |
91 | cmd.extend(['--', path]) | |
92 | ||
93 | sha1_sums = check_output(cmd, cwd=os.path.abspath(self.path)) | |
94 | return [self[sha1] for sha1 in sha1_sums.strip().split('\n')] | |
95 | ||
96 | def get_tree(self, commit, path): | |
97 | """ Returns the Git tree object for `path` at `commit`. """ | |
98 | tree = self[commit.tree] | |
99 | if path: | |
100 | for directory in path.strip('/').split('/'): | |
101 | if directory: | |
102 | tree = self[tree[directory][1]] | |
103 | return tree | |
104 | ||
105 | def commit_diff(self, commit): | |
106 | from klaus.utils import guess_is_binary, force_unicode | |
107 | ||
108 | if commit.parents: | |
109 | parent_tree = self[commit.parents[0]].tree | |
110 | else: | |
111 | parent_tree = None | |
112 | ||
113 | changes = self.object_store.tree_changes(parent_tree, commit.tree) | |
114 | for (oldpath, newpath), (oldmode, newmode), (oldsha, newsha) in changes: | |
115 | try: | |
116 | if newsha and guess_is_binary(self[newsha]) or \ | |
117 | oldsha and guess_is_binary(self[oldsha]): | |
118 | yield { | |
119 | 'is_binary': True, | |
120 | 'old_filename': oldpath or '/dev/null', | |
121 | 'new_filename': newpath or '/dev/null', | |
122 | 'chunks': None | |
123 | } | |
124 | continue | |
125 | except KeyError: | |
126 | # newsha/oldsha are probably related to submodules. | |
127 | # Dulwich will handle that. | |
128 | pass | |
129 | ||
130 | stringio = cStringIO.StringIO() | |
131 | dulwich.patch.write_object_diff(stringio, self.object_store, | |
132 | (oldpath, oldmode, oldsha), | |
133 | (newpath, newmode, newsha)) | |
134 | files = prepare_udiff(force_unicode(stringio.getvalue()), | |
135 | want_header=False) | |
136 | if not files: | |
137 | # the diff module doesn't handle deletions/additions | |
138 | # of empty files correctly. | |
139 | yield { | |
140 | 'old_filename': oldpath or '/dev/null', | |
141 | 'new_filename': newpath or '/dev/null', | |
142 | 'chunks': [] | |
143 | } | |
144 | else: | |
145 | yield files[0] |
0 | @charset "utf-8"; | |
1 | ||
2 | /* Reset */ | |
3 | body { margin: 0; padding: 0; font-family: sans-serif; } | |
4 | pre { line-height: 125%; } | |
5 | a, a:visited { color: #003278; text-decoration: none; } | |
6 | a:hover { text-decoration: underline; } | |
7 | table { border-spacing: 0; border-collapse: collapse; } | |
8 | ||
9 | h2 > span:last-of-type { font-size: 60%; } | |
10 | h2 > code:last-of-type { | |
11 | font-size: 60%; | |
12 | border-bottom: 1px solid #E0E0E0; | |
13 | padding: 4px 5px; | |
14 | margin-left: 12px; | |
15 | } | |
16 | ||
17 | .slash { color: #666; margin: 0 -0.2em; } | |
18 | ||
19 | .clearfloat { clear: both; } | |
20 | ||
21 | .history ul, .repolist, .tree ul, .branch-selector ul { | |
22 | list-style-type: none; | |
23 | padding-left: 0; | |
24 | } | |
25 | ||
26 | /* Header */ | |
27 | header { font-size: 90%; padding: 8px 10px; border-bottom: 3px solid #e0e0e0; } | |
28 | header > a { padding: 10px 0; } | |
29 | header .breadcrumbs > span:before { content: ' » '; color: #666; } | |
30 | header .slash { margin: 0 -3px; } | |
31 | ||
32 | .branch-selector { | |
33 | position: absolute; | |
34 | top: 5px; | |
35 | right: 4px; | |
36 | font-size: 90%; | |
37 | background-color: #fefefe; | |
38 | } | |
39 | .branch-selector > * { | |
40 | background-color: #fcfcfc; | |
41 | position: relative; | |
42 | } | |
43 | .branch-selector span { | |
44 | border: 1px solid #f1f1f1; | |
45 | padding: 4px 5px; | |
46 | float: right; | |
47 | } | |
48 | .branch-selector span:after { content: "☟"; margin-left: 5px; } | |
49 | .branch-selector span:hover { background-color: #fefefe; cursor: pointer; } | |
50 | .branch-selector ul { | |
51 | z-index: 1; | |
52 | clear: both; | |
53 | display: none; | |
54 | margin: 0; | |
55 | } | |
56 | .branch-selector li a { | |
57 | display: block; | |
58 | padding: 4px 5px; | |
59 | border-bottom: 1px solid #f1f1f1; | |
60 | } | |
61 | .branch-selector li:first-child a { border-top: 1px solid #f1f1f1; } | |
62 | .branch-selector li a:hover { background-color: #fefefe; } | |
63 | .branch-selector li:last-child a { border: 0; } | |
64 | .branch-selector:hover { border: 1px solid #ccc; } | |
65 | .branch-selector:hover span { border: 0; background-color: inherit; } | |
66 | .branch-selector:hover ul { display: block; } | |
67 | ||
68 | /* Footer */ | |
69 | footer { | |
70 | clear: both; | |
71 | font-size: 80%; | |
72 | float: right; | |
73 | color: #666; | |
74 | padding: 50px 3px 3px 0; | |
75 | } | |
76 | footer a { color: inherit; border-bottom: 1px dotted #666; } | |
77 | footer a:hover { text-decoration: none; } | |
78 | ||
79 | ||
80 | /* Container */ | |
81 | #content { padding: 5px 1.5%; } | |
82 | #content > div:nth-of-type(1), | |
83 | #content > div:nth-of-type(2) { float: left; } | |
84 | #content > div:nth-of-type(1) { width: 24%; } | |
85 | #content > div:nth-of-type(2) { | |
86 | width: 72%; | |
87 | padding-left: 1.5%; | |
88 | margin-left: 1.5%; | |
89 | border-left: 1px dotted #ccc; | |
90 | } | |
91 | ||
92 | ||
93 | /* Pagination */ | |
94 | .pagination { float: right; margin: 0; font-size: 90%; } | |
95 | .pagination > * { | |
96 | border: 1px solid; | |
97 | padding: 2px 10px; | |
98 | text-align: center; | |
99 | } | |
100 | .pagination .n { font-size: 90%; padding: 1px 5px; position: relative; top: 1px; } | |
101 | .pagination > a { opacity: 0.6; border-color: #6491bf; } | |
102 | .pagination > a:hover { opacity: 1; text-decoration: none; border-color: #4D6FA0; } | |
103 | .pagination span { color: #999; border-color: #ccc; } | |
104 | ||
105 | ||
106 | /* Repo List */ | |
107 | .repolist { margin-left: 2em; font-size: 120%; } | |
108 | .repolist li { margin-bottom: 10px; } | |
109 | .repolist li a { display: block; } | |
110 | .repolist li a .last-updated { | |
111 | display: block; | |
112 | color: #737373; | |
113 | font-size: 60%; | |
114 | margin-left: 1px; | |
115 | } | |
116 | .repolist li a:hover { text-decoration: none; } | |
117 | .repolist li a:hover .name { text-decoration: underline; } | |
118 | ||
119 | ||
120 | /* Base styles for history and commit views */ | |
121 | .commit { | |
122 | background-color: #f9f9f9; | |
123 | padding: 8px 10px; | |
124 | margin-bottom: 2px; | |
125 | display: block; | |
126 | border: 1px solid #e0e0e0; | |
127 | } | |
128 | .commit:hover { text-decoration: none; } | |
129 | ||
130 | .commit > span { display: block; } | |
131 | ||
132 | .commit .line1 { font-family: monospace; padding-bottom: 2px; } | |
133 | .commit .line1 span { white-space: pre-wrap; text-overflow: hidden; } | |
134 | .commit:hover .line1 { text-decoration: underline; color: #aaa; } | |
135 | .commit:hover .line1 span { color: black; } | |
136 | ||
137 | .commit .line2 { position: relative; top: 3px; left: 1px; } | |
138 | .commit .line2 > span:first-child { float: left; } | |
139 | .commit .line2 > span:nth-child(2) { float: right; } | |
140 | .commit .line2 { color: #737373; font-size: 80%; } | |
141 | ||
142 | ||
143 | /* History View */ | |
144 | .history .pagination { margin-top: -2em; } | |
145 | a.commit { color: black !important; } | |
146 | ||
147 | .tree ul { font-family: monospace; border-top: 1px solid #e0e0e0; } | |
148 | .tree li { background-color: #f9f9f9; border: 1px solid #e0e0e0; border-top: 0; } | |
149 | .tree li a { padding: 5px 7px 6px 7px; display: block; color: #001533; } | |
150 | .tree li a:before, .diff .filename:before { | |
151 | margin-right: 5px; | |
152 | position: relative; | |
153 | top: 2px; | |
154 | opacity: 0.7; | |
155 | content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAPCAYAAADUFP50AAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sGBhMmAbS/QqsAAAAdaVRYdENvbW1lbnQAAAAAAENyZWF0ZWQgd2l0aCBHSU1QZC5lBwAAANBJREFUKM+Vkj2OgzAQhb8HSLupkKiQD8DPWbZMkSMgLsF9IlLmMpiKA9CncraIQGbXIPGqsec9faOx1TTNwxhz5YT6vr8lxphr13Wc1D1Zqnmecc4BIGl1LLUk4jgmTVMA1qBzDmvtxuhLEnVdr+fEb5ZleUj0lfgGn/hXh8SiKAKEF+/3F1EUhYkA4zhumlVVARfgBXzvjxoiSkK6/Bt9Q7TWHi7lM8HOVsNE7RMlMQxDkLRs078LEkPh3XfMsuzUZ1Xbts88z3/OhKZpuv8CNeMsq6Yg8OoAAAAASUVORK5CYII=); | |
156 | } | |
157 | .tree li a.dir:before { | |
158 | content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAPCAYAAADtc08vAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sGBhMiMxgE1i8AAAAdaVRYdENvbW1lbnQAAAAAAENyZWF0ZWQgd2l0aCBHSU1QZC5lBwAAAYxJREFUKM+l0r1KXEEYxvH/bNS4K2RNCjsRBAlqITESI0QCuqBdWLwACSJaiZILiDcQkDRql8oUIqkkkRTRwiKVyvoRV5HFEFEX/DjomePsmfNauB4xrLAmD1PNML95eBnV0Fj/vjPRMcpfMcYAMP9jYXDzV3qSO1LSmegYfdvbV/DQ8zS+709oVzu7u78/FwQAHOeU9Y31gsjz5hYcx5lqbXsxdb23ld4eW15aGQmBaDRGZfzxXS1JvukBQCmFUoqZL9PDIWCMQWuX76tnpLIxisqjJC39SXmoM5thg1Q2xsd3XXjGFmWUlz1g6MPc0xIArV0A9o89dg7PiwJqqyoAiHieRzRaZPUCibiuGzb4J+B6Bv8F3LeBtQFeznLrH5RGAgQQEZRSiAgiEIhgrZCzAcYXLnxLzgrxirIbQGuXmvgFR2eGP0caRBEg5BciIAgieRjwrdwAB9lDnrW9Yjlzkr909boIBAiCApGwVWvdE+a+fkOvzX5STd0D86XV7a/vOzy7t/hzaXb85SVDycBfkNNgmgAAAABJRU5ErkJggg==); | |
159 | } | |
160 | ||
161 | ||
162 | /* Common for diff and blob view */ | |
163 | .line { display: block; } | |
164 | .linenos { background-color: #f9f9f9; text-align: right; } | |
165 | .linenos a { color: #888; } | |
166 | .linenos a:hover { text-decoration: none; } | |
167 | .highlight-line { background-color: #fefed0; } | |
168 | .linenos a { padding: 0 6px 0 6px; } | |
169 | ||
170 | ||
171 | /* Blob View */ | |
172 | .blobview img { max-width: 100%; padding: 1px; } | |
173 | .blobview table, .blobview img { border: 1px solid #e0e0e0; } | |
174 | .blobview .linenos { border: 1px solid #e0e0e0; padding: 0; } | |
175 | .blobview .code { padding: 0; } | |
176 | .blobview .code .line { padding: 0 5px 0 10px; } | |
177 | .blobview .markup { font-family: Georgia, serif; } | |
178 | .blobview .markup pre { background-color: #f9f9f9; padding: 10px 12px; } | |
179 | ||
180 | ||
181 | /* Commit View */ | |
182 | .full-commit { width: 100% !important; margin-top: 10px; } | |
183 | ||
184 | .full-commit .commit { padding: 15px 20px; } | |
185 | .full-commit .commit .line1 { padding-bottom: 5px; } | |
186 | .full-commit .commit:hover .line1 { text-decoration: none; } | |
187 | .full-commit .commit .line2 > span:nth-child(2) { float: left; } | |
188 | .full-commit .commit .line2 > span:nth-child(2):before { content: '·'; margin: 0 3px 0 5px; } | |
189 | ||
190 | .diff { font-family: monospace; } | |
191 | .diff .filename { | |
192 | background-color: #f9f9f9; | |
193 | padding: 8px 10px; | |
194 | border: 1px solid #e0e0e0; | |
195 | border-bottom: 0; | |
196 | margin-top: 25px; | |
197 | } | |
198 | .diff .filename del { color: #999; } | |
199 | .diff .filename:before { margin-right: 0; opacity: 0.3; } | |
200 | .diff table, .diff .binarydiff { | |
201 | border: 1px solid #e0e0e0; | |
202 | background-color: #fdfdfd; | |
203 | width: 100%; | |
204 | } | |
205 | .diff .binarydiff { | |
206 | padding: 7px 10px; | |
207 | } | |
208 | .diff td { | |
209 | padding: 0; | |
210 | border-left: 1px solid #e0e0e0; | |
211 | } | |
212 | .diff td .line { padding: 1px 10px; display: block; height: 1.2em; } | |
213 | .diff .linenos { font-size: 85%; padding: 0; } | |
214 | .diff .linenos a { display: block; padding-top: 1px; padding-bottom: 1px; } | |
215 | .diff td + td + td { width: 100%; } | |
216 | .diff td + td + td > span { white-space: pre; } | |
217 | .diff tr:first-of-type td { padding-top: 7px; } | |
218 | .diff tr:last-of-type td { padding-bottom: 7px; } | |
219 | .diff table .del { background-color: #ffdddd; } | |
220 | .diff table .add { background-color: #ddffdd; } | |
221 | .diff table del { background-color: #ee9999; text-decoration: none; } | |
222 | .diff table ins { background-color: #99ee99; text-decoration: none; } | |
223 | .diff .sep > td { | |
224 | height: 1.2em; | |
225 | background-color: #f9f9f9; | |
226 | text-align: center; | |
227 | border: 1px solid #e0e0e0; | |
228 | } | |
229 | .diff .sep:hover > td { background-color: #f9f9f9; } |
0 | var highlight_linenos = function(opts) { | |
1 | var forEach = function(collection, func) { | |
2 | for(var i = 0; i < collection.length; ++i) { | |
3 | func(collection[i]); | |
4 | } | |
5 | } | |
6 | ||
7 | ||
8 | var links = document.querySelectorAll(opts.linksSelector); | |
9 | currentHash = location.hash; | |
10 | ||
11 | forEach(links, function(a) { | |
12 | var lineno = a.getAttribute('href').substr(1), | |
13 | selector = 'a[name="' + lineno + '"]', | |
14 | anchor = document.querySelector(selector), | |
15 | associatedLine = opts.getLineFromAnchor(anchor); | |
16 | ||
17 | var highlight = function() { | |
18 | a.className = 'highlight-line'; | |
19 | associatedLine.className = 'line highlight-line'; | |
20 | currentHighlight = a; | |
21 | } | |
22 | ||
23 | var unhighlight = function() { | |
24 | if (a.getAttribute('href') != location.hash) { | |
25 | a.className = ''; | |
26 | associatedLine.className = 'line'; | |
27 | } | |
28 | } | |
29 | ||
30 | a.onmouseover = associatedLine.onmouseover = highlight; | |
31 | a.onmouseout = associatedLine.onmouseout = unhighlight; | |
32 | }); | |
33 | ||
34 | ||
35 | window.onpopstate = function() { | |
36 | if (currentHash) { | |
37 | forEach(document.querySelectorAll('a[href="' + currentHash + '"]'), | |
38 | function(e) { e.onmouseout() }) | |
39 | } | |
40 | if (location.hash) { | |
41 | forEach(document.querySelectorAll('a[href="' + location.hash + '"]'), | |
42 | function(e) { e.onmouseover() }); | |
43 | currentHash = location.hash; | |
44 | } | |
45 | }; | |
46 | } |
0 | /* This is the Pygments Trac theme */ | |
1 | .code .hll { background-color: #ffffcc } | |
2 | .code { background: #ffffff; } | |
3 | .code .c { color: #999988; font-style: italic } /* Comment */ | |
4 | .code .err { color: #a61717; background-color: #e3d2d2 } /* Error */ | |
5 | .code .k { font-weight: bold } /* Keyword */ | |
6 | .code .o { font-weight: bold } /* Operator */ | |
7 | .code .cm { color: #999988; font-style: italic } /* Comment.Multiline */ | |
8 | .code .cp { color: #999999; font-weight: bold } /* Comment.Preproc */ | |
9 | .code .c1 { color: #999988; font-style: italic } /* Comment.Single */ | |
10 | .code .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */ | |
11 | .code .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ | |
12 | .code .ge { font-style: italic } /* Generic.Emph */ | |
13 | .code .gr { color: #aa0000 } /* Generic.Error */ | |
14 | .code .gh { color: #999999 } /* Generic.Heading */ | |
15 | .code .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ | |
16 | .code .go { color: #888888 } /* Generic.Output */ | |
17 | .code .gp { color: #555555 } /* Generic.Prompt */ | |
18 | .code .gs { font-weight: bold } /* Generic.Strong */ | |
19 | .code .gu { color: #aaaaaa } /* Generic.Subheading */ | |
20 | .code .gt { color: #aa0000 } /* Generic.Traceback */ | |
21 | .code .kc { font-weight: bold } /* Keyword.Constant */ | |
22 | .code .kd { font-weight: bold } /* Keyword.Declaration */ | |
23 | .code .kn { font-weight: bold } /* Keyword.Namespace */ | |
24 | .code .kp { font-weight: bold } /* Keyword.Pseudo */ | |
25 | .code .kr { font-weight: bold } /* Keyword.Reserved */ | |
26 | .code .kt { color: #445588; font-weight: bold } /* Keyword.Type */ | |
27 | .code .m { color: #009999 } /* Literal.Number */ | |
28 | .code .s { color: #bb8844 } /* Literal.String */ | |
29 | .code .na { color: #008080 } /* Name.Attribute */ | |
30 | .code .nb { color: #999999 } /* Name.Builtin */ | |
31 | .code .nc { color: #445588; font-weight: bold } /* Name.Class */ | |
32 | .code .no { color: #008080 } /* Name.Constant */ | |
33 | .code .ni { color: #800080 } /* Name.Entity */ | |
34 | .code .ne { color: #990000; font-weight: bold } /* Name.Exception */ | |
35 | .code .nf { color: #990000; font-weight: bold } /* Name.Function */ | |
36 | .code .nn { color: #555555 } /* Name.Namespace */ | |
37 | .code .nt { color: #000080 } /* Name.Tag */ | |
38 | .code .nv { color: #008080 } /* Name.Variable */ | |
39 | .code .ow { font-weight: bold } /* Operator.Word */ | |
40 | .code .w { color: #bbbbbb } /* Text.Whitespace */ | |
41 | .code .mf { color: #009999 } /* Literal.Number.Float */ | |
42 | .code .mh { color: #009999 } /* Literal.Number.Hex */ | |
43 | .code .mi { color: #009999 } /* Literal.Number.Integer */ | |
44 | .code .mo { color: #009999 } /* Literal.Number.Oct */ | |
45 | .code .sb { color: #bb8844 } /* Literal.String.Backtick */ | |
46 | .code .sc { color: #bb8844 } /* Literal.String.Char */ | |
47 | .code .sd { color: #bb8844 } /* Literal.String.Doc */ | |
48 | .code .s2 { color: #bb8844 } /* Literal.String.Double */ | |
49 | .code .se { color: #bb8844 } /* Literal.String.Escape */ | |
50 | .code .sh { color: #bb8844 } /* Literal.String.Heredoc */ | |
51 | .code .si { color: #bb8844 } /* Literal.String.Interpol */ | |
52 | .code .sx { color: #bb8844 } /* Literal.String.Other */ | |
53 | .code .sr { color: #808000 } /* Literal.String.Regex */ | |
54 | .code .s1 { color: #bb8844 } /* Literal.String.Single */ | |
55 | .code .ss { color: #bb8844 } /* Literal.String.Symbol */ | |
56 | .code .bp { color: #999999 } /* Name.Builtin.Pseudo */ | |
57 | .code .vc { color: #008080 } /* Name.Variable.Class */ | |
58 | .code .vg { color: #008080 } /* Name.Variable.Global */ | |
59 | .code .vi { color: #008080 } /* Name.Variable.Instance */ | |
60 | .code .il { color: #009999 } /* Literal.Number.Integer.Long */ |
0 | {% extends 'skeleton.html' %} | |
1 | ||
2 | {% block breadcrumbs %} | |
3 | <span> | |
4 | <a href="{{ url_for('history', repo=repo.name) }}">{{ repo.name }}</a> | |
5 | <span class=slash>/</span> | |
6 | <a href="{{ url_for('history', repo=repo.name, commit_id=commit_id) }}">{{ commit_id|shorten_sha1 }}</a> | |
7 | </span> | |
8 | ||
9 | {% if subpaths %} | |
10 | <span> | |
11 | {% for name, subpath in subpaths %} | |
12 | {% if loop.last %} | |
13 | <a href="">{{ name|force_unicode }}</a> | |
14 | {% else %} | |
15 | <a href="{{ url_for('history', repo=repo.name, commit_id=commit_id, path=subpath) }}">{{ name|force_unicode }}</a> | |
16 | <span class=slash>/</span> | |
17 | {% endif %} | |
18 | {% endfor %} | |
19 | </span> | |
20 | {% endif %} | |
21 | {% endblock %} | |
22 | ||
23 | {% block extra_header %} | |
24 | <div class=branch-selector> | |
25 | <span>{{ commit_id|shorten_sha1 }}</span> | |
26 | <ul> | |
27 | {% for branch in branches %} | |
28 | <li><a href="{{ url_for(view, repo=repo.name, commit_id=branch, path=path) }}">{{ branch }}</a></li> | |
29 | {% endfor %} | |
30 | </ul> | |
31 | </div> | |
32 | {% endblock %} |
0 | {% extends 'base.html' %} | |
1 | {% block content %} | |
2 | ||
3 | {% include 'tree.inc.html' %} | |
4 | ||
5 | {# TODO: move logic into view #} | |
6 | {% set history = repo.history(branch, path.strip('/'), history_length+1, skip) %} | |
7 | {% set has_more_commits = history|length == history_length+1 %} | |
8 | ||
9 | {% macro pagination() %} | |
10 | <div class=pagination> | |
11 | {% if page %} | |
12 | {% for n in previous_pages %} | |
13 | {% if n is none %} | |
14 | <span class=n>...</span> | |
15 | {% else %} | |
16 | <a href="?page={{n}}" class=n>{{ n }}</a> | |
17 | {% endif %} | |
18 | {% endfor %} | |
19 | {% endif %} | |
20 | {% if has_more_commits %} | |
21 | <a href="?page={{page+1}}">»»</a> | |
22 | {% else %} | |
23 | <span>»»</span> | |
24 | {% endif%} | |
25 | </div> | |
26 | <div class=clearfloat></div> | |
27 | {% endmacro %} | |
28 | ||
29 | <div> | |
30 | <div class=history> | |
31 | <h2> | |
32 | {% if subpaths %} | |
33 | History for | |
34 | {% for name, subpath in subpaths %} | |
35 | {{ name }} | |
36 | {% if not loop.last %} | |
37 | <span class=slash>/</span> | |
38 | {% endif %} | |
39 | {% endfor %} | |
40 | {% else %} | |
41 | Commit History | |
42 | {% endif %} | |
43 | <span> | |
44 | @<a href="{{ url_for(view, repo=repo.name, commit_id=branch) }}">{{ branch }}</a> | |
45 | </span> | |
46 | {% if USE_SMARTHTTP %} | |
47 | <code>git clone {{ url_for('history', repo=repo.name, _external=True) }}</code> | |
48 | {% endif %} | |
49 | </h2> | |
50 | ||
51 | {{ pagination() }} | |
52 | ||
53 | <ul> | |
54 | {% for commit in history %} | |
55 | {% if not loop.last or history|length < history_length %} | |
56 | <li> | |
57 | <a class=commit href="{{ url_for('commit', repo=repo.name, commit_id=commit.id) }}"> | |
58 | <span class=line1> | |
59 | <span>{{ commit.message|force_unicode|shorten_message }}</span> | |
60 | </span> | |
61 | <span class=line2> | |
62 | <span>{{ commit.author|force_unicode|extract_author_name }}</span> | |
63 | <span>{{ commit.commit_time|timesince }} ago</span> | |
64 | </span> | |
65 | <span class=clearfloat></span> | |
66 | </a> | |
67 | </li> | |
68 | {% endif %} | |
69 | {% endfor %} | |
70 | </ul> | |
71 | </div> | |
72 | ||
73 | {{ pagination() }} | |
74 | ||
75 | </div> | |
76 | ||
77 | {% endblock %} |
0 | {% extends 'skeleton.html' %} | |
1 | {% block content %} | |
2 | ||
3 | <h2> | |
4 | Repositories | |
5 | <span> | |
6 | (<a href=?by-last-update=yep>order by last update</a>) | |
7 | </span> | |
8 | </h2> | |
9 | <ul class=repolist> | |
10 | {% for repo in repos %} | |
11 | <li> | |
12 | <a href="{{ url_for('history', repo=repo.name) }}"> | |
13 | <span class=name>{{ repo.name }}</span> | |
14 | <span class=last-updated> | |
15 | {% set last_updated_at = repo.get_last_updated_at() %} | |
16 | {% if last_updated_at is not none %} | |
17 | last updated {{ last_updated_at|timesince }} ago | |
18 | {% else %} | |
19 | no commits yet | |
20 | {% endif %} | |
21 | </span></a> | |
22 | </li> | |
23 | {% endfor %} | |
24 | </ul> | |
25 | ||
26 | {% endblock %} |
0 | <!doctype html> | |
1 | <meta http-equiv="content-type" content="text/html; charset=utf-8"> | |
2 | <link rel="stylesheet" href={{ url_for('static', filename='pygments.css') }}> | |
3 | <link rel="stylesheet" href={{ url_for('static', filename='klaus.css') }}> | |
4 | <script src={{ url_for('static', filename='line-highlighter.js') }}></script> | |
5 | ||
6 | <header> | |
7 | <a href={{ url_for('repo_list') }}> | |
8 | {{ SITENAME }} | |
9 | </a> | |
10 | <span class=breadcrumbs>{% block breadcrumbs %}{% endblock %}</span> | |
11 | {% block extra_header %}{% endblock %} | |
12 | </header> | |
13 | ||
14 | <div id=content> | |
15 | {% block content %}{% endblock %} | |
16 | </div> | |
17 | ||
18 | <footer> | |
19 | powered by <a href="https://github.com/jonashaag/klaus">klaus</a> {{ KLAUS_VERSION }}, | |
20 | a simple Git viewer by Jonas Haag | |
21 | </footer> |
0 | <div class=tree> | |
1 | <h2>Tree @<a href="{{ url_for('commit', repo=repo.name, commit_id=commit_id) }}">{{ commit_id|shorten_sha1 }}</a></h2> | |
2 | <ul> | |
3 | {% for _, name, fullpath in tree.dirs %} | |
4 | <li><a href="{{ url_for('history', repo=repo.name, commit_id=commit_id, path=fullpath) }}" class=dir>{{ name|force_unicode }}</a></li> | |
5 | {% endfor %} | |
6 | {% for _, name, fullpath in tree.files %} | |
7 | <li><a href="{{ url_for('blob', repo=repo.name, commit_id=commit_id, path=fullpath) }}">{{ name|force_unicode }}</a></li> | |
8 | {% endfor %} | |
9 | </ul> | |
10 | </div> |
0 | {% extends 'base.html' %} | |
1 | {% block content %} | |
2 | ||
3 | {% include 'tree.inc.html' %} | |
4 | ||
5 | {% set raw_url = url_for('raw', repo=repo.name, commit_id=commit_id, path=path) %} | |
6 | ||
7 | <div class=blobview> | |
8 | <h2> | |
9 | {{ filename|force_unicode }} | |
10 | <span> | |
11 | @<a href="{{ url_for('commit', repo=repo.name, commit_id=commit_id) }}">{{ commit_id|shorten_sha1 }}</a> | |
12 | — | |
13 | {% if is_markup %} | |
14 | {% if render_markup %} | |
15 | <a href="?markup">view markup</a> | |
16 | {% else %} | |
17 | <a href="?">view rendered</a> | |
18 | {% endif %} | |
19 | · | |
20 | {% endif %} | |
21 | <a href="{{ raw_url }}">raw</a> | |
22 | · <a href="{{ url_for('history', repo=repo.name, commit_id=commit_id, path=path) }}">history</a> | |
23 | </span> | |
24 | </h2> | |
25 | {% if is_binary %} | |
26 | {% if is_image %} | |
27 | <a href="{{ raw_url }}"><img src="{{ raw_url }}"></a> | |
28 | {% else %} | |
29 | <div class=binary-warning>(Binary data not shown)</div> | |
30 | {% endif %} | |
31 | {% else %} | |
32 | {% if too_large %} | |
33 | <div class=too-large-warning>(Large file not shown)</div> | |
34 | {% else %} | |
35 | {% autoescape false %} | |
36 | {% if is_markup and render_markup %} | |
37 | <div class=markup>{{ rendered_code }}</div> | |
38 | {% else %} | |
39 | {{ rendered_code }} | |
40 | {% endif %} | |
41 | {% endautoescape %} | |
42 | {% endif %} | |
43 | {% endif %} | |
44 | </div> | |
45 | ||
46 | <script> | |
47 | highlight_linenos({ | |
48 | linksSelector: '.highlighttable .linenos a', | |
49 | getLineFromAnchor: function(anchor) { return anchor.nextSibling } | |
50 | }) | |
51 | </script> | |
52 | ||
53 | {% endblock %} |
0 | {% extends 'base.html' %} | |
1 | ||
2 | {% block extra_header %}{% endblock %} {# no branch selector on commits #} | |
3 | ||
4 | {% block content %} | |
5 | ||
6 | <div class=full-commit> | |
7 | ||
8 | <div class=commit> | |
9 | <span class=line1> | |
10 | <span>{{ commit.message|force_unicode }}</span> | |
11 | </span> | |
12 | <span class=line2> | |
13 | <span>{{ commit.author|force_unicode|extract_author_name }}</span> | |
14 | <span>{{ commit.commit_time|timesince }} ago</span> | |
15 | </span> | |
16 | <span class=clearfloat></span> | |
17 | </div> | |
18 | ||
19 | <div class=diff> | |
20 | {% for file in repo.commit_diff(commit) %} | |
21 | ||
22 | {% set fileno = loop.index0 %} | |
23 | ||
24 | <div class=filename> | |
25 | {# TODO dulwich doesn't do rename recognition | |
26 | {% if file.old_filename != file.new_filename %} | |
27 | {{ file.old_filename }} → | |
28 | {% endif %}#} | |
29 | {% if file.new_filename == '/dev/null' %} | |
30 | <del>{{ file.old_filename|force_unicode }}</del> | |
31 | {% else %} | |
32 | <a href="{{ url_for('blob', repo=repo.name, commit_id=commit_id, path=file.new_filename) }}"> | |
33 | {{ file.new_filename|force_unicode }} | |
34 | </a> | |
35 | {% endif %} | |
36 | </div> | |
37 | ||
38 | {% if file.chunks %} | |
39 | ||
40 | <table> | |
41 | {% for chunk in file.chunks %} | |
42 | ||
43 | {%- for line in chunk -%} | |
44 | <tr> | |
45 | ||
46 | {#- left column: linenos -#} | |
47 | {%- if line.old_lineno -%} | |
48 | <td class=linenos><a href="#{{fileno}}-L-{{line.old_lineno}}">{{ line.old_lineno }}</a></td> | |
49 | {%- if line.new_lineno -%} | |
50 | <td class=linenos><a href="#{{fileno}}-L-{{line.old_lineno}}">{{ line.new_lineno }}</a></td> | |
51 | {%- else -%} | |
52 | <td class=linenos></td> | |
53 | {%- endif -%} | |
54 | {%- else %} | |
55 | {%- if line.old_lineno -%} | |
56 | <td class=linenos><a href="#{{fileno}}-R-{{line.old_lineno}}">{{ line.new_lineno }}</a></td> | |
57 | {%- else -%} | |
58 | <td class=linenos></td> | |
59 | {%- endif -%} | |
60 | <td class=linenos><a href="#{{fileno}}-R-{{line.new_lineno}}">{{ line.new_lineno }}</a></td> | |
61 | {% endif %} | |
62 | ||
63 | {#- right column: code -#} | |
64 | <td class={{line.action}}> | |
65 | {#- lineno anchors -#} | |
66 | {%- if line.old_lineno -%} | |
67 | <a name="{{fileno}}-L-{{line.old_lineno}}"></a> | |
68 | {%- else -%} | |
69 | <a name="{{fileno}}-R-{{line.new_lineno}}"></a> | |
70 | {%- endif -%} | |
71 | ||
72 | {#- the actual line of code -#} | |
73 | <span class=line>{% autoescape false %}{{ line.line|force_unicode }}{% endautoescape %}</span> | |
74 | </td> | |
75 | ||
76 | </tr> | |
77 | {%- endfor -%} {# lines #} | |
78 | ||
79 | {% if not loop.last %} | |
80 | <tr class=sep> | |
81 | <td colspan=3></td> | |
82 | </tr> | |
83 | {% endif %} | |
84 | ||
85 | {%- endfor -%} {# chunks #} | |
86 | </table> | |
87 | ||
88 | {% else %} | |
89 | <div class=binarydiff>Binary diff not shown</div> | |
90 | {% endif %} | |
91 | ||
92 | {% endfor %} | |
93 | </div> | |
94 | ||
95 | </div> | |
96 | ||
97 | <script> | |
98 | highlight_linenos({ | |
99 | linksSelector: '.linenos a', | |
100 | getLineFromAnchor: function(anchor) { | |
101 | /* If we got the first (old_lineno) anchor, the span we're looking for is | |
102 | the second-next sibling, otherwise it's the next. */ | |
103 | if (anchor.nextSibling instanceof HTMLSpanElement) | |
104 | return anchor.nextSibling; | |
105 | else | |
106 | return anchor.nextSibling.nextSibling; | |
107 | } | |
108 | }); | |
109 | </script> | |
110 | ||
111 | {% endblock %} |
0 | # encoding: utf-8 | |
1 | ||
2 | import re | |
3 | import time | |
4 | import mimetypes | |
5 | ||
6 | from pygments import highlight | |
7 | from pygments.lexers import get_lexer_for_filename, guess_lexer, ClassNotFound | |
8 | from pygments.formatters import HtmlFormatter | |
9 | ||
10 | from klaus import markup | |
11 | ||
12 | ||
13 | class SubUri(object): | |
14 | """ | |
15 | WSGI middleware that tweaks the WSGI environ so that it's possible to serve | |
16 | the wrapped app (klaus) under a sub-URL and/or to use a different HTTP | |
17 | scheme (http:// vs. https://) for proxy communication. | |
18 | ||
19 | This is done by making your proxy pass appropriate HTTP_X_SCRIPT_NAME and | |
20 | HTTP_X_SCHEME headers. | |
21 | ||
22 | For instance if you have klaus mounted under /git/ and your site uses SSL | |
23 | (but your proxy doesn't), make it pass :: | |
24 | ||
25 | X-Script-Name = '/git' | |
26 | X-Scheme = 'https' | |
27 | ||
28 | Snippet stolen from http://flask.pocoo.org/snippets/35/ | |
29 | """ | |
30 | def __init__(self, app): | |
31 | self.app = app | |
32 | ||
33 | def __call__(self, environ, start_response): | |
34 | script_name = environ.get('HTTP_X_SCRIPT_NAME', '') | |
35 | if script_name: | |
36 | environ['SCRIPT_NAME'] = script_name.rstrip('/') | |
37 | ||
38 | if script_name and environ['PATH_INFO'].startswith(script_name): | |
39 | # strip `script_name` from PATH_INFO | |
40 | environ['PATH_INFO'] = environ['PATH_INFO'][len(script_name):] | |
41 | ||
42 | if 'HTTP_X_SCHEME' in environ: | |
43 | environ['wsgi.url_scheme'] = environ['HTTP_X_SCHEME'] | |
44 | ||
45 | return self.app(environ, start_response) | |
46 | ||
47 | ||
48 | class KlausFormatter(HtmlFormatter): | |
49 | def __init__(self): | |
50 | HtmlFormatter.__init__(self, linenos='table', lineanchors='L', | |
51 | anchorlinenos=True) | |
52 | ||
53 | def _format_lines(self, tokensource): | |
54 | for tag, line in HtmlFormatter._format_lines(self, tokensource): | |
55 | if tag == 1: | |
56 | # sourcecode line | |
57 | line = '<span class=line>%s</span>' % line | |
58 | yield tag, line | |
59 | ||
60 | ||
61 | def pygmentize(code, filename=None, render_markup=True): | |
62 | """ | |
63 | Renders code using Pygments, markup (markdown, rst, ...) using the | |
64 | corresponding renderer, if available. | |
65 | """ | |
66 | if render_markup and markup.can_render(filename): | |
67 | return markup.render(filename, code) | |
68 | ||
69 | try: | |
70 | lexer = get_lexer_for_filename(filename) | |
71 | except ClassNotFound: | |
72 | lexer = guess_lexer(code) | |
73 | ||
74 | return highlight(code, lexer, KlausFormatter()) | |
75 | ||
76 | ||
77 | def timesince(when, now=time.time): | |
78 | """ Returns the difference between `when` and `now` in human readable form. """ | |
79 | # TODO: rewrite this mess | |
80 | delta = now() - when | |
81 | result = [] | |
82 | break_next = False | |
83 | for unit, seconds, break_immediately in [ | |
84 | ('year', 365*24*60*60, False), | |
85 | ('month', 30*24*60*60, False), | |
86 | ('week', 7*24*60*60, False), | |
87 | ('day', 24*60*60, True), | |
88 | ('hour', 60*60, False), | |
89 | ('minute', 60, True), | |
90 | ('second', 1, False), | |
91 | ]: | |
92 | if delta > seconds: | |
93 | n = int(delta/seconds) | |
94 | delta -= n*seconds | |
95 | result.append((n, unit)) | |
96 | if break_immediately: | |
97 | break | |
98 | if not break_next: | |
99 | break_next = True | |
100 | continue | |
101 | if break_next: | |
102 | break | |
103 | ||
104 | if len(result) > 1: | |
105 | n, unit = result[0] | |
106 | if unit == 'month': | |
107 | if n == 1: | |
108 | # 1 month, 3 weeks --> 7 weeks | |
109 | result = [(result[1][0] + 4, 'week')] | |
110 | else: | |
111 | # 2 months, 1 week -> 2 months | |
112 | result = result[:1] | |
113 | elif unit == 'hour' and n > 5: | |
114 | result = result[:1] | |
115 | ||
116 | return ', '.join('%d %s%s' % (n, unit, 's' if n != 1 else '') | |
117 | for n, unit in result[:2]) | |
118 | ||
119 | ||
120 | def guess_is_binary(dulwich_blob): | |
121 | return any('\0' in chunk for chunk in dulwich_blob.chunked) | |
122 | ||
123 | ||
124 | def guess_is_image(filename): | |
125 | mime, _ = mimetypes.guess_type(filename) | |
126 | if mime is None: | |
127 | return False | |
128 | return mime.startswith('image/') | |
129 | ||
130 | ||
131 | def force_unicode(s): | |
132 | """ Does all kind of magic to turn `s` into unicode """ | |
133 | if isinstance(s, unicode): | |
134 | return s | |
135 | try: | |
136 | return s.decode('utf-8') | |
137 | except UnicodeDecodeError as exc: | |
138 | pass | |
139 | try: | |
140 | return s.decode('iso-8859-1') | |
141 | except UnicodeDecodeError: | |
142 | pass | |
143 | try: | |
144 | import chardet | |
145 | encoding = chardet.detect(s)['encoding'] | |
146 | if encoding is not None: | |
147 | return s.decode(encoding) | |
148 | except (ImportError, UnicodeDecodeError): | |
149 | raise exc | |
150 | ||
151 | ||
152 | def extract_author_name(email): | |
153 | """ | |
154 | Extracts the name from an email address... | |
155 | >>> extract_author_name("John <john@example.com>") | |
156 | "John" | |
157 | ||
158 | ... or returns the address if none is given. | |
159 | >>> extract_author_name("noname@example.com") | |
160 | "noname@example.com" | |
161 | """ | |
162 | match = re.match('^(.*?)<.*?>$', email) | |
163 | if match: | |
164 | return match.group(1).strip() | |
165 | return email | |
166 | ||
167 | ||
168 | def shorten_sha1(sha1): | |
169 | if re.match('[a-z\d]{20,40}', sha1): | |
170 | sha1 = sha1[:10] | |
171 | return sha1 | |
172 | ||
173 | ||
174 | def subpaths(path): | |
175 | """ | |
176 | Yields a `(last part, subpath)` tuple for all possible sub-paths of `path`. | |
177 | ||
178 | >>> list(subpaths("foo/bar/spam")) | |
179 | [('foo', 'foo'), ('bar', 'foo/bar'), ('spam', 'foo/bar/spam')] | |
180 | """ | |
181 | seen = [] | |
182 | for part in path.split('/'): | |
183 | seen.append(part) | |
184 | yield part, '/'.join(seen) | |
185 | ||
186 | ||
187 | def shorten_message(msg): | |
188 | return msg.split('\n')[0] | |
189 | ||
190 | ||
191 | def get_mimetype_and_encoding(blob, filename): | |
192 | mime, encoding = mimetypes.guess_type(filename) | |
193 | if mime and mime.startswith('text/'): | |
194 | mime = 'text/plain' | |
195 | return mime, encoding | |
196 | ||
197 | ||
198 | try: | |
199 | from subprocess import check_output | |
200 | except ImportError: | |
201 | # Python < 2.7 fallback, stolen from the 2.7 stdlib | |
202 | def check_output(*popenargs, **kwargs): | |
203 | from subprocess import Popen, PIPE, CalledProcessError | |
204 | if 'stdout' in kwargs: | |
205 | raise ValueError('stdout argument not allowed, it will be overridden.') | |
206 | process = Popen(stdout=PIPE, *popenargs, **kwargs) | |
207 | output, _ = process.communicate() | |
208 | retcode = process.poll() | |
209 | if retcode: | |
210 | cmd = kwargs.get("args") | |
211 | if cmd is None: | |
212 | cmd = popenargs[0] | |
213 | raise CalledProcessError(retcode, cmd, output=output) | |
214 | return output |
0 | import os | |
1 | import stat | |
2 | ||
3 | from flask import request, render_template, current_app | |
4 | from flask.views import View | |
5 | ||
6 | from werkzeug.wrappers import Response | |
7 | from werkzeug.exceptions import NotFound | |
8 | ||
9 | from klaus import markup | |
10 | from klaus.utils import subpaths, get_mimetype_and_encoding, pygmentize, \ | |
11 | force_unicode, guess_is_binary, guess_is_image | |
12 | ||
13 | ||
14 | def repo_list(): | |
15 | """Shows a list of all repos and can be sorted by last update. """ | |
16 | if 'by-last-update' in request.args: | |
17 | sort_key = lambda repo: repo.get_last_updated_at() | |
18 | reverse = True | |
19 | else: | |
20 | sort_key = lambda repo: repo.name | |
21 | reverse = False | |
22 | repos = sorted(current_app.repos, key=sort_key, reverse=reverse) | |
23 | return render_template('repo_list.html', repos=repos) | |
24 | ||
25 | ||
26 | class BaseRepoView(View): | |
27 | """ | |
28 | Base for all views with a repo context. | |
29 | ||
30 | The arguments `repo`, `commit_id`, `path` (see `dispatch_request`) define | |
31 | the repository, branch/commit and directory/file context, respectively -- | |
32 | that is, they specify what (and in what state) is being displayed in all the | |
33 | derived views. | |
34 | ||
35 | For example: The 'history' view is the `git log` equivalent, i.e. if `path` | |
36 | is "/foo/bar", only commits related to "/foo/bar" are displayed, and if | |
37 | `commit_id` is "master", the history of the "master" branch is displayed. | |
38 | """ | |
39 | def __init__(self, view_name, template_name=None): | |
40 | self.view_name = view_name | |
41 | self.template_name = template_name | |
42 | self.context = {} | |
43 | ||
44 | def dispatch_request(self, repo, commit_id=None, path=''): | |
45 | self.make_context(repo, commit_id, path) | |
46 | return self.get_response() | |
47 | ||
48 | def get_response(self): | |
49 | return render_template(self.template_name, **self.context) | |
50 | ||
51 | def make_context(self, repo, commit_id, path): | |
52 | try: | |
53 | repo = current_app.repo_map[repo] | |
54 | if commit_id is None: | |
55 | commit_id = repo.get_default_branch() | |
56 | commit, isbranch = repo.get_branch_or_commit(commit_id) | |
57 | except KeyError: | |
58 | raise NotFound | |
59 | ||
60 | self.context = { | |
61 | 'view': self.view_name, | |
62 | 'repo': repo, | |
63 | 'commit_id': commit_id, | |
64 | 'commit': commit, | |
65 | 'branch': commit_id if isbranch else 'master', | |
66 | 'branches': repo.get_branch_names(exclude=[commit_id]), | |
67 | 'path': path, | |
68 | 'subpaths': subpaths(path) if path else None, | |
69 | } | |
70 | ||
71 | ||
72 | class TreeViewMixin(object): | |
73 | """ | |
74 | Implements the logic required for displaying the current directory in the sidebar | |
75 | """ | |
76 | def make_context(self, *args): | |
77 | super(TreeViewMixin, self).make_context(*args) | |
78 | self.context['tree'] = self.listdir() | |
79 | ||
80 | def listdir(self): | |
81 | """ | |
82 | Returns a list of directories and files in the current path of the | |
83 | selected commit | |
84 | """ | |
85 | dirs, files = [], [] | |
86 | root = self.get_directory() | |
87 | try: | |
88 | tree = self.context['repo'].get_tree(self.context['commit'], root) | |
89 | except KeyError: | |
90 | raise NotFound | |
91 | ||
92 | for entry in tree.iteritems(): | |
93 | name, entry = entry.path, entry.in_path(root) | |
94 | if entry.mode & stat.S_IFDIR: | |
95 | dirs.append((name.lower(), name, entry.path)) | |
96 | else: | |
97 | files.append((name.lower(), name, entry.path)) | |
98 | ||
99 | files.sort() | |
100 | dirs.sort() | |
101 | ||
102 | if root: | |
103 | dirs.insert(0, (None, '..', os.path.split(root)[0])) | |
104 | ||
105 | return {'dirs' : dirs, 'files' : files} | |
106 | ||
107 | def get_directory(self): | |
108 | return self.context['path'] | |
109 | ||
110 | ||
111 | class HistoryView(TreeViewMixin, BaseRepoView): | |
112 | """ Show commits of a branch + path, just like `git log`. With pagination. """ | |
113 | def make_context(self, *args): | |
114 | super(HistoryView, self).make_context(*args) | |
115 | ||
116 | try: | |
117 | page = int(request.args.get('page')) | |
118 | except (TypeError, ValueError): | |
119 | page = 0 | |
120 | ||
121 | self.context['page'] = page | |
122 | ||
123 | if page: | |
124 | self.context['history_length'] = 30 | |
125 | self.context['skip'] = (self.context['page']-1) * 30 + 10 | |
126 | if page > 7: | |
127 | self.context['previous_pages'] = [0, 1, 2, None] + range(page)[-3:] | |
128 | else: | |
129 | self.context['previous_pages'] = xrange(page) | |
130 | else: | |
131 | self.context['history_length'] = 10 | |
132 | self.context['skip'] = 0 | |
133 | ||
134 | ||
135 | class BlobViewMixin(object): | |
136 | def make_context(self, *args): | |
137 | super(BlobViewMixin, self).make_context(*args) | |
138 | self.context['filename'] = os.path.basename(self.context['path']) | |
139 | self.context['blob'] = self.context['repo'].get_tree(self.context['commit'], | |
140 | self.context['path']) | |
141 | ||
142 | ||
143 | class BlobView(BlobViewMixin, TreeViewMixin, BaseRepoView): | |
144 | """ Shows a file rendered using ``pygmentize`` """ | |
145 | def make_context(self, *args): | |
146 | super(BlobView, self).make_context(*args) | |
147 | render_markup = 'markup' not in request.args | |
148 | rendered_code = pygmentize( | |
149 | force_unicode(self.context['blob'].data), | |
150 | self.context['filename'], | |
151 | render_markup | |
152 | ) | |
153 | self.context.update({ | |
154 | 'too_large': sum(map(len, self.context['blob'].chunked)) > 100*1024, | |
155 | 'is_markup': markup.can_render(self.context['filename']), | |
156 | 'render_markup': render_markup, | |
157 | 'rendered_code': rendered_code, | |
158 | 'is_binary': guess_is_binary(self.context['blob']), | |
159 | 'is_image': guess_is_image(self.context['filename']), | |
160 | }) | |
161 | ||
162 | def get_directory(self): | |
163 | return os.path.split(self.context['path'])[0] | |
164 | ||
165 | ||
166 | class RawView(BlobViewMixin, BaseRepoView): | |
167 | """ | |
168 | Shows a single file in raw for (as if it were a normal filesystem file | |
169 | served through a static file server) | |
170 | """ | |
171 | def get_response(self): | |
172 | chunks = self.context['blob'].chunked | |
173 | ||
174 | if len(chunks) == 1 and not chunks[0]: | |
175 | # empty file | |
176 | chunks = [] | |
177 | mime = 'text/plain' | |
178 | encoding = 'utf-8' | |
179 | else: | |
180 | mime, encoding = get_mimetype_and_encoding(chunks, self.context['filename']) | |
181 | ||
182 | headers = {'Content-Type': mime} | |
183 | if encoding: | |
184 | headers['Content-Encoding'] = encoding | |
185 | ||
186 | return Response(chunks, headers=headers) | |
187 | ||
188 | ||
189 | # TODO v | |
190 | history = HistoryView.as_view('history', 'history', 'history.html') | |
191 | commit = BaseRepoView.as_view('commit', 'commit', 'view_commit.html') | |
192 | blob = BlobView.as_view('blob', 'blob', 'view_blob.html') | |
193 | raw = RawView.as_view('raw', 'raw') |
0 | import os | |
1 | from klaus import make_app | |
2 | ||
3 | application = make_app( | |
4 | os.environ['KLAUS_REPOS'].split(), | |
5 | os.environ['KLAUS_SITE_TITLE'], | |
6 | os.environ.get('KLAUS_USE_SMARTHTTP'), | |
7 | os.environ.get('KLAUS_HTDIGEST_FILE'), | |
8 | ) |
0 | import sys | |
1 | import os | |
2 | import re | |
3 | import stat | |
4 | import time | |
5 | import urlparse | |
6 | import mimetypes | |
7 | from future_builtins import map | |
8 | from functools import wraps | |
9 | ||
10 | from dulwich.objects import Commit, Blob | |
11 | ||
12 | from jinja2 import Environment, FileSystemLoader | |
13 | ||
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 | |
18 | ||
19 | from nano import NanoApplication, HttpError | |
20 | from repo import Repo | |
21 | ||
22 | ||
23 | KLAUS_ROOT = os.path.join(os.path.dirname(__file__)) | |
24 | TEMPLATE_DIR = os.path.join(KLAUS_ROOT, 'templates') | |
25 | ||
26 | try: | |
27 | KLAUS_VERSION = ' ' + open(os.path.join(KLAUS_ROOT, '.git/refs/heads/master')).read()[:7] | |
28 | except IOError: | |
29 | KLAUS_VERSION = '' | |
30 | ||
31 | ||
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()) | |
35 | ||
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), | |
40 | extensions=['jinja2.ext.autoescape'], | |
41 | autoescape=True) | |
42 | self.jinja_env.globals['build_url'] = self.build_url | |
43 | 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 | |
70 | ||
71 | def render_template(self, template_name, **kwargs): | |
72 | return self.jinja_env.get_template(template_name).render(**kwargs) | |
73 | ||
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 | ) | |
80 | ||
81 | def pygmentize(code, filename=None, language=None): | |
82 | if language: | |
83 | lexer = get_lexer_by_name(language) | |
84 | else: | |
85 | try: | |
86 | lexer = get_lexer_for_filename(filename) | |
87 | except ClassNotFound: | |
88 | lexer = guess_lexer(code) | |
89 | ||
90 | return highlight(code, lexer, KlausFormatter()) | |
91 | ||
92 | ||
93 | class KlausFormatter(HtmlFormatter): | |
94 | def __init__(self): | |
95 | HtmlFormatter.__init__(self, linenos='table', lineanchors='L', | |
96 | anchorlinenos=True) | |
97 | ||
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 | |
104 | ||
105 | ||
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 | |
131 | ||
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] | |
143 | ||
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') |
0 | import sys | |
1 | import os | |
2 | import re | |
3 | import stat | |
4 | import time | |
5 | import urlparse | |
6 | import mimetypes | |
7 | from future_builtins import map | |
8 | from functools import wraps | |
9 | ||
10 | from dulwich.objects import Commit, Blob | |
11 | ||
12 | from jinja2 import Environment, FileSystemLoader | |
13 | ||
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 | |
18 | ||
19 | from nano import NanoApplication, HttpError | |
20 | from repo import Repo | |
21 | ||
22 | ||
23 | KLAUS_ROOT = os.path.join(os.path.dirname(__file__)) | |
24 | TEMPLATE_DIR = os.path.join(KLAUS_ROOT, 'templates') | |
25 | ||
26 | try: | |
27 | KLAUS_VERSION = ' ' + open(os.path.join(KLAUS_ROOT, '.git/refs/heads/master')).read()[:7] | |
28 | except IOError: | |
29 | KLAUS_VERSION = '' | |
30 | ||
31 | ||
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()) | |
35 | ||
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), | |
40 | extensions=['jinja2.ext.autoescape'], | |
41 | autoescape=True) | |
42 | self.jinja_env.globals['build_url'] = self.build_url | |
43 | 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 | |
70 | ||
71 | def render_template(self, template_name, **kwargs): | |
72 | return self.jinja_env.get_template(template_name).render(**kwargs) | |
73 | ||
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 | ) | |
80 | ||
81 | def pygmentize(code, filename=None, language=None): | |
82 | if language: | |
83 | lexer = get_lexer_by_name(language) | |
84 | else: | |
85 | try: | |
86 | lexer = get_lexer_for_filename(filename) | |
87 | except ClassNotFound: | |
88 | lexer = guess_lexer(code) | |
89 | ||
90 | return highlight(code, lexer, KlausFormatter()) | |
91 | ||
92 | ||
93 | class KlausFormatter(HtmlFormatter): | |
94 | def __init__(self): | |
95 | HtmlFormatter.__init__(self, linenos='table', lineanchors='L', | |
96 | anchorlinenos=True) | |
97 | ||
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 | |
104 | ||
105 | ||
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 | |
131 | ||
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] | |
143 | ||
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') |
0 | import os | |
1 | import itertools | |
2 | import cStringIO | |
3 | import subprocess | |
4 | ||
5 | import dulwich, dulwich.patch | |
6 | from diff import prepare_udiff | |
7 | ||
8 | def pairwise(iterable): | |
9 | """ | |
10 | Yields the items in `iterable` pairwise: | |
11 | ||
12 | >>> list(pairwise(['a', 'b', 'c', 'd'])) | |
13 | [('a', 'b'), ('b', 'c'), ('c', 'd')] | |
14 | """ | |
15 | prev = None | |
16 | for item in iterable: | |
17 | if prev is not None: | |
18 | yield prev, item | |
19 | prev = item | |
20 | ||
21 | class RepoWrapper(dulwich.repo.Repo): | |
22 | def get_branch_or_commit(self, id): | |
23 | """ | |
24 | Returns a `(commit_object, is_branch)` tuple for the commit or branch | |
25 | identified by `id`. | |
26 | """ | |
27 | try: | |
28 | return self[id], False | |
29 | except KeyError: | |
30 | return self.get_branch(id), True | |
31 | ||
32 | def get_branch(self, name): | |
33 | """ Returns the commit object pointed to by the branch `name`. """ | |
34 | return self['refs/heads/'+name] | |
35 | ||
36 | def get_default_branch(self): | |
37 | return self.get_branch('master') | |
38 | ||
39 | def get_branch_names(self, exclude=()): | |
40 | """ Returns a sorted list of branch names. """ | |
41 | branches = [] | |
42 | for ref in self.get_refs(): | |
43 | if ref.startswith('refs/heads/'): | |
44 | name = ref[len('refs/heads/'):] | |
45 | if name not in exclude: | |
46 | branches.append(name) | |
47 | branches.sort() | |
48 | return branches | |
49 | ||
50 | def get_tag_names(self): | |
51 | """ Returns a sorted list of tag names. """ | |
52 | tags = [] | |
53 | for ref in self.get_refs(): | |
54 | if ref.startswith('refs/tags/'): | |
55 | tags.append(ref[len('refs/tags/'):]) | |
56 | tags.sort() | |
57 | return tags | |
58 | ||
59 | def history(self, commit, path=None, max_commits=None, skip=0): | |
60 | """ | |
61 | Returns a list of all commits that infected `path`, starting at branch | |
62 | or commit `commit`. `skip` can be used for pagination, `max_commits` | |
63 | to limit the number of commits returned. | |
64 | ||
65 | Similar to `git log [branch/commit] [--skip skip] [-n max_commits]`. | |
66 | """ | |
67 | # XXX The pure-Python/dulwich code is very slow compared to `git log` | |
68 | # at the time of this writing (Oct 2011). | |
69 | # For instance, `git log .tx` in the Django root directory takes | |
70 | # about 0.15s on my machine whereas the history() method needs 5s. | |
71 | # Therefore we use `git log` here unless dulwich gets faster. | |
72 | ||
73 | cmd = ['git', 'log', '--format=%H'] | |
74 | if skip: | |
75 | cmd.append('--skip=%d' % skip) | |
76 | if max_commits: | |
77 | cmd.append('--max-count=%d' % max_commits) | |
78 | cmd.append(commit) | |
79 | if path: | |
80 | cmd.extend(['--', path]) | |
81 | ||
82 | # sha1_sums = subprocess.check_output(cmd, cwd=os.path.abspath(self.path)) | |
83 | # Can't use 'check_output' for Python 2.6 compatibility reasons | |
84 | sha1_sums = subprocess.Popen(cmd, cwd=os.path.abspath(self.path), | |
85 | stdout=subprocess.PIPE).communicate()[0] | |
86 | return [self[sha1] for sha1 in sha1_sums.strip().split('\n')] | |
87 | # | |
88 | # if not isinstance(commit, dulwich.objects.Commit): | |
89 | # commit, _ = self.get_branch_or_commit(commit) | |
90 | # commits = self._history(commit) | |
91 | # path = path.strip('/') | |
92 | # if path: | |
93 | # commits = (c1 for c1, c2 in pairwise(commits) | |
94 | # if self._path_changed_between(path, c1, c2)) | |
95 | # return list(itertools.islice(commits, skip, skip+max_commits)) | |
96 | ||
97 | # def _history(self, commit): | |
98 | # """ Yields all commits that lead to `commit`. """ | |
99 | # if commit is None: | |
100 | # commit = self.get_default_branch() | |
101 | # while commit.parents: | |
102 | # yield commit | |
103 | # commit = self[commit.parents[0]] | |
104 | # yield commit | |
105 | ||
106 | # def _path_changed_between(self, path, commit1, commit2): | |
107 | # """ | |
108 | # Returns `True` if `path` changed between `commit1` and `commit2`, | |
109 | # including the case that the file was added or deleted in `commit2`. | |
110 | # """ | |
111 | # path, filename = os.path.split(path) | |
112 | # try: | |
113 | # blob1 = self.get_tree(commit1, path) | |
114 | # if not isinstance(blob1, dulwich.objects.Tree): | |
115 | # return True | |
116 | # blob1 = blob1[filename] | |
117 | # except KeyError: | |
118 | # blob1 = None | |
119 | # try: | |
120 | # blob2 = self.get_tree(commit2, path) | |
121 | # if not isinstance(blob2, dulwich.objects.Tree): | |
122 | # return True | |
123 | # blob2 = blob2[filename] | |
124 | # except KeyError: | |
125 | # blob2 = None | |
126 | # if blob1 is None and blob2 is None: | |
127 | # # file present in neither tree | |
128 | # return False | |
129 | # return blob1 != blob2 | |
130 | ||
131 | def get_tree(self, commit, path, noblobs=False): | |
132 | """ Returns the Git tree object for `path` at `commit`. """ | |
133 | tree = self[commit.tree] | |
134 | if path: | |
135 | for directory in path.strip('/').split('/'): | |
136 | if directory: | |
137 | tree = self[tree[directory][1]] | |
138 | return tree | |
139 | ||
140 | def commit_diff(self, commit): | |
141 | from klaus import guess_is_binary, force_unicode | |
142 | ||
143 | if commit.parents: | |
144 | parent_tree = self[commit.parents[0]].tree | |
145 | else: | |
146 | parent_tree = None | |
147 | ||
148 | changes = self.object_store.tree_changes(parent_tree, commit.tree) | |
149 | for (oldpath, newpath), (oldmode, newmode), (oldsha, newsha) in changes: | |
150 | try: | |
151 | if newsha and guess_is_binary(self[newsha].chunked) or \ | |
152 | oldsha and guess_is_binary(self[oldsha].chunked): | |
153 | yield { | |
154 | 'is_binary': True, | |
155 | 'old_filename': oldpath or '/dev/null', | |
156 | 'new_filename': newpath or '/dev/null', | |
157 | 'chunks': [[{'line' : 'Binary diff not shown'}]] | |
158 | } | |
159 | continue | |
160 | except KeyError: | |
161 | # newsha/oldsha are probably related to submodules. | |
162 | # Dulwich will handle that. | |
163 | pass | |
164 | ||
165 | stringio = cStringIO.StringIO() | |
166 | dulwich.patch.write_object_diff(stringio, self.object_store, | |
167 | (oldpath, oldmode, oldsha), | |
168 | (newpath, newmode, newsha)) | |
169 | files = prepare_udiff(force_unicode(stringio.getvalue()), | |
170 | want_header=False) | |
171 | if not files: | |
172 | # the diff module doesn't handle deletions/additions | |
173 | # of empty files correctly. | |
174 | yield { | |
175 | 'old_filename': oldpath or '/dev/null', | |
176 | 'new_filename': newpath or '/dev/null', | |
177 | 'chunks': [] | |
178 | } | |
179 | else: | |
180 | yield files[0] | |
181 | ||
182 | ||
183 | def Repo(name, path, _cache={}): | |
184 | repo = _cache.get(path) | |
185 | if repo is None: | |
186 | repo = _cache[path] = RepoWrapper(path) | |
187 | repo.name = name | |
188 | return repo |
0 | # encoding: utf-8 | |
1 | ||
2 | import glob | |
3 | from distutils.core import setup | |
4 | ||
5 | ||
6 | def install_data_files_hack(): | |
7 | # This is a clever hack to circumvent distutil's data_files | |
8 | # policy "install once, find never". Definitely a TODO! | |
9 | # -- https://groups.google.com/group/comp.lang.python/msg/2105ee4d9e8042cb | |
10 | from distutils.command.install import INSTALL_SCHEMES | |
11 | for scheme in INSTALL_SCHEMES.values(): | |
12 | scheme['data'] = scheme['purelib'] | |
13 | ||
14 | ||
15 | install_data_files_hack() | |
16 | ||
17 | requires = ['flask', 'pygments', 'dulwich', 'httpauth'] | |
18 | ||
19 | try: | |
20 | import argparse # not available for Python 2.6 | |
21 | except ImportError: | |
22 | requires.append('argparse') | |
23 | ||
24 | ||
25 | setup( | |
26 | name='klaus', | |
27 | version='0.2', | |
28 | author='Jonas Haag', | |
29 | author_email='jonas@lophus.org', | |
30 | packages=['klaus'], | |
31 | scripts=['bin/klaus'], | |
32 | data_files=[ | |
33 | ['klaus/templates', glob.glob('klaus/templates/*')], | |
34 | ['klaus/static', glob.glob('klaus/static/*')], | |
35 | ], | |
36 | url='https://github.com/jonashaag/klaus', | |
37 | license='2-clause BSD', | |
38 | description='The first Git web viewer that Just Works™.', | |
39 | long_description=__doc__, | |
40 | classifiers=[ | |
41 | "Development Status :: 5 - Production/Stable", | |
42 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", | |
43 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", | |
44 | "Topic :: Software Development :: Version Control", | |
45 | "Environment :: Web Environment", | |
46 | "Intended Audience :: Developers", | |
47 | "License :: OSI Approved :: ICS License", | |
48 | "Programming Language :: Python", | |
49 | "Programming Language :: Python :: 2.6", | |
50 | "Programming Language :: Python :: 2.7", | |
51 | ], | |
52 | install_requires=requires, | |
53 | ) | |
54 |
0 | @charset "utf-8"; | |
1 | ||
2 | body { margin: 0; padding: 0; font-family: sans-serif; } | |
3 | pre { line-height: 125%; } | |
4 | a, a:visited { color: #003278; text-decoration: none; } | |
5 | a:hover { text-decoration: underline; } | |
6 | ul { list-style-type: none; padding-left: 0; } | |
7 | table { border-spacing: 0; border-collapse: collapse; } | |
8 | ||
9 | h2 > span:last-of-type { font-size: 60%; } | |
10 | ||
11 | .slash { color: #666; margin: 0 -0.2em; } | |
12 | ||
13 | .clearfloat { clear: both; } | |
14 | ||
15 | ||
16 | /* Header */ | |
17 | header { font-size: 90%; padding: 8px 10px; border-bottom: 3px solid #e0e0e0; } | |
18 | header > a { padding: 10px 0; } | |
19 | header .breadcrumbs > span:before { content: ' » '; color: #666; } | |
20 | header .slash { margin: 0 -3px; } | |
21 | ||
22 | .branch-selector { | |
23 | position: absolute; | |
24 | top: 5px; | |
25 | right: 4px; | |
26 | font-size: 90%; | |
27 | } | |
28 | .branch-selector > * { | |
29 | background-color: #fcfcfc; | |
30 | position: relative; | |
31 | } | |
32 | .branch-selector span { | |
33 | z-index: 2; | |
34 | border: 1px solid #f1f1f1; | |
35 | padding: 3px 5px; | |
36 | float: right; | |
37 | } | |
38 | .branch-selector span:after { content: "☟"; margin-left: 5px; } | |
39 | .branch-selector span:hover { background-color: #fefefe; cursor: pointer; } | |
40 | .branch-selector ul { | |
41 | z-index: 1; | |
42 | top: -1px; | |
43 | clear: both; | |
44 | border: 1px solid #ccc; | |
45 | display: none; | |
46 | } | |
47 | .branch-selector li a { | |
48 | display: block; | |
49 | padding: 5px; | |
50 | border-bottom: 1px solid #f1f1f1; | |
51 | } | |
52 | .branch-selector li a:hover { background-color: #fefefe; } | |
53 | .branch-selector li:last-child a { border: 0; padding-bottom: 4px; } | |
54 | .branch-selector:hover span { border-color: #ccc; border-bottom-color: #f1f1f1; } | |
55 | .branch-selector:hover ul { display: block; } | |
56 | ||
57 | /* Footer */ | |
58 | footer { | |
59 | clear: both; | |
60 | font-size: 80%; | |
61 | float: right; | |
62 | color: #666; | |
63 | padding: 50px 3px 3px 0; | |
64 | } | |
65 | footer a { color: inherit; border-bottom: 1px dotted #666; } | |
66 | footer a:hover { text-decoration: none; } | |
67 | ||
68 | ||
69 | /* Container */ | |
70 | #content { padding: 5px 1.5%; } | |
71 | #content > div:nth-of-type(1), | |
72 | #content > div:nth-of-type(2) { float: left; } | |
73 | #content > div:nth-of-type(1) { width: 24%; } | |
74 | #content > div:nth-of-type(2) { | |
75 | width: 72%; | |
76 | padding-left: 1.5%; | |
77 | margin-left: 1.5%; | |
78 | border-left: 1px dotted #ccc; | |
79 | } | |
80 | ||
81 | ||
82 | /* Pagination */ | |
83 | .pagination { float: right; margin: 0; font-size: 90%; } | |
84 | .pagination > * { | |
85 | border: 1px solid; | |
86 | padding: 2px 10px; | |
87 | text-align: center; | |
88 | } | |
89 | .pagination .n { font-size: 90%; padding: 1px 5px; position: relative; top: 1px; } | |
90 | .pagination > a { opacity: 0.6; border-color: #6491bf; } | |
91 | .pagination > a:hover { opacity: 1; text-decoration: none; border-color: #4D6FA0; } | |
92 | .pagination span { color: #999; border-color: #ccc; } | |
93 | ||
94 | ||
95 | /* Repo List */ | |
96 | .repolist { margin-left: 2em; font-size: 120%; } | |
97 | .repolist li { margin-bottom: 10px; } | |
98 | .repolist li a { display: block; } | |
99 | .repolist li a .last-updated { | |
100 | display: block; | |
101 | color: #737373; | |
102 | font-size: 60%; | |
103 | margin-left: 1px; | |
104 | } | |
105 | .repolist li a:hover { text-decoration: none; } | |
106 | .repolist li a:hover .name { text-decoration: underline; } | |
107 | ||
108 | ||
109 | /* Base styles for history and commit views */ | |
110 | .commit { | |
111 | background-color: #f9f9f9; | |
112 | padding: 8px 10px; | |
113 | margin-bottom: 2px; | |
114 | display: block; | |
115 | border: 1px solid #e0e0e0; | |
116 | } | |
117 | .commit:hover { text-decoration: none; } | |
118 | ||
119 | .commit > span { display: block; } | |
120 | ||
121 | .commit .line1 { font-family: monospace; padding-bottom: 2px; } | |
122 | .commit .line1 span { white-space: pre-wrap; text-overflow: hidden; } | |
123 | .commit:hover .line1 { text-decoration: underline; color: #aaa; } | |
124 | .commit:hover .line1 span { color: black; } | |
125 | ||
126 | .commit .line2 { position: relative; top: 3px; left: 1px; } | |
127 | .commit .line2 > span:first-child { float: left; } | |
128 | .commit .line2 > span:nth-child(2) { float: right; } | |
129 | .commit .line2 { color: #737373; font-size: 80%; } | |
130 | ||
131 | ||
132 | /* History View */ | |
133 | .history .pagination { margin-top: -2em; } | |
134 | a.commit { color: black !important; } | |
135 | ||
136 | .tree ul { font-family: monospace; border-top: 1px solid #e0e0e0; } | |
137 | .tree li { background-color: #f9f9f9; border: 1px solid #e0e0e0; border-top: 0; } | |
138 | .tree li a { padding: 5px 7px 6px 7px; display: block; color: #001533; } | |
139 | .tree li a:before, .diff .filename:before { | |
140 | margin-right: 5px; | |
141 | position: relative; | |
142 | top: 2px; | |
143 | opacity: 0.7; | |
144 | content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAPCAYAAADUFP50AAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sGBhMmAbS/QqsAAAAdaVRYdENvbW1lbnQAAAAAAENyZWF0ZWQgd2l0aCBHSU1QZC5lBwAAANBJREFUKM+Vkj2OgzAQhb8HSLupkKiQD8DPWbZMkSMgLsF9IlLmMpiKA9CncraIQGbXIPGqsec9faOx1TTNwxhz5YT6vr8lxphr13Wc1D1Zqnmecc4BIGl1LLUk4jgmTVMA1qBzDmvtxuhLEnVdr+fEb5ZleUj0lfgGn/hXh8SiKAKEF+/3F1EUhYkA4zhumlVVARfgBXzvjxoiSkK6/Bt9Q7TWHi7lM8HOVsNE7RMlMQxDkLRs078LEkPh3XfMsuzUZ1Xbts88z3/OhKZpuv8CNeMsq6Yg8OoAAAAASUVORK5CYII=); | |
145 | } | |
146 | .tree li a.dir:before { | |
147 | content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAPCAYAAADtc08vAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sGBhMiMxgE1i8AAAAdaVRYdENvbW1lbnQAAAAAAENyZWF0ZWQgd2l0aCBHSU1QZC5lBwAAAYxJREFUKM+l0r1KXEEYxvH/bNS4K2RNCjsRBAlqITESI0QCuqBdWLwACSJaiZILiDcQkDRql8oUIqkkkRTRwiKVyvoRV5HFEFEX/DjomePsmfNauB4xrLAmD1PNML95eBnV0Fj/vjPRMcpfMcYAMP9jYXDzV3qSO1LSmegYfdvbV/DQ8zS+709oVzu7u78/FwQAHOeU9Y31gsjz5hYcx5lqbXsxdb23ld4eW15aGQmBaDRGZfzxXS1JvukBQCmFUoqZL9PDIWCMQWuX76tnpLIxisqjJC39SXmoM5thg1Q2xsd3XXjGFmWUlz1g6MPc0xIArV0A9o89dg7PiwJqqyoAiHieRzRaZPUCibiuGzb4J+B6Bv8F3LeBtQFeznLrH5RGAgQQEZRSiAgiEIhgrZCzAcYXLnxLzgrxirIbQGuXmvgFR2eGP0caRBEg5BciIAgieRjwrdwAB9lDnrW9Yjlzkr909boIBAiCApGwVWvdE+a+fkOvzX5STd0D86XV7a/vOzy7t/hzaXb85SVDycBfkNNgmgAAAABJRU5ErkJggg==); | |
148 | } | |
149 | ||
150 | ||
151 | /* Common for diff and blob view */ | |
152 | .line { display: block; } | |
153 | .linenos { background-color: #f9f9f9; text-align: right; } | |
154 | .linenos a { color: #888; } | |
155 | .linenos a:hover { text-decoration: none; } | |
156 | .highlight-line { background-color: #fefed0; } | |
157 | .linenos a { padding: 0 6px 0 6px; } | |
158 | ||
159 | ||
160 | /* Blob View */ | |
161 | .blobview img { max-width: 100%; padding: 1px; } | |
162 | .blobview table, .blobview img { border: 1px solid #e0e0e0; } | |
163 | .blobview .linenos { border: 1px solid #e0e0e0; padding: 0; } | |
164 | .blobview .code { padding: 0; } | |
165 | .blobview .code .line { padding: 0 5px 0 10px; } | |
166 | ||
167 | ||
168 | /* Commit View */ | |
169 | .full-commit { width: 100% !important; margin-top: 10px; } | |
170 | ||
171 | .full-commit .commit { padding: 15px 20px; } | |
172 | .full-commit .commit .line1 { padding-bottom: 5px; } | |
173 | .full-commit .commit:hover .line1 { text-decoration: none; } | |
174 | .full-commit .commit .line2 > span:nth-child(2) { float: left; } | |
175 | .full-commit .commit .line2 > span:nth-child(2):before { content: '·'; margin: 0 3px 0 5px; } | |
176 | ||
177 | .diff { font-family: monospace; } | |
178 | .diff .filename { | |
179 | background-color: #f9f9f9; | |
180 | padding: 8px 10px; | |
181 | border: 1px solid #e0e0e0; | |
182 | border-bottom: 0; | |
183 | margin-top: 25px; | |
184 | } | |
185 | .diff .filename del { color: #999; } | |
186 | .diff .filename:before { margin-right: 0; opacity: 0.3; } | |
187 | .diff table { | |
188 | border: 1px solid #e0e0e0; | |
189 | background-color: #fdfdfd; | |
190 | width: 100%; | |
191 | } | |
192 | .diff td { | |
193 | padding: 0; | |
194 | border-left: 1px solid #e0e0e0; | |
195 | } | |
196 | .diff td .line { padding: 1px 10px; display: block; height: 1.2em; } | |
197 | .diff .linenos { font-size: 85%; padding: 0; } | |
198 | .diff .linenos a { display: block; padding-top: 1px; padding-bottom: 1px; } | |
199 | .diff td + td + td { width: 100%; } | |
200 | .diff td + td + td > span { white-space: pre; } | |
201 | .diff tr:first-of-type td { padding-top: 7px; } | |
202 | .diff tr:last-of-type td { padding-bottom: 7px; } | |
203 | .diff table .del { background-color: #ffdddd; } | |
204 | .diff table .add { background-color: #ddffdd; } | |
205 | .diff table del { background-color: #ee9999; text-decoration: none; } | |
206 | .diff table ins { background-color: #99ee99; text-decoration: none; } | |
207 | .diff .sep > td { | |
208 | height: 1.2em; | |
209 | background-color: #f9f9f9; | |
210 | text-align: center; | |
211 | border: 1px solid #e0e0e0; | |
212 | } | |
213 | .diff .sep:hover > td { background-color: #f9f9f9; } |
0 | var highlight_linenos = function(opts) { | |
1 | var forEach = function(collection, func) { | |
2 | for(var i = 0; i < collection.length; ++i) { | |
3 | func(collection[i]); | |
4 | } | |
5 | } | |
6 | ||
7 | ||
8 | var links = document.querySelectorAll(opts.linksSelector); | |
9 | currentHash = location.hash; | |
10 | ||
11 | forEach(links, function(a) { | |
12 | var lineno = a.getAttribute('href').substr(1), | |
13 | selector = 'a[name="' + lineno + '"]', | |
14 | anchor = document.querySelector(selector), | |
15 | associatedLine = opts.getLineFromAnchor(anchor); | |
16 | ||
17 | var highlight = function() { | |
18 | a.className = 'highlight-line'; | |
19 | associatedLine.className = 'line highlight-line'; | |
20 | currentHighlight = a; | |
21 | } | |
22 | ||
23 | var unhighlight = function() { | |
24 | if (a.getAttribute('href') != location.hash) { | |
25 | a.className = ''; | |
26 | associatedLine.className = 'line'; | |
27 | } | |
28 | } | |
29 | ||
30 | a.onmouseover = associatedLine.onmouseover = highlight; | |
31 | a.onmouseout = associatedLine.onmouseout = unhighlight; | |
32 | }); | |
33 | ||
34 | ||
35 | window.onpopstate = function() { | |
36 | if (currentHash) { | |
37 | forEach(document.querySelectorAll('a[href="' + currentHash + '"]'), | |
38 | function(e) { e.onmouseout() }) | |
39 | } | |
40 | if (location.hash) { | |
41 | forEach(document.querySelectorAll('a[href="' + location.hash + '"]'), | |
42 | function(e) { e.onmouseover() }); | |
43 | currentHash = location.hash; | |
44 | } | |
45 | }; | |
46 | } |
0 | /* This is the Pygments Trac theme */ | |
1 | .code .hll { background-color: #ffffcc } | |
2 | .code { background: #ffffff; } | |
3 | .code .c { color: #999988; font-style: italic } /* Comment */ | |
4 | .code .err { color: #a61717; background-color: #e3d2d2 } /* Error */ | |
5 | .code .k { font-weight: bold } /* Keyword */ | |
6 | .code .o { font-weight: bold } /* Operator */ | |
7 | .code .cm { color: #999988; font-style: italic } /* Comment.Multiline */ | |
8 | .code .cp { color: #999999; font-weight: bold } /* Comment.Preproc */ | |
9 | .code .c1 { color: #999988; font-style: italic } /* Comment.Single */ | |
10 | .code .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */ | |
11 | .code .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ | |
12 | .code .ge { font-style: italic } /* Generic.Emph */ | |
13 | .code .gr { color: #aa0000 } /* Generic.Error */ | |
14 | .code .gh { color: #999999 } /* Generic.Heading */ | |
15 | .code .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ | |
16 | .code .go { color: #888888 } /* Generic.Output */ | |
17 | .code .gp { color: #555555 } /* Generic.Prompt */ | |
18 | .code .gs { font-weight: bold } /* Generic.Strong */ | |
19 | .code .gu { color: #aaaaaa } /* Generic.Subheading */ | |
20 | .code .gt { color: #aa0000 } /* Generic.Traceback */ | |
21 | .code .kc { font-weight: bold } /* Keyword.Constant */ | |
22 | .code .kd { font-weight: bold } /* Keyword.Declaration */ | |
23 | .code .kn { font-weight: bold } /* Keyword.Namespace */ | |
24 | .code .kp { font-weight: bold } /* Keyword.Pseudo */ | |
25 | .code .kr { font-weight: bold } /* Keyword.Reserved */ | |
26 | .code .kt { color: #445588; font-weight: bold } /* Keyword.Type */ | |
27 | .code .m { color: #009999 } /* Literal.Number */ | |
28 | .code .s { color: #bb8844 } /* Literal.String */ | |
29 | .code .na { color: #008080 } /* Name.Attribute */ | |
30 | .code .nb { color: #999999 } /* Name.Builtin */ | |
31 | .code .nc { color: #445588; font-weight: bold } /* Name.Class */ | |
32 | .code .no { color: #008080 } /* Name.Constant */ | |
33 | .code .ni { color: #800080 } /* Name.Entity */ | |
34 | .code .ne { color: #990000; font-weight: bold } /* Name.Exception */ | |
35 | .code .nf { color: #990000; font-weight: bold } /* Name.Function */ | |
36 | .code .nn { color: #555555 } /* Name.Namespace */ | |
37 | .code .nt { color: #000080 } /* Name.Tag */ | |
38 | .code .nv { color: #008080 } /* Name.Variable */ | |
39 | .code .ow { font-weight: bold } /* Operator.Word */ | |
40 | .code .w { color: #bbbbbb } /* Text.Whitespace */ | |
41 | .code .mf { color: #009999 } /* Literal.Number.Float */ | |
42 | .code .mh { color: #009999 } /* Literal.Number.Hex */ | |
43 | .code .mi { color: #009999 } /* Literal.Number.Integer */ | |
44 | .code .mo { color: #009999 } /* Literal.Number.Oct */ | |
45 | .code .sb { color: #bb8844 } /* Literal.String.Backtick */ | |
46 | .code .sc { color: #bb8844 } /* Literal.String.Char */ | |
47 | .code .sd { color: #bb8844 } /* Literal.String.Doc */ | |
48 | .code .s2 { color: #bb8844 } /* Literal.String.Double */ | |
49 | .code .se { color: #bb8844 } /* Literal.String.Escape */ | |
50 | .code .sh { color: #bb8844 } /* Literal.String.Heredoc */ | |
51 | .code .si { color: #bb8844 } /* Literal.String.Interpol */ | |
52 | .code .sx { color: #bb8844 } /* Literal.String.Other */ | |
53 | .code .sr { color: #808000 } /* Literal.String.Regex */ | |
54 | .code .s1 { color: #bb8844 } /* Literal.String.Single */ | |
55 | .code .ss { color: #bb8844 } /* Literal.String.Symbol */ | |
56 | .code .bp { color: #999999 } /* Name.Builtin.Pseudo */ | |
57 | .code .vc { color: #008080 } /* Name.Variable.Class */ | |
58 | .code .vg { color: #008080 } /* Name.Variable.Global */ | |
59 | .code .vi { color: #008080 } /* Name.Variable.Instance */ | |
60 | .code .il { color: #009999 } /* Literal.Number.Integer.Long */ |
0 | {% extends 'skeleton.html' %} | |
1 | ||
2 | {% block breadcrumbs %} | |
3 | <span> | |
4 | <a href="{{ build_url('history', commit_id='master') }}">{{ repo.name }}</a> | |
5 | <span class=slash>/</span> | |
6 | <a href="{{ build_url('history') }}">{{ commit_id|shorten_sha1 }}</a> | |
7 | </span> | |
8 | ||
9 | {% if subpaths %} | |
10 | <span> | |
11 | {% for name, subpath in subpaths %} | |
12 | {% if loop.last %} | |
13 | <a href="">{{ name|u }}</a> | |
14 | {% else %} | |
15 | <a href="{{ build_url('history', path=subpath) }}">{{ name|u }}</a> | |
16 | <span class=slash>/</span> | |
17 | {% endif %} | |
18 | {% endfor %} | |
19 | </span> | |
20 | {% endif %} | |
21 | {% endblock %} | |
22 | ||
23 | {% block extra_header %} | |
24 | <div class=branch-selector> | |
25 | <span>{{ commit_id|shorten_sha1 }}</span> | |
26 | <ul> | |
27 | {% for branch in branches %} | |
28 | <li><a href="{{ build_url(commit_id=branch, path=path) }}">{{ branch }}</a></li> | |
29 | {% endfor %} | |
30 | </ul> | |
31 | </div> | |
32 | {% endblock %} |
0 | {% extends 'base.html' %} | |
1 | {% block content %} | |
2 | ||
3 | {% include 'tree.inc.html' %} | |
4 | ||
5 | {% set history = repo.history(branch, path.strip('/'), history_length+1, skip) %} | |
6 | {% set has_more_commits = history|length == history_length+1 %} | |
7 | ||
8 | {% macro pagination() %} | |
9 | <div class=pagination> | |
10 | {% if page %} | |
11 | {% for n in previous_pages %} | |
12 | {% if n is none %} | |
13 | <span class=n>...</span> | |
14 | {% else %} | |
15 | <a href="?page={{n}}" class=n>{{ n }}</a> | |
16 | {% endif %} | |
17 | {% endfor %} | |
18 | {% endif %} | |
19 | {% if has_more_commits %} | |
20 | <a href="?page={{page+1}}">»»</a> | |
21 | {% else %} | |
22 | <span>»»</span> | |
23 | {% endif%} | |
24 | </div> | |
25 | <div class=clearfloat></div> | |
26 | {% endmacro %} | |
27 | ||
28 | <div> | |
29 | <div class=history> | |
30 | <h2> | |
31 | {% if subpaths %} | |
32 | History for | |
33 | {% for name, subpath in subpaths %} | |
34 | {{ name }} | |
35 | {% if not loop.last %} | |
36 | <span class=slash>/</span> | |
37 | {% endif %} | |
38 | {% endfor %} | |
39 | {% else %} | |
40 | Commit History | |
41 | {% endif %} | |
42 | <span> | |
43 | @<a href="{{ build_url(commit_id=branch) }}">{{ branch }}</a> | |
44 | </span> | |
45 | </h2> | |
46 | ||
47 | {{ pagination() }} | |
48 | ||
49 | <ul> | |
50 | {% for commit in history %} | |
51 | {% if not loop.last or history|length < history_length %} | |
52 | <li> | |
53 | <a class=commit href="{{ build_url('view_commit', commit_id=commit.id) }}"> | |
54 | <span class=line1> | |
55 | <span>{{ commit.message|u|shorten_message }}</span> | |
56 | </span> | |
57 | <span class=line2> | |
58 | <span>{{ commit.author|u|shorten_author }}</span> | |
59 | <span>{{ commit.commit_time|timesince }} ago</span> | |
60 | </span> | |
61 | <span class=clearfloat></span> | |
62 | </a> | |
63 | </li> | |
64 | {% endif %} | |
65 | {% endfor %} | |
66 | </ul> | |
67 | </div> | |
68 | ||
69 | {{ pagination() }} | |
70 | ||
71 | </div> | |
72 | ||
73 | {% endblock %} |
0 | {% extends 'skeleton.html' %} | |
1 | {% block content %} | |
2 | ||
3 | <h2> | |
4 | Repositories | |
5 | <span> | |
6 | (<a href=?by-last-update=yep>order by last update</a>) | |
7 | </span> | |
8 | </h2> | |
9 | <ul class=repolist> | |
10 | {% for name, last_update_at in repos %} | |
11 | <li> | |
12 | <a href="{{ build_url('history', repo=name, commit_id='master', path='') }}"> | |
13 | <span class=name>{{ name }}</span> | |
14 | <span class=last-updated> | |
15 | {% if last_update_at is not none %} | |
16 | last updated {{ last_update_at|timesince }} ago | |
17 | {% else %} | |
18 | no commits yet | |
19 | {% endif %} | |
20 | </span></a> | |
21 | </li> | |
22 | {% endfor %} | |
23 | </ul> | |
24 | ||
25 | {% endblock %} |
0 | <!doctype html> | |
1 | <meta http-equiv="content-type" content="text/html; charset=utf-8"> | |
2 | <link rel=stylesheet href=/static/pygments.css> | |
3 | <link rel=stylesheet href=/static/klaus.css> | |
4 | <script src=/static/line-highlighter.js></script> | |
5 | ||
6 | <header> | |
7 | <a href=/>{{ environ.HTTP_HOST }}</a> | |
8 | <span class=breadcrumbs>{% block breadcrumbs %}{% endblock %}</span> | |
9 | {% block extra_header %}{% endblock %} | |
10 | </header> | |
11 | ||
12 | <div id=content> | |
13 | {% block content %}{% endblock %} | |
14 | </div> | |
15 | ||
16 | <footer> | |
17 | powered by <a href="https://github.com/jonashaag/klaus">klaus</a>{{ KLAUS_VERSION }}, | |
18 | a simple Git viewer by Jonas Haag | |
19 | </footer> |
0 | <div class=tree> | |
1 | <h2>Tree @<a href="{{ build_url('view_commit') }}">{{ commit_id|shorten_sha1 }}</a></h2> | |
2 | <ul> | |
3 | {% for _, name, fullpath in tree.dirs %} | |
4 | <li><a href="{{ build_url('history', path=fullpath) }}" class=dir>{{ name|u }}</a></li> | |
5 | {% endfor %} | |
6 | {% for _, name, fullpath in tree.files %} | |
7 | <li><a href="{{ build_url('view_blob', path=fullpath) }}">{{ name|u }}</a></li> | |
8 | {% endfor %} | |
9 | </ul> | |
10 | </div> |
0 | {% extends 'base.html' %} | |
1 | {% block content %} | |
2 | ||
3 | {% include 'tree.inc.html' %} | |
4 | ||
5 | <div class=blobview> | |
6 | <h2> | |
7 | {{ filename|u }} | |
8 | <span> | |
9 | @<a href="{{ build_url('view_commit') }}">{{ commit_id|shorten_sha1 }}</a> | |
10 | (<a href="{{ raw_url }}">raw</a> | |
11 | · <a href="{{ build_url('history', path=path) }}">history</a>) | |
12 | </span> | |
13 | </h2> | |
14 | {% if blob.chunked|is_binary %} | |
15 | {% if filename|is_image %} | |
16 | <a href="{{ raw_url }}"><img src="{{ raw_url }}"></a> | |
17 | {% else %} | |
18 | <div class=binary-warning>(Binary data not shown)</div> | |
19 | {% endif %} | |
20 | {% else %} | |
21 | {% if too_large %} | |
22 | <div class=too-large-warning>(Large file not shown)</div> | |
23 | {% else %} | |
24 | {% autoescape off %} | |
25 | {{ blob.data|u|pygmentize(filename=filename) }} | |
26 | {% endautoescape %} | |
27 | {% endif %} | |
28 | {% endif %} | |
29 | </div> | |
30 | ||
31 | <script> | |
32 | highlight_linenos({ | |
33 | linksSelector: '.highlighttable .linenos a', | |
34 | getLineFromAnchor: function(anchor) { return anchor.nextSibling } | |
35 | }) | |
36 | </script> | |
37 | ||
38 | {% endblock %} |
0 | {% set title = 'Commit %s to %s' % (commit.id, repo.name) %} | |
1 | {% extends 'base.html' %} | |
2 | ||
3 | {% block extra_header %}{% endblock %} {# no branch selector on commits #} | |
4 | ||
5 | {% block content %} | |
6 | ||
7 | <div class=full-commit> | |
8 | ||
9 | <div class=commit> | |
10 | <span class=line1> | |
11 | <span>{{ commit.message|u }}</span> | |
12 | </span> | |
13 | <span class=line2> | |
14 | <span>{{ commit.author|u|shorten_author }}</span> | |
15 | <span>{{ commit.commit_time|timesince }} ago</span> | |
16 | </span> | |
17 | <span class=clearfloat></span> | |
18 | </div> | |
19 | ||
20 | <div class=diff> | |
21 | {% for file in repo.commit_diff(commit) %} | |
22 | ||
23 | {% set fileno = loop.index0 %} | |
24 | ||
25 | <div class=filename> | |
26 | {# TODO dulwich doesn't do rename recognition | |
27 | {% if file.old_filename != file.new_filename %} | |
28 | {{ file.old_filename }} → | |
29 | {% endif %}#} | |
30 | {% if file.new_filename == '/dev/null' %} | |
31 | <del>{{ file.old_filename|u }}</del> | |
32 | {% else %} | |
33 | <a href="{{ build_url('view_blob', path=file.new_filename) }}"> | |
34 | {{ file.new_filename|u }} | |
35 | </a> | |
36 | {% endif %} | |
37 | </div> | |
38 | ||
39 | <table> | |
40 | {% for chunk in file.chunks %} | |
41 | ||
42 | {%- for line in chunk -%} | |
43 | <tr> | |
44 | ||
45 | {#- left column: linenos -#} | |
46 | {%- if line.old_lineno -%} | |
47 | <td class=linenos><a href="#{{fileno}}-L-{{line.old_lineno}}">{{ line.old_lineno }}</a></td> | |
48 | {%- if line.new_lineno -%} | |
49 | <td class=linenos><a href="#{{fileno}}-L-{{line.old_lineno}}">{{ line.new_lineno }}</a></td> | |
50 | {%- else -%} | |
51 | <td class=linenos></td> | |
52 | {%- endif -%} | |
53 | {%- else %} | |
54 | {%- if line.old_lineno -%} | |
55 | <td class=linenos><a href="#{{fileno}}-R-{{line.old_lineno}}">{{ line.new_lineno }}</a></td> | |
56 | {%- else -%} | |
57 | <td class=linenos></td> | |
58 | {%- endif -%} | |
59 | <td class=linenos><a href="#{{fileno}}-R-{{line.new_lineno}}">{{ line.new_lineno }}</a></td> | |
60 | {% endif %} | |
61 | ||
62 | {#- right column: code -#} | |
63 | <td class={{line.action}}> | |
64 | {#- lineno anchors -#} | |
65 | {%- if line.old_lineno -%} | |
66 | <a name="{{fileno}}-L-{{line.old_lineno}}"></a> | |
67 | {%- else -%} | |
68 | <a name="{{fileno}}-R-{{line.new_lineno}}"></a> | |
69 | {%- endif -%} | |
70 | ||
71 | {#- the actual line of code -#} | |
72 | <span class=line>{% autoescape off %}{{ line.line|u }}{% endautoescape %}</span> | |
73 | </td> | |
74 | ||
75 | </tr> | |
76 | {%- endfor -%} {# lines #} | |
77 | ||
78 | {% if not loop.last %} | |
79 | <tr class=sep> | |
80 | <td colspan=3></td> | |
81 | </tr> | |
82 | {% endif %} | |
83 | ||
84 | {%- endfor -%} {# chunks #} | |
85 | </table> | |
86 | ||
87 | {% endfor %} | |
88 | </div> | |
89 | ||
90 | </div> | |
91 | ||
92 | <script> | |
93 | highlight_linenos({ | |
94 | linksSelector: '.linenos a', | |
95 | getLineFromAnchor: function(anchor) { | |
96 | /* If we got the first (old_lineno) anchor, the span we're looking for is | |
97 | the second-next sibling, otherwise it's the next. */ | |
98 | if (anchor.nextSibling instanceof HTMLSpanElement) | |
99 | return anchor.nextSibling; | |
100 | else | |
101 | return anchor.nextSibling.nextSibling; | |
102 | } | |
103 | }); | |
104 | </script> | |
105 | ||
106 | {% endblock %} |
0 | #!/usr/bin/env python2 | |
1 | import sys, os | |
2 | import inspect | |
3 | ||
4 | sys.path.append(os.path.join(os.path.dirname(__file__), 'nano')) | |
5 | ||
6 | class ReloadApplicationMiddleware(object): | |
7 | def __init__(self, import_func): | |
8 | self.import_func = import_func | |
9 | self.app = import_func() | |
10 | self.files = self.get_module_mtimes() | |
11 | ||
12 | def get_module_mtimes(self): | |
13 | files = {} | |
14 | for module in sys.modules.itervalues(): | |
15 | try: | |
16 | file = inspect.getsourcefile(module) | |
17 | files[file] = os.stat(file).st_mtime | |
18 | except TypeError: | |
19 | continue | |
20 | return files | |
21 | ||
22 | def shall_reload(self): | |
23 | for file, mtime in self.get_module_mtimes().iteritems(): | |
24 | if not file in self.files or self.files[file] < mtime: | |
25 | self.files = self.get_module_mtimes() | |
26 | return True | |
27 | return False | |
28 | ||
29 | def __call__(self, *args, **kwargs): | |
30 | if self.shall_reload(): | |
31 | print 'Reloading...' | |
32 | self.app = self.import_func() | |
33 | return self.app(*args, **kwargs) | |
34 | ||
35 | def import_app(): | |
36 | sys.modules.pop('klaus', None) | |
37 | sys.modules.pop('repo', None) | |
38 | from klaus import app | |
39 | return app | |
40 | ||
41 | import bjoern | |
42 | bjoern.run(ReloadApplicationMiddleware(import_app), '127.0.0.1', 8080) |
12 | 12 | return url |
13 | 13 | |
14 | 14 | AHREF_RE = re.compile('href="([\w/][^"]+)"') |
15 | ||
16 | BASE_URL = 'http://localhost:8080' | |
17 | 15 | |
18 | 16 | seen = set() |
19 | 17 | errors = defaultdict(set) |
36 | 34 | if '-v' in sys.argv: |
37 | 35 | print 'Requesting %r...' % url |
38 | 36 | start = time.time() |
39 | http_conn.request('GET', BASE_URL + url) | |
37 | http_conn.request('GET', url) | |
40 | 38 | response = http_conn.getresponse() |
41 | 39 | durations[view_from_url(url)].append(time.time() - start) |
42 | 40 | status = str(response.status) |
0 | #!/usr/bin/env python2 | |
1 | # coding: utf-8 | |
2 | import sys, os | |
3 | import argparse | |
4 | ||
5 | ||
6 | PROJECT_ROOT = os.path.join(os.path.dirname(__file__), os.pardir) | |
7 | ||
8 | sys.path.append(PROJECT_ROOT) | |
9 | ||
10 | ||
11 | try: | |
12 | import nano | |
13 | except ImportError: | |
14 | sys.path.append(os.path.join(PROJECT_ROOT, 'nano')) | |
15 | try: | |
16 | import nano | |
17 | except ImportError: | |
18 | raise ImportError( | |
19 | "Could not find a copy of nano (https://github.com/jonashaag/nano). " | |
20 | "Use 'git submodule update --init' to initialize the nano submodule " | |
21 | "or copy the 'nano.py' into the klaus root directory by hand." | |
22 | ) | |
23 | ||
24 | ||
25 | try: | |
26 | from bjoern import run | |
27 | except ImportError: | |
28 | from wsgiref.simple_server import make_server | |
29 | def run(app, host, port): | |
30 | make_server(host, port, app).serve_forever() | |
31 | ||
32 | ||
33 | def valid_directory(path): | |
34 | if not os.path.exists(path): | |
35 | raise argparse.ArgumentTypeError('%r: No such directory' % path) | |
36 | return path | |
37 | ||
38 | ||
39 | def main(): | |
40 | parser = argparse.ArgumentParser(epilog='Gemüse kaufen!') | |
41 | parser.add_argument('host', help='(without http://)') | |
42 | parser.add_argument('port', type=int) | |
43 | parser.add_argument('--display-host', dest='custom_host') | |
44 | parser.add_argument('repo', nargs='+', type=valid_directory, | |
45 | help='repository directories to serve') | |
46 | args = parser.parse_args() | |
47 | sys.argv = ['this is a hack'] + args.repo | |
48 | ||
49 | from klaus import app | |
50 | if args.custom_host: | |
51 | app.custom_host = args.custom_host | |
52 | ||
53 | run(app, args.host, args.port) | |
54 | ||
55 | ||
56 | if __name__ == '__main__': | |
57 | main() |