Merge lp:~jelmer/brz/bundle-keywords into lp:brz
- bundle-keywords
- Merge into trunk
Proposed by
Jelmer Vernooij
Status: | Work in progress |
---|---|
Proposed branch: | lp:~jelmer/brz/bundle-keywords |
Merge into: | lp:brz |
Diff against target: |
862 lines (+810/-0) 9 files modified
breezy/filters/__init__.py (+3/-0) breezy/plugins/keywords/NEWS (+18/-0) breezy/plugins/keywords/README.txt (+43/-0) breezy/plugins/keywords/TODO (+13/-0) breezy/plugins/keywords/__init__.py (+240/-0) breezy/plugins/keywords/keywords.py (+269/-0) breezy/plugins/keywords/tests/__init__.py (+19/-0) breezy/plugins/keywords/tests/test_conversion.py (+82/-0) breezy/plugins/keywords/tests/test_keywords_in_trees.py (+123/-0) |
To merge this branch: | bzr merge lp:~jelmer/brz/bundle-keywords |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Breezy developers | Pending | ||
Review via email: mp+358357@code.launchpad.net |
Commit message
Description of the change
Bundle the keywords plugin.
To post a comment you must log in.
lp:~jelmer/brz/bundle-keywords
updated
- 6694. By Jelmer Vernooij
-
Use absolute imports.
Unmerged revisions
- 6694. By Jelmer Vernooij
-
Use absolute imports.
- 6693. By Jelmer Vernooij
-
Fix tests with breezy.
- 6692. By Jelmer Vernooij
-
merge trunk.
- 6691. By Jelmer Vernooij
-
Bundle keywords plugin.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'breezy/filters/__init__.py' |
2 | --- breezy/filters/__init__.py 2018-07-17 22:59:51 +0000 |
3 | +++ breezy/filters/__init__.py 2018-11-12 21:28:40 +0000 |
4 | @@ -91,6 +91,9 @@ |
5 | """Relative path of file to tree-root.""" |
6 | return self._relpath |
7 | |
8 | + def file_id(self): |
9 | + return self.source_tree().path2id(self.relpath()) |
10 | + |
11 | def source_tree(self): |
12 | """Source Tree object.""" |
13 | return self._tree |
14 | |
15 | === added directory 'breezy/plugins/keywords' |
16 | === added file 'breezy/plugins/keywords/NEWS' |
17 | --- breezy/plugins/keywords/NEWS 1970-01-01 00:00:00 +0000 |
18 | +++ breezy/plugins/keywords/NEWS 2018-11-12 21:28:40 +0000 |
19 | @@ -0,0 +1,18 @@ |
20 | +########################## |
21 | +bzr-keywords Release Notes |
22 | +########################## |
23 | + |
24 | +.. contents:: |
25 | + |
26 | +In Development |
27 | +############## |
28 | + |
29 | +This version is suitable for use with Bazaar 1.14 or later using |
30 | +trees in the 1.14 format. |
31 | + |
32 | + |
33 | +0.1 28-Jul-2008 |
34 | +############### |
35 | + |
36 | +This version is suitable for testing with the development branch, |
37 | +~ian-clatworthy/bzr/bzr.content-filters. |
38 | |
39 | === added file 'breezy/plugins/keywords/README.txt' |
40 | --- breezy/plugins/keywords/README.txt 1970-01-01 00:00:00 +0000 |
41 | +++ breezy/plugins/keywords/README.txt 2018-11-12 21:28:40 +0000 |
42 | @@ -0,0 +1,43 @@ |
43 | +bzr-keywords: RCS-like keyword templates |
44 | +======================================== |
45 | + |
46 | +Overview |
47 | +-------- |
48 | + |
49 | +This plugin adds keyword filtering to selected files. This allows |
50 | +you to do things like include the current user and date in a web page. |
51 | + |
52 | + |
53 | +Installation |
54 | +------------ |
55 | + |
56 | +The easiest way to install this plugin is to either copy or symlink the |
57 | +directory into your ~/.bazaar/plugins directory. Be sure to rename the |
58 | +directory to keywords (instead of bzr-keywords). |
59 | + |
60 | +See http://bazaar-vcs.org/UsingPlugins for other options such as |
61 | +using the BZR_PLUGIN_PATH environment variable. |
62 | + |
63 | + |
64 | +Testing |
65 | +------- |
66 | + |
67 | +To test the plugin after installation: |
68 | + |
69 | + bzr selftest keywords.tests |
70 | + |
71 | + |
72 | +Documentation |
73 | +------------- |
74 | + |
75 | +To see the documentation after installation: |
76 | + |
77 | + bzr help keywords |
78 | + |
79 | + |
80 | +Licensing |
81 | +--------- |
82 | + |
83 | +This plugin is (C) Copyright Canonical Limited 2008 under the |
84 | +GPL Version 2 or later. Please see the file COPYING.txt for the licence |
85 | +details. |
86 | |
87 | === added file 'breezy/plugins/keywords/TODO' |
88 | --- breezy/plugins/keywords/TODO 1970-01-01 00:00:00 +0000 |
89 | +++ breezy/plugins/keywords/TODO 2018-11-12 21:28:40 +0000 |
90 | @@ -0,0 +1,13 @@ |
91 | +Things to consider: |
92 | + * python_escape (maybe called string_escape) ala xml_escape? |
93 | + * some sort of block construct so easier to include in ReST and properties |
94 | + files, e.g. |
95 | + .. $begin-keywords$ |
96 | + :name1: value1 |
97 | + :name2: value2 |
98 | + .. $end-keywords$ |
99 | + |
100 | +* Add tests for: |
101 | + * untested keyword values (including date formatting) |
102 | + * escaping |
103 | + * style formatting |
104 | |
105 | === added file 'breezy/plugins/keywords/__init__.py' |
106 | --- breezy/plugins/keywords/__init__.py 1970-01-01 00:00:00 +0000 |
107 | +++ breezy/plugins/keywords/__init__.py 2018-11-12 21:28:40 +0000 |
108 | @@ -0,0 +1,240 @@ |
109 | +# Copyright (C) 2008 Canonical Ltd |
110 | +# |
111 | +# This program is free software; you can redistribute it and/or modify |
112 | +# it under the terms of the GNU General Public License as published by |
113 | +# the Free Software Foundation; either version 2 of the License, or |
114 | +# (at your option) any later version. |
115 | +# |
116 | +# This program is distributed in the hope that it will be useful, |
117 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
118 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
119 | +# GNU General Public License for more details. |
120 | +# |
121 | +# You should have received a copy of the GNU General Public License |
122 | +# along with this program; if not, write to the Free Software |
123 | +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
124 | + |
125 | +r'''Keyword Templating |
126 | +================== |
127 | + |
128 | +Keyword templating is provided as a content filter where Bazaar internally |
129 | +stores a canonical format but outputs a convenience format. See |
130 | +``bzr help content-filters`` for general information about using these. |
131 | + |
132 | +Note: Content filtering is only supported in recently added formats, |
133 | +e.g. 1.14. |
134 | + |
135 | +Keyword templates are specified using the following patterns: |
136 | + |
137 | + * in canonical/compressed format: $Keyword$ |
138 | + * in convenience/expanded format: $Keyword: value $ |
139 | + |
140 | +When expanding, the existing text is retained if an unknown keyword is |
141 | +found. If the keyword is already expanded but known, the value is replaced. |
142 | +When compressing, the values of known keywords are removed. |
143 | + |
144 | +Keyword filtering needs to be enabled for selected branches and files via |
145 | +rules. See ``bzr help rules`` for general information on defining rules. |
146 | +For example, to enable keywords for all ``txt`` files on your system, add |
147 | +these lines to your ``BZR_HOME/rules`` file:: |
148 | + |
149 | + [name *.txt] |
150 | + keywords = on |
151 | + |
152 | +To disable keywords for ``txt`` files but enable them for ``html`` files:: |
153 | + |
154 | + [name *.txt] |
155 | + keywords = off |
156 | + |
157 | + [name *.html] |
158 | + keywords = xml_escape |
159 | + |
160 | +``xml_escape`` enables keyword expansion but it escapes special characters |
161 | +in keyword values so they can be safely included in HTML or XML files. |
162 | + |
163 | +The currently supported keywords are given below. |
164 | + |
165 | + ============= ========================================================= |
166 | + Keyword Description |
167 | + ============= ========================================================= |
168 | + Date the date and time the file was last modified |
169 | + Committer the committer (name and email) of the last change |
170 | + Authors the authors (names and emails) of the last change |
171 | + Revision-Id the unique id of the revision that last changed the file |
172 | + Path the relative path of the file in the tree |
173 | + Filename just the name part of the relative path |
174 | + Directory just the directory part of the relative path |
175 | + File-Id the unique id assigned to this file |
176 | + Now the current date and time |
177 | + User the current user (name and email) |
178 | + ============= ========================================================= |
179 | + |
180 | +If you want finer control over the formatting of names and email |
181 | +addresses, you can use the following keywords. |
182 | + |
183 | + ============= ======================================================= |
184 | + Keyword Description |
185 | + ============= ======================================================= |
186 | + Committer-Name just the name of the current committer |
187 | + Committer-Email just the email address of the current committer |
188 | + Author1-Name just the name of the first author |
189 | + Author1-Email just the email address of the first author |
190 | + Author2-Name just the name of the second author |
191 | + Author2-Email just the email address of the second author |
192 | + Author3-Name just the name of the third author |
193 | + Author3-Email just the email address of the third author |
194 | + User-Name just the name of the current user |
195 | + User-Email just the email address of the current user |
196 | + ============= ======================================================= |
197 | + |
198 | +Note: If you have more than 3 authors for a given revision, please |
199 | +ask on the Bazaar mailing list for an enhancement to support the |
200 | +number you need. |
201 | + |
202 | +By default, dates/times are output using this format:: |
203 | + |
204 | + YYYY-MM-DD HH:MM:SS+HH:MM |
205 | + |
206 | +To specify a custom format, add a configuration setting to |
207 | +``BZR_HOME/bazaar.conf`` like this:: |
208 | + |
209 | + keywords.format.Now = %A, %B %d, %Y |
210 | + |
211 | +The last part of the key needs to match the keyword name. The value must be |
212 | +a legal strftime (http://docs.python.org/lib/module-time.html) format. |
213 | +''' |
214 | + |
215 | +from __future__ import absolute_import |
216 | + |
217 | + |
218 | +from ... import ( |
219 | + builtins, |
220 | + commands, |
221 | + filters, |
222 | + option, |
223 | + ) |
224 | + |
225 | + |
226 | +def test_suite(): |
227 | + """Called by breezy to fetch tests for this plugin""" |
228 | + from unittest import TestSuite, TestLoader |
229 | + from .tests import ( |
230 | + test_conversion, |
231 | + test_keywords_in_trees, |
232 | + ) |
233 | + loader = TestLoader() |
234 | + suite = TestSuite() |
235 | + for module in [ |
236 | + test_conversion, |
237 | + test_keywords_in_trees, |
238 | + ]: |
239 | + suite.addTests(loader.loadTestsFromModule(module)) |
240 | + return suite |
241 | + |
242 | + |
243 | +# Define and register the filter stack map |
244 | +def _keywords_filter_stack_lookup(k): |
245 | + from .keywords import ( |
246 | + _kw_compressor, |
247 | + _normal_kw_expander, |
248 | + _xml_escape_kw_expander, |
249 | + ) |
250 | + filter_stack_map = { |
251 | + 'off': [], |
252 | + 'on': |
253 | + [filters.ContentFilter(_kw_compressor, _normal_kw_expander)], |
254 | + 'xml_escape': |
255 | + [filters.ContentFilter(_kw_compressor, _xml_escape_kw_expander)], |
256 | + } |
257 | + return filter_stack_map.get(k) |
258 | + |
259 | +try: |
260 | + register_filter = filters.filter_stacks_registry.register |
261 | +except AttributeError: |
262 | + register_filter = filters.register_filter_stack_map |
263 | + |
264 | +register_filter('keywords', _keywords_filter_stack_lookup) |
265 | + |
266 | + |
267 | +class cmd_cat(builtins.cmd_cat): |
268 | + """ |
269 | + The ``--keywords`` option specifies the keywords expansion |
270 | + style. By default (``raw`` style), no expansion is done. |
271 | + Other styles enable expansion in a ``cooked`` mode where both |
272 | + the keyword and its value are displayed inside $ markers, or in |
273 | + numerous publishing styles - ``publish``, ``publish-values`` and |
274 | + ``publish-names`` - where the $ markers are completely removed. |
275 | + The publishing styles do not support round-tripping back to the |
276 | + raw content but are useful for improving the readability of |
277 | + published web pages for example. |
278 | + |
279 | + Note: Files must have the ``keywords`` preference defined for them |
280 | + in order for the ``--keywords`` option to take effect. In particular, |
281 | + the preference specifies how keyword values are encoded for different |
282 | + filename patterns. See ``bzr help keywords`` for more information on |
283 | + how to specify the required preference using rules. |
284 | + """ |
285 | + |
286 | + # Add a new option to the builtin command and |
287 | + # override the inherited run() and help() methods |
288 | + |
289 | + takes_options = builtins.cmd_cat.takes_options + [ |
290 | + option.RegistryOption('keywords', |
291 | + lazy_registry=(__name__ + ".keywords", |
292 | + "_keyword_style_registry"), |
293 | + converter=lambda s: s, |
294 | + help='Keyword expansion style.')] |
295 | + |
296 | + def run(self, *args, **kwargs): |
297 | + """Process special options and delegate to superclass.""" |
298 | + if 'keywords' in kwargs: |
299 | + from .keywords import ( |
300 | + _keyword_style_registry, |
301 | + ) |
302 | + # Implicitly set the filters option |
303 | + kwargs['filters'] = True |
304 | + style = kwargs['keywords'] |
305 | + _keyword_style_registry.default_key = style |
306 | + del kwargs['keywords'] |
307 | + return super(cmd_cat, self).run(*args, **kwargs) |
308 | + |
309 | + def help(self): |
310 | + """Return help message including text from superclass.""" |
311 | + from inspect import getdoc |
312 | + return getdoc(super(cmd_cat, self)) + '\n\n' + getdoc(self) |
313 | + |
314 | + |
315 | +class cmd_export(builtins.cmd_export): |
316 | + # Add a new option to the builtin command and |
317 | + # override the inherited run() and help() methods |
318 | + |
319 | + takes_options = builtins.cmd_export.takes_options + [ |
320 | + option.RegistryOption('keywords', |
321 | + lazy_registry=(__name__ + ".keywords", |
322 | + "_keyword_style_registry"), |
323 | + converter=lambda s: s, |
324 | + help='Keyword expansion style.')] |
325 | + |
326 | + def run(self, *args, **kwargs): |
327 | + """Process special options and delegate to superclass.""" |
328 | + if 'keywords' in kwargs: |
329 | + from .keywords import ( |
330 | + _keyword_style_registry, |
331 | + ) |
332 | + # Implicitly set the filters option |
333 | + kwargs['filters'] = True |
334 | + style = kwargs['keywords'] |
335 | + _keyword_style_registry.default_key = style |
336 | + del kwargs['keywords'] |
337 | + return super(cmd_export, self).run(*args, **kwargs) |
338 | + |
339 | + def help(self): |
340 | + """Return help message including text from superclass.""" |
341 | + from inspect import getdoc |
342 | + # NOTE: Reuse of cmd_cat help below is deliberate, not a bug |
343 | + return getdoc(super(cmd_export, self)) + '\n\n' + getdoc(cmd_cat) |
344 | + |
345 | + |
346 | +# Register the command wrappers |
347 | +commands.register_command(cmd_cat, decorate=False) |
348 | +commands.register_command(cmd_export, decorate=False) |
349 | |
350 | === added file 'breezy/plugins/keywords/keywords.py' |
351 | --- breezy/plugins/keywords/keywords.py 1970-01-01 00:00:00 +0000 |
352 | +++ breezy/plugins/keywords/keywords.py 2018-11-12 21:28:40 +0000 |
353 | @@ -0,0 +1,269 @@ |
354 | +# Copyright (C) 2008 Canonical Ltd |
355 | +# |
356 | +# This program is free software; you can redistribute it and/or modify |
357 | +# it under the terms of the GNU General Public License as published by |
358 | +# the Free Software Foundation; either version 2 of the License, or |
359 | +# (at your option) any later version. |
360 | +# |
361 | +# This program is distributed in the hope that it will be useful, |
362 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
363 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
364 | +# GNU General Public License for more details. |
365 | +# |
366 | +# You should have received a copy of the GNU General Public License |
367 | +# along with this program; if not, write to the Free Software |
368 | +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
369 | + |
370 | +from __future__ import absolute_import |
371 | + |
372 | +import re, time |
373 | +from ... import ( |
374 | + debug, |
375 | + osutils, |
376 | + registry, |
377 | + trace, |
378 | + ) |
379 | +from ...sixish import text_type |
380 | + |
381 | +# Expansion styles |
382 | +# Note: Round-tripping is only required between the raw and cooked styles |
383 | +_keyword_style_registry = registry.Registry() |
384 | +_keyword_style_registry.register('raw', b'$%(name)s$') |
385 | +_keyword_style_registry.register('cooked', b'$%(name)s: %(value)s $') |
386 | +_keyword_style_registry.register('publish', b'%(name)s: %(value)s') |
387 | +_keyword_style_registry.register('publish-values', b'%(value)s') |
388 | +_keyword_style_registry.register('publish-names', b'%(name)s') |
389 | +_keyword_style_registry.default_key = 'cooked' |
390 | + |
391 | + |
392 | +# Regular expressions for matching the raw and cooked patterns |
393 | +_KW_RAW_RE = re.compile(b'\\$([\\w\\-]+)(:[^$]*)?\\$') |
394 | +_KW_COOKED_RE = re.compile(b'\\$([\\w\\-]+):([^$]+)\\$') |
395 | + |
396 | + |
397 | +# The registry of keywords. Other plugins may wish to add entries to this. |
398 | +keyword_registry = registry.Registry() |
399 | + |
400 | +# Revision-related keywords |
401 | +keyword_registry.register('Date', |
402 | + lambda c: format_date(c.revision().timestamp, c.revision().timezone, |
403 | + c.config(), 'Date')) |
404 | +keyword_registry.register('Committer', |
405 | + lambda c: c.revision().committer) |
406 | +keyword_registry.register('Authors', |
407 | + lambda c: ", ".join(c.revision().get_apparent_authors())) |
408 | +keyword_registry.register('Revision-Id', |
409 | + lambda c: c.revision_id()) |
410 | +keyword_registry.register('Path', |
411 | + lambda c: c.relpath()) |
412 | +keyword_registry.register('Directory', |
413 | + lambda c: osutils.split(c.relpath())[0]) |
414 | +keyword_registry.register('Filename', |
415 | + lambda c: osutils.split(c.relpath())[1]) |
416 | +keyword_registry.register('File-Id', |
417 | + lambda c: c.file_id()) |
418 | + |
419 | +# Environment-related keywords |
420 | +keyword_registry.register('Now', |
421 | + lambda c: format_date(time.time(), time.timezone, c.config(), 'Now')) |
422 | +keyword_registry.register('User', |
423 | + lambda c: c.config().username()) |
424 | + |
425 | +# Keywords for finer control over name & address formatting |
426 | +keyword_registry.register('Committer-Name', |
427 | + lambda c: extract_name(c.revision().committer)) |
428 | +keyword_registry.register('Committer-Email', |
429 | + lambda c: extract_email(c.revision().committer)) |
430 | +keyword_registry.register('Author1-Name', |
431 | + lambda c: extract_name_item(c.revision().get_apparent_authors(), 0)) |
432 | +keyword_registry.register('Author1-Email', |
433 | + lambda c: extract_email_item(c.revision().get_apparent_authors(), 0)) |
434 | +keyword_registry.register('Author2-Name', |
435 | + lambda c: extract_name_item(c.revision().get_apparent_authors(), 1)) |
436 | +keyword_registry.register('Author2-Email', |
437 | + lambda c: extract_email_item(c.revision().get_apparent_authors(), 1)) |
438 | +keyword_registry.register('Author3-Name', |
439 | + lambda c: extract_name_item(c.revision().get_apparent_authors(), 2)) |
440 | +keyword_registry.register('Author3-Email', |
441 | + lambda c: extract_email_item(c.revision().get_apparent_authors(), 2)) |
442 | +keyword_registry.register('User-Name', |
443 | + lambda c: extract_name(c.config().username())) |
444 | +keyword_registry.register('User-Email', |
445 | + lambda c: extract_email(c.config().username())) |
446 | + |
447 | + |
448 | +def format_date(timestamp, offset=0, cfg=None, name=None): |
449 | + """Return a formatted date string. |
450 | + |
451 | + :param timestamp: Seconds since the epoch. |
452 | + :param offset: Timezone offset in seconds east of utc. |
453 | + """ |
454 | + if cfg is not None and name is not None: |
455 | + cfg_key = 'keywords.format.%s' % (name,) |
456 | + format = cfg.get_user_option(cfg_key) |
457 | + else: |
458 | + format = None |
459 | + return osutils.format_date(timestamp, offset, date_fmt=format) |
460 | + |
461 | + |
462 | +def extract_name(userid): |
463 | + """Extract the name out of a user-id string. |
464 | + |
465 | + user-id strings have the format 'name <email>'. |
466 | + """ |
467 | + if userid and userid[-1] == '>': |
468 | + return userid[:-1].rsplit('<', 1)[0].rstrip() |
469 | + else: |
470 | + return userid |
471 | + |
472 | + |
473 | +def extract_email(userid): |
474 | + """Extract the email address out of a user-id string. |
475 | + |
476 | + user-id strings have the format 'name <email>'. |
477 | + """ |
478 | + if userid and userid[-1] == '>': |
479 | + return userid[:-1].rsplit('<', 1)[1] |
480 | + else: |
481 | + return userid |
482 | + |
483 | +def extract_name_item(seq, n): |
484 | + """Extract the name out of the nth item in a sequence of user-ids. |
485 | + |
486 | + :return: the user-name or an empty string |
487 | + """ |
488 | + try: |
489 | + return extract_name(seq[n]) |
490 | + except IndexError: |
491 | + return "" |
492 | + |
493 | + |
494 | +def extract_email_item(seq, n): |
495 | + """Extract the email out of the nth item in a sequence of user-ids. |
496 | + |
497 | + :return: the email address or an empty string |
498 | + """ |
499 | + try: |
500 | + return extract_email(seq[n]) |
501 | + except IndexError: |
502 | + return "" |
503 | + |
504 | + |
505 | +def compress_keywords(s, keyword_dicts): |
506 | + """Replace cooked style keywords with raw style in a string. |
507 | + |
508 | + Note: If the keyword is not known, the text is not modified. |
509 | + |
510 | + :param s: the string |
511 | + :param keyword_dicts: an iterable of keyword dictionaries. |
512 | + :return: the string with keywords compressed |
513 | + """ |
514 | + _raw_style = _keyword_style_registry.get('raw') |
515 | + result = b'' |
516 | + rest = s |
517 | + while True: |
518 | + match = _KW_COOKED_RE.search(rest) |
519 | + if not match: |
520 | + break |
521 | + result += rest[:match.start()] |
522 | + keyword = match.group(1) |
523 | + expansion = _get_from_dicts(keyword_dicts, keyword.decode('ascii')) |
524 | + if expansion is None: |
525 | + # Unknown expansion - leave as is |
526 | + result += match.group(0) |
527 | + else: |
528 | + result += _raw_style % {b'name': keyword} |
529 | + rest = rest[match.end():] |
530 | + return result + rest |
531 | + |
532 | + |
533 | +def expand_keywords(s, keyword_dicts, context=None, encoder=None, style=None): |
534 | + """Replace raw style keywords with another style in a string. |
535 | + |
536 | + Note: If the keyword is already in the expanded style, the value is |
537 | + not replaced. |
538 | + |
539 | + :param s: the string |
540 | + :param keyword_dicts: an iterable of keyword dictionaries. If values |
541 | + are callables, they are executed to find the real value. |
542 | + :param context: the parameter to pass to callable values |
543 | + :param style: the style of expansion to use of None for the default |
544 | + :return: the string with keywords expanded |
545 | + """ |
546 | + _expanded_style = _keyword_style_registry.get(style) |
547 | + result = b'' |
548 | + rest = s |
549 | + while True: |
550 | + match = _KW_RAW_RE.search(rest) |
551 | + if not match: |
552 | + break |
553 | + result += rest[:match.start()] |
554 | + keyword = match.group(1) |
555 | + expansion = _get_from_dicts(keyword_dicts, keyword.decode('ascii')) |
556 | + if callable(expansion): |
557 | + try: |
558 | + expansion = expansion(context) |
559 | + except AttributeError as err: |
560 | + if 'error' in debug.debug_flags: |
561 | + trace.note("error evaluating %s for keyword %s: %s", |
562 | + expansion, keyword, err) |
563 | + expansion = b"(evaluation error)" |
564 | + if isinstance(expansion, text_type): |
565 | + expansion = expansion.encode('utf-8') |
566 | + if expansion is None: |
567 | + # Unknown expansion - leave as is |
568 | + result += match.group(0) |
569 | + rest = rest[match.end():] |
570 | + continue |
571 | + if b'$' in expansion: |
572 | + # Expansion is not safe to be collapsed later |
573 | + expansion = b"(value unsafe to expand)" |
574 | + if encoder is not None: |
575 | + expansion = encoder(expansion) |
576 | + params = {b'name': keyword, b'value': expansion} |
577 | + result += _expanded_style % params |
578 | + rest = rest[match.end():] |
579 | + return result + rest |
580 | + |
581 | + |
582 | +def _get_from_dicts(dicts, key, default=None): |
583 | + """Search a sequence of dictionaries or registries for a key. |
584 | + |
585 | + :return: the value, or default if not found |
586 | + """ |
587 | + for dict in dicts: |
588 | + if key in dict: |
589 | + return dict.get(key) |
590 | + return default |
591 | + |
592 | + |
593 | +def _xml_escape(s): |
594 | + """Escape a string so it can be included safely in XML/HTML.""" |
595 | + # Compile the regular expressions if not already done |
596 | + from ... import xml8 |
597 | + xml8._ensure_utf8_re() |
598 | + # Convert and strip the trailing quote |
599 | + return xml8._encode_and_escape(s)[:-1] |
600 | + |
601 | + |
602 | +def _kw_compressor(chunks, context=None): |
603 | + """Filter that replaces keywords with their compressed form.""" |
604 | + text = b''.join(chunks) |
605 | + return [compress_keywords(text, [keyword_registry])] |
606 | + |
607 | + |
608 | +def _kw_expander(chunks, context, encoder=None): |
609 | + """Keyword expander.""" |
610 | + text = b''.join(chunks) |
611 | + return [expand_keywords(text, [keyword_registry], context=context, |
612 | + encoder=encoder)] |
613 | + |
614 | + |
615 | +def _normal_kw_expander(chunks, context=None): |
616 | + """Filter that replaces keywords with their expanded form.""" |
617 | + return _kw_expander(chunks, context) |
618 | + |
619 | + |
620 | +def _xml_escape_kw_expander(chunks, context=None): |
621 | + """Filter that replaces keywords with a form suitable for use in XML.""" |
622 | + return _kw_expander(chunks, context, encoder=_xml_escape) |
623 | |
624 | === added directory 'breezy/plugins/keywords/tests' |
625 | === added file 'breezy/plugins/keywords/tests/__init__.py' |
626 | --- breezy/plugins/keywords/tests/__init__.py 1970-01-01 00:00:00 +0000 |
627 | +++ breezy/plugins/keywords/tests/__init__.py 2018-11-12 21:28:40 +0000 |
628 | @@ -0,0 +1,19 @@ |
629 | +# Copyright (C) 2008 Canonical Limited. |
630 | +# |
631 | +# This program is free software; you can redistribute it and/or modify |
632 | +# it under the terms of the GNU General Public License as published by |
633 | +# the Free Software Foundation; version 2 of the License. |
634 | +# |
635 | +# This program is distributed in the hope that it will be useful, |
636 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
637 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
638 | +# GNU General Public License for more details. |
639 | +# |
640 | +# You should have received a copy of the GNU General Public License |
641 | +# along with this program; if not, write to the Free Software |
642 | +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA |
643 | +# |
644 | + |
645 | +"""Tests for bzr-keywords.""" |
646 | + |
647 | +from __future__ import absolute_import |
648 | |
649 | === added file 'breezy/plugins/keywords/tests/test_conversion.py' |
650 | --- breezy/plugins/keywords/tests/test_conversion.py 1970-01-01 00:00:00 +0000 |
651 | +++ breezy/plugins/keywords/tests/test_conversion.py 2018-11-12 21:28:40 +0000 |
652 | @@ -0,0 +1,82 @@ |
653 | +# Copyright (C) 2008 Canonical Limited. |
654 | +# |
655 | +# This program is free software; you can redistribute it and/or modify |
656 | +# it under the terms of the GNU General Public License as published by |
657 | +# the Free Software Foundation; version 2 of the License. |
658 | +# |
659 | +# This program is distributed in the hope that it will be useful, |
660 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
661 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
662 | +# GNU General Public License for more details. |
663 | +# |
664 | +# You should have received a copy of the GNU General Public License |
665 | +# along with this program; if not, write to the Free Software |
666 | +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA |
667 | +# |
668 | + |
669 | +from __future__ import absolute_import |
670 | + |
671 | +"""Tests for keyword expansion/contraction.""" |
672 | + |
673 | + |
674 | +from .... import tests |
675 | +from ..keywords import ( |
676 | + compress_keywords, |
677 | + expand_keywords, |
678 | + ) |
679 | + |
680 | + |
681 | +# Sample unexpanded and expanded pairs for a keyword dictionary |
682 | +_keywords = {'Foo': 'FOO!', 'Bar': 'bar', 'CallMe': lambda c: "now!"} |
683 | +_keywords_dicts = [{'Foo': 'FOO!'}, {'Bar': 'bar', 'CallMe': lambda c: "now!"}] |
684 | +_samples = [ |
685 | + (b'$Foo$', b'$Foo: FOO! $'), |
686 | + (b'$Foo', b'$Foo'), |
687 | + (b'Foo$', b'Foo$'), |
688 | + (b'$Foo$ xyz', b'$Foo: FOO! $ xyz'), |
689 | + (b'abc $Foo$', b'abc $Foo: FOO! $'), |
690 | + (b'abc $Foo$ xyz', b'abc $Foo: FOO! $ xyz'), |
691 | + (b'$Foo$$Bar$', b'$Foo: FOO! $$Bar: bar $'), |
692 | + (b'abc $Foo$ xyz $Bar$ qwe', b'abc $Foo: FOO! $ xyz $Bar: bar $ qwe'), |
693 | + (b'$Unknown$$Bar$', b'$Unknown$$Bar: bar $'), |
694 | + (b'$Unknown: unkn $$Bar$', b'$Unknown: unkn $$Bar: bar $'), |
695 | + (b'$Foo$$Unknown$', b'$Foo: FOO! $$Unknown$'), |
696 | + (b'$CallMe$', b'$CallMe: now! $'), |
697 | + ] |
698 | + |
699 | + |
700 | +class TestKeywordsConversion(tests.TestCase): |
701 | + |
702 | + def test_compression(self): |
703 | + # Test keyword expansion |
704 | + for raw, cooked in _samples: |
705 | + self.assertEqual(raw, compress_keywords(cooked, [_keywords])) |
706 | + |
707 | + def test_expansion(self): |
708 | + # Test keyword expansion |
709 | + for raw, cooked in _samples: |
710 | + self.assertEqual(cooked, expand_keywords(raw, [_keywords])) |
711 | + |
712 | + def test_expansion_across_multiple_dictionaries(self): |
713 | + # Check all still works when keywords in different dictionaries |
714 | + for raw, cooked in _samples: |
715 | + self.assertEqual(cooked, expand_keywords(raw, _keywords_dicts)) |
716 | + |
717 | + def test_expansion_feedback_when_unsafe(self): |
718 | + kw_dict = {'Xxx': 'y$z'} |
719 | + self.assertEqual(b'$Xxx: (value unsafe to expand) $', |
720 | + expand_keywords(b'$Xxx$', [kw_dict])) |
721 | + |
722 | + def test_expansion_feedback_when_error(self): |
723 | + kw_dict = {'Xxx': lambda ctx: ctx.unknownMethod} |
724 | + self.assertEqual(b'$Xxx: (evaluation error) $', |
725 | + expand_keywords(b'$Xxx$', [kw_dict])) |
726 | + |
727 | + def test_expansion_replaced_if_already_expanded(self): |
728 | + s = b'$Xxx: old value $' |
729 | + kw_dict = {'Xxx': 'new value'} |
730 | + self.assertEqual(b'$Xxx: new value $', expand_keywords(s, [kw_dict])) |
731 | + |
732 | + def test_expansion_ignored_if_already_expanded_but_unknown(self): |
733 | + s = b'$Xxx: old value $' |
734 | + self.assertEqual(b'$Xxx: old value $', expand_keywords(s, [{}])) |
735 | |
736 | === added file 'breezy/plugins/keywords/tests/test_keywords_in_trees.py' |
737 | --- breezy/plugins/keywords/tests/test_keywords_in_trees.py 1970-01-01 00:00:00 +0000 |
738 | +++ breezy/plugins/keywords/tests/test_keywords_in_trees.py 2018-11-12 21:28:40 +0000 |
739 | @@ -0,0 +1,123 @@ |
740 | +# Copyright (C) 2009 Canonical Limited. |
741 | +# |
742 | +# This program is free software; you can redistribute it and/or modify |
743 | +# it under the terms of the GNU General Public License as published by |
744 | +# the Free Software Foundation; version 2 of the License. |
745 | +# |
746 | +# This program is distributed in the hope that it will be useful, |
747 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
748 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
749 | +# GNU General Public License for more details. |
750 | +# |
751 | +# You should have received a copy of the GNU General Public License |
752 | +# along with this program; if not, write to the Free Software |
753 | +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA |
754 | +# |
755 | + |
756 | +from __future__ import absolute_import |
757 | + |
758 | +"""Tests for keyword expansion/contraction in trees.""" |
759 | + |
760 | +## TODO: add tests for xml_escaped |
761 | + |
762 | +from .... import rules |
763 | +from ....tests import TestCaseWithTransport |
764 | +from ....workingtree import WorkingTree |
765 | + |
766 | + |
767 | +# Sample files. We exclude keywords that change from one run to another, |
768 | +# TODO: Test Date, Path, Now, User, User-Email |
769 | +_sample_text_raw = b""" |
770 | +Committer: $Committer$ |
771 | +Committer-Name: $Committer-Name$ |
772 | +Authors: $Authors$ |
773 | +Author1-Email: $Author1-Email$ |
774 | +Revision-Id: $Revision-Id$ |
775 | +Filename: $Filename$ |
776 | +Directory: $Directory$ |
777 | +File-Id: $File-Id$ |
778 | +""" |
779 | +#User: $User$ |
780 | +#User-Email: $User-Email$ |
781 | +_sample_text_cooked = b""" |
782 | +Committer: $Committer: Jane Smith <jane@example.com> $ |
783 | +Committer-Name: $Committer-Name: Jane Smith $ |
784 | +Authors: $Authors: Sue Smith <sue@example.com> $ |
785 | +Author1-Email: $Author1-Email: sue@example.com $ |
786 | +Revision-Id: $Revision-Id: rev1-id $ |
787 | +Filename: $Filename: file1 $ |
788 | +Directory: $Directory: $ |
789 | +File-Id: $File-Id: file1-id $ |
790 | +""" |
791 | +#User: $User: Dave Smith <dave@example.com>$ |
792 | +#User-Email: $User-Email: dave@example.com $ |
793 | +_sample_binary = _sample_text_raw + b"""\x00""" |
794 | + |
795 | + |
796 | +class TestKeywordsInTrees(TestCaseWithTransport): |
797 | + |
798 | + def patch_rules_searcher(self, keywords): |
799 | + """Patch in a custom rules searcher with a given keywords setting.""" |
800 | + if keywords is None: |
801 | + WorkingTree._get_rules_searcher = self.real_rules_searcher |
802 | + else: |
803 | + def custom__rules_searcher(tree, default_searcher): |
804 | + return rules._IniBasedRulesSearcher([ |
805 | + '[name *]\n', |
806 | + 'keywords=%s\n' % keywords, |
807 | + ]) |
808 | + WorkingTree._get_rules_searcher = custom__rules_searcher |
809 | + |
810 | + def prepare_tree(self, content, keywords=None): |
811 | + """Prepare a working tree and commit some content.""" |
812 | + def restore_real_rules_searcher(): |
813 | + WorkingTree._get_rules_searcher = self.real_rules_searcher |
814 | + self.real_rules_searcher = WorkingTree._get_rules_searcher |
815 | + self.addCleanup(restore_real_rules_searcher) |
816 | + self.patch_rules_searcher(keywords) |
817 | + t = self.make_branch_and_tree('tree1') |
818 | + # Patch is a custom username |
819 | + #def custom_global_config(): |
820 | + # config_file = StringIO( |
821 | + # "[DEFAULT]\nemail=Dave Smith <dave@example.com>\n") |
822 | + # my_config = config.GlobalConfig() |
823 | + # my_config._parser = my_config._get_parser(file=config_file) |
824 | + # return my_config |
825 | + #t.branch.get_config()._get_global_config = custom_global_config |
826 | + self.build_tree_contents([('tree1/file1', content)]) |
827 | + t.add(['file1'], [b'file1-id']) |
828 | + t.commit("add file1", rev_id=b"rev1-id", |
829 | + committer="Jane Smith <jane@example.com>", |
830 | + authors=["Sue Smith <sue@example.com>"]) |
831 | + basis = t.basis_tree() |
832 | + basis.lock_read() |
833 | + self.addCleanup(basis.unlock) |
834 | + return t, basis |
835 | + |
836 | + def assertNewContentForSetting(self, wt, keywords, expected): |
837 | + """Clone a working tree and check the convenience content.""" |
838 | + self.patch_rules_searcher(keywords) |
839 | + wt2 = wt.controldir.sprout('tree-%s' % keywords).open_workingtree() |
840 | + # To see exactly what got written to disk, we need an unfiltered read |
841 | + content = wt2.get_file_text('file1', filtered=False) |
842 | + self.assertEqual(expected, content) |
843 | + |
844 | + def assertContent(self, wt, basis, expected_raw, expected_cooked): |
845 | + """Check the committed content and content in cloned trees.""" |
846 | + basis_content = basis.get_file_text('file1') |
847 | + self.assertEqualDiff(expected_raw, basis_content) |
848 | + self.assertNewContentForSetting(wt, None, expected_raw) |
849 | + self.assertNewContentForSetting(wt, 'on', expected_cooked) |
850 | + self.assertNewContentForSetting(wt, 'off', expected_raw) |
851 | + |
852 | + def test_keywords_no_rules(self): |
853 | + wt, basis = self.prepare_tree(_sample_text_raw) |
854 | + self.assertContent(wt, basis, _sample_text_raw, _sample_text_cooked) |
855 | + |
856 | + def test_keywords_on(self): |
857 | + wt, basis = self.prepare_tree(_sample_text_raw, keywords='on') |
858 | + self.assertContent(wt, basis, _sample_text_raw, _sample_text_cooked) |
859 | + |
860 | + def test_keywords_off(self): |
861 | + wt, basis = self.prepare_tree(_sample_text_raw, keywords='off') |
862 | + self.assertContent(wt, basis, _sample_text_raw, _sample_text_cooked) |