| 1 | from trac.core import * |
|---|
| 2 | from trac.wiki.macros import WikiMacroBase |
|---|
| 3 | from trac.wiki.formatter import wiki_to_html |
|---|
| 4 | from trac.mimeview.api import IHTMLPreviewAnnotator, Mimeview |
|---|
| 5 | from os import linesep |
|---|
| 6 | |
|---|
| 7 | class IncludeSourceMacro(WikiMacroBase): |
|---|
| 8 | """Includes a source file from the repository into the Wiki. |
|---|
| 9 | |
|---|
| 10 | There is one required parameter, which is the path to the |
|---|
| 11 | file to include. This should be the repository path, not a |
|---|
| 12 | full URL. |
|---|
| 13 | |
|---|
| 14 | Optional named parameters are: |
|---|
| 15 | * ''start '' The first line of the file to include. Defaults to the |
|---|
| 16 | beginning of the file. Otherwise should be a numeric value. |
|---|
| 17 | |
|---|
| 18 | Note that files start with line 1, not line 0. |
|---|
| 19 | * ''end'' The last line of the file to include. Defaults to the end |
|---|
| 20 | of the file. |
|---|
| 21 | |
|---|
| 22 | Note that both 'start' and 'end' are used for array slicing the |
|---|
| 23 | lines of the file, so if (for example) you want the last 20 lines |
|---|
| 24 | of the file, you can use start=-20 and leave end blank. |
|---|
| 25 | * ''rev'' Which revision to include. This defaults to HEAD if |
|---|
| 26 | not supplied. Otherwise this should be a valid numeric revision |
|---|
| 27 | number in your version control repository. |
|---|
| 28 | * ''mimetype'' Which mimetype to use to determine syntax highlighting. |
|---|
| 29 | If not supplied, this is determined by the file extension (which |
|---|
| 30 | is normally what you want) |
|---|
| 31 | |
|---|
| 32 | Examples: |
|---|
| 33 | {{{ |
|---|
| 34 | # include entire file |
|---|
| 35 | [[IncludeSource(trunk/proj/file.py)]] |
|---|
| 36 | |
|---|
| 37 | # includes line 20-50 inclusive |
|---|
| 38 | [[IncludeSource(trunk/proj/file.py, start=20, end=50)]] |
|---|
| 39 | |
|---|
| 40 | # includes last 30 lines of file at revision 1200 |
|---|
| 41 | [[IncludeSource(trunk/proj/file.py, start=-30, rev=1200)]] |
|---|
| 42 | |
|---|
| 43 | # includes last 30 lines of file at revision 1200 |
|---|
| 44 | [[IncludeSource(trunk/proj/file.py, start=-30, rev=1200)]] |
|---|
| 45 | |
|---|
| 46 | # include entire file but formatted plain |
|---|
| 47 | [[IncludeSource(trunk/proj/file.py, mimetype=text/plain)]] |
|---|
| 48 | |
|---|
| 49 | }}} |
|---|
| 50 | |
|---|
| 51 | See TracLinks, TracSyntaxColoring and trac/mimeview/api.py |
|---|
| 52 | |
|---|
| 53 | TODO |
|---|
| 54 | {{{ |
|---|
| 55 | * Fix non-localized strings |
|---|
| 56 | |
|---|
| 57 | * Fix proper encoding of output |
|---|
| 58 | |
|---|
| 59 | * Implement some sort of caching (especially in cases where the |
|---|
| 60 | revision is known and we know that the contents won't change). |
|---|
| 61 | |
|---|
| 62 | * Fix issue with calling IncludeSource more than once on a single |
|---|
| 63 | page. This causes some error in the C bindings for Subversion |
|---|
| 64 | so it will cause a server error instead of producing a log message. |
|---|
| 65 | |
|---|
| 66 | See the comments in IncludeSource.py for more information about |
|---|
| 67 | where this occurs. |
|---|
| 68 | |
|---|
| 69 | * Allow multiple chunks from the file in one call. You can do this |
|---|
| 70 | with the existing code, but it will pull the entire file out of |
|---|
| 71 | version control and trim it for each chunk, so this could be |
|---|
| 72 | optimized a bit. |
|---|
| 73 | }}} |
|---|
| 74 | """ |
|---|
| 75 | |
|---|
| 76 | def render_macro(self, req, name, args): |
|---|
| 77 | self.log.warning('Begin render_macro for req: ' + repr(args)) |
|---|
| 78 | |
|---|
| 79 | largs, kwargs = parse_args(args) |
|---|
| 80 | repos = self.env.get_repository() |
|---|
| 81 | |
|---|
| 82 | self.log.warning('have repo') |
|---|
| 83 | if len(largs) == 0: |
|---|
| 84 | # TODO - don't hardcode this in English |
|---|
| 85 | raise Exception("File name to include is required parameter!") |
|---|
| 86 | |
|---|
| 87 | try: |
|---|
| 88 | file_name = largs[0] |
|---|
| 89 | |
|---|
| 90 | rev = kwargs.get('rev', None) |
|---|
| 91 | |
|---|
| 92 | # This dies in the C code when we have two instances of the |
|---|
| 93 | # macro on one request. It dies in trac.versioncontrol.svn_fs |
|---|
| 94 | # in the SubversionNode __init__ method on the line |
|---|
| 95 | # self.root = fs.revision_root(self.fs_ptr, rev, self.pool()) |
|---|
| 96 | # |
|---|
| 97 | # This could be from a bad fs_ptr or bad pool, but it wasn't |
|---|
| 98 | # obvious during some initial python level debugging as to what |
|---|
| 99 | # the cause is |
|---|
| 100 | src = repos.get_node(file_name, rev).get_content().read() |
|---|
| 101 | |
|---|
| 102 | # We'd like to just use Mimeview directly here, but there's no |
|---|
| 103 | # support for the partial output that we want to support here |
|---|
| 104 | mv = IncludeSourceMimeview(self.env) |
|---|
| 105 | mv.file_name = file_name |
|---|
| 106 | mv.rev = rev |
|---|
| 107 | |
|---|
| 108 | start, end = kwargs.get('start', None), kwargs.get('end', None) |
|---|
| 109 | if start or end: |
|---|
| 110 | src, start, end = self._handle_partial(src, start, end) |
|---|
| 111 | mv.startline = start |
|---|
| 112 | |
|---|
| 113 | mimetype = kwargs.get('mimetype', None) |
|---|
| 114 | url = None # render method doesn't seem to use this |
|---|
| 115 | src = mv.render(req, mimetype, src, file_name, url, ['givenlineno']) |
|---|
| 116 | |
|---|
| 117 | finally: |
|---|
| 118 | repos.close() |
|---|
| 119 | repos = None |
|---|
| 120 | return src |
|---|
| 121 | |
|---|
| 122 | def _handle_partial(self, src, start, end): |
|---|
| 123 | # we want to only show a certain number of lines, so we |
|---|
| 124 | # break the source into lines and set our numbers for 1-based |
|---|
| 125 | # line numbering. |
|---|
| 126 | # |
|---|
| 127 | # Note that there are some good performance enhancements that |
|---|
| 128 | # could be done by |
|---|
| 129 | # a) reading lines out of Subversion, using svn_stream_readline |
|---|
| 130 | # instead of svn_stream_read when fetching data |
|---|
| 131 | # b) have the render method accept a list/iterator of lines |
|---|
| 132 | # instead of only accepting a string (which it then splits) |
|---|
| 133 | lines = src.split(linesep) |
|---|
| 134 | linecount = len(lines) |
|---|
| 135 | |
|---|
| 136 | if start: |
|---|
| 137 | start = int(start) |
|---|
| 138 | if start >= 0: |
|---|
| 139 | start -= 1 |
|---|
| 140 | if end: |
|---|
| 141 | end = int(end) |
|---|
| 142 | src = lines[start:end] |
|---|
| 143 | |
|---|
| 144 | # calculate actual startline for display purposes |
|---|
| 145 | if not start: |
|---|
| 146 | start = 1 |
|---|
| 147 | elif start < 0: |
|---|
| 148 | start = linecount + start + 1 |
|---|
| 149 | else: |
|---|
| 150 | start += 1 |
|---|
| 151 | |
|---|
| 152 | return linesep.join(src), start, end |
|---|
| 153 | |
|---|
| 154 | class GivenLineNumberAnnotator(Component): |
|---|
| 155 | """Text annotator that adds a column with given line numbers.""" |
|---|
| 156 | implements(IHTMLPreviewAnnotator) |
|---|
| 157 | |
|---|
| 158 | file_name = '' |
|---|
| 159 | rev = None |
|---|
| 160 | |
|---|
| 161 | # ITextAnnotator methods |
|---|
| 162 | |
|---|
| 163 | def get_annotation_type(self): |
|---|
| 164 | return 'givenlineno', 'Line', 'Line numbers' |
|---|
| 165 | |
|---|
| 166 | def annotate_line(self, number, content): |
|---|
| 167 | if self.file_name.startswith('/'): |
|---|
| 168 | self.file_name = self.file_name[1:] |
|---|
| 169 | |
|---|
| 170 | rev = self.rev and '@' + str(self.rev) or '' |
|---|
| 171 | |
|---|
| 172 | return '<th id="L%s"><a href="../browser/%s%s#L%s">%s</a></th>' % \ |
|---|
| 173 | (number, self.file_name, rev, number, number) |
|---|
| 174 | |
|---|
| 175 | class IncludeSourceMimeview(Mimeview): |
|---|
| 176 | |
|---|
| 177 | startline = 1 |
|---|
| 178 | file_name = '' |
|---|
| 179 | rev = None |
|---|
| 180 | |
|---|
| 181 | def _annotate(self, lines, annotations): |
|---|
| 182 | import re |
|---|
| 183 | from StringIO import StringIO |
|---|
| 184 | from trac.mimeview.api import _html_splitlines |
|---|
| 185 | |
|---|
| 186 | buf = StringIO() |
|---|
| 187 | buf.write('<table class="code"><thead><tr>') |
|---|
| 188 | annotators = [] |
|---|
| 189 | for annotator in self.annotators: |
|---|
| 190 | atype, alabel, adesc = annotator.get_annotation_type() |
|---|
| 191 | if atype in annotations: |
|---|
| 192 | |
|---|
| 193 | # kludge to get lineno CSS class |
|---|
| 194 | if atype == 'givenlineno': |
|---|
| 195 | atype = 'lineno' |
|---|
| 196 | |
|---|
| 197 | buf.write('<th class="%s">%s</th>' % (atype, alabel)) |
|---|
| 198 | annotators.append(annotator) |
|---|
| 199 | buf.write('<th class="content"> </th>') |
|---|
| 200 | buf.write('</tr></thead><tbody>') |
|---|
| 201 | |
|---|
| 202 | space_re = re.compile('(?P<spaces> (?: +))|' |
|---|
| 203 | '^(?P<tag><\w+.*?>)?( )') |
|---|
| 204 | def htmlify(match): |
|---|
| 205 | m = match.group('spaces') |
|---|
| 206 | if m: |
|---|
| 207 | div, mod = divmod(len(m), 2) |
|---|
| 208 | return div * ' ' + mod * ' ' |
|---|
| 209 | return (match.group('tag') or '') + ' ' |
|---|
| 210 | |
|---|
| 211 | num = -1 |
|---|
| 212 | for num, line in enumerate(_html_splitlines(lines)): |
|---|
| 213 | cells = [] |
|---|
| 214 | linenum = num + self.startline |
|---|
| 215 | for annotator in annotators: |
|---|
| 216 | |
|---|
| 217 | # kludgey way of passing data to annotator |
|---|
| 218 | if annotator.get_annotation_type()[0] == 'givenlineno': |
|---|
| 219 | annotator.file_name = self.file_name |
|---|
| 220 | annotator.rev = self.rev |
|---|
| 221 | |
|---|
| 222 | cells.append(annotator.annotate_line(linenum, line)) |
|---|
| 223 | cells.append('<td>%s</td>\n' % space_re.sub(htmlify, line)) |
|---|
| 224 | buf.write('<tr>' + '\n'.join(cells) + '</tr>') |
|---|
| 225 | else: |
|---|
| 226 | if num < 0: |
|---|
| 227 | return '' |
|---|
| 228 | buf.write('</tbody></table>') |
|---|
| 229 | return buf.getvalue() |
|---|
| 230 | |
|---|
| 231 | |
|---|
| 232 | |
|---|
| 233 | # see ticket 2983 - this parse_args is added to Trac there |
|---|
| 234 | # so we'll define it here if someone is on a version without that |
|---|
| 235 | # http://trac.edgewall.org/ticket/2983 |
|---|
| 236 | try: |
|---|
| 237 | from trac.wiki.api import parse_args |
|---|
| 238 | except: |
|---|
| 239 | import re |
|---|
| 240 | def parse_args(args): |
|---|
| 241 | """Utility for parsing macro "content" and splitting them into arguments. |
|---|
| 242 | |
|---|
| 243 | The content is split along commas, unless they are escaped with a |
|---|
| 244 | backquote (like this: \,). |
|---|
| 245 | Named arguments a la Python are supported, and keys must be valid python |
|---|
| 246 | identifiers immediately followed by the "=" sign. |
|---|
| 247 | |
|---|
| 248 | >>> parse_args('') |
|---|
| 249 | ([], {}) |
|---|
| 250 | >>> parse_args('Some text') |
|---|
| 251 | (['Some text'], {}) |
|---|
| 252 | >>> parse_args('Some text, mode= 3, some other arg\, with a comma.') |
|---|
| 253 | (['Some text', ' some other arg, with a comma.'], {'mode': ' 3'}) |
|---|
| 254 | |
|---|
| 255 | """ |
|---|
| 256 | largs, kwargs = [], {} |
|---|
| 257 | if args: |
|---|
| 258 | for arg in re.split(r'(?<!\\),', args): |
|---|
| 259 | arg = arg.replace(r'\,', ',') |
|---|
| 260 | m = re.match(r'\s*[a-zA-Z_]\w+=', arg) |
|---|
| 261 | if m: |
|---|
| 262 | kwargs[arg[:m.end()-1].lstrip()] = arg[m.end():] |
|---|
| 263 | else: |
|---|
| 264 | largs.append(arg) |
|---|
| 265 | return largs, kwargs |
|---|
| 266 | |
|---|
| 267 | |
|---|