1#!/usr/bin/env python
2# PYTHON_ARGCOMPLETE_OK
3"""Changelog generator and linter."""
4
5from __future__ import (absolute_import, division, print_function)
6__metaclass__ = type
7
8import argparse
9import collections
10import datetime
11import docutils.utils
12import json
13import logging
14import os
15import packaging.version
16import re
17import rstcheck
18import subprocess
19import sys
20import yaml
21
22try:
23    import argcomplete
24except ImportError:
25    argcomplete = None
26
27from ansible import constants as C
28from ansible.module_utils.six import string_types
29from ansible.module_utils._text import to_bytes, to_text
30
31BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
32CHANGELOG_DIR = os.path.join(BASE_DIR, 'changelogs')
33CONFIG_PATH = os.path.join(CHANGELOG_DIR, 'config.yaml')
34CHANGES_PATH = os.path.join(CHANGELOG_DIR, '.changes.yaml')
35LOGGER = logging.getLogger('changelog')
36
37
38def main():
39    """Main program entry point."""
40    parser = argparse.ArgumentParser(description='Changelog generator and linter.')
41
42    common = argparse.ArgumentParser(add_help=False)
43    common.add_argument('-v', '--verbose',
44                        action='count',
45                        default=0,
46                        help='increase verbosity of output')
47
48    subparsers = parser.add_subparsers(metavar='COMMAND')
49
50    lint_parser = subparsers.add_parser('lint',
51                                        parents=[common],
52                                        help='check changelog fragments for syntax errors')
53    lint_parser.set_defaults(func=command_lint)
54    lint_parser.add_argument('fragments',
55                             metavar='FRAGMENT',
56                             nargs='*',
57                             help='path to fragment to test')
58
59    release_parser = subparsers.add_parser('release',
60                                           parents=[common],
61                                           help='add a new release to the change metadata')
62    release_parser.set_defaults(func=command_release)
63    release_parser.add_argument('--version',
64                                help='override release version')
65    release_parser.add_argument('--codename',
66                                help='override release codename')
67    release_parser.add_argument('--date',
68                                default=str(datetime.date.today()),
69                                help='override release date')
70    release_parser.add_argument('--reload-plugins',
71                                action='store_true',
72                                help='force reload of plugin cache')
73
74    generate_parser = subparsers.add_parser('generate',
75                                            parents=[common],
76                                            help='generate the changelog')
77    generate_parser.set_defaults(func=command_generate)
78    generate_parser.add_argument('--reload-plugins',
79                                 action='store_true',
80                                 help='force reload of plugin cache')
81
82    if argcomplete:
83        argcomplete.autocomplete(parser)
84
85    formatter = logging.Formatter('%(levelname)s %(message)s')
86
87    handler = logging.StreamHandler(sys.stdout)
88    handler.setFormatter(formatter)
89
90    LOGGER.addHandler(handler)
91    LOGGER.setLevel(logging.WARN)
92
93    args = parser.parse_args()
94
95    if args.verbose > 2:
96        LOGGER.setLevel(logging.DEBUG)
97    elif args.verbose > 1:
98        LOGGER.setLevel(logging.INFO)
99    elif args.verbose > 0:
100        LOGGER.setLevel(logging.WARN)
101
102    args.func(args)
103
104
105def command_lint(args):
106    """
107    :type args: any
108    """
109    paths = args.fragments  # type: list
110
111    exceptions = []
112    fragments = load_fragments(paths, exceptions)
113    lint_fragments(fragments, exceptions)
114
115
116def command_release(args):
117    """
118    :type args: any
119    """
120    version = args.version  # type: str
121    codename = args.codename  # type: str
122    date = datetime.datetime.strptime(args.date, "%Y-%m-%d").date()
123    reload_plugins = args.reload_plugins  # type: bool
124
125    if not version or not codename:
126        import ansible.release
127
128        version = version or ansible.release.__version__
129        codename = codename or ansible.release.__codename__
130
131    changes = load_changes()
132    plugins = load_plugins(version=version, force_reload=reload_plugins)
133    fragments = load_fragments()
134    add_release(changes, plugins, fragments, version, codename, date)
135    generate_changelog(changes, plugins, fragments)
136
137
138def command_generate(args):
139    """
140    :type args: any
141    """
142    reload_plugins = args.reload_plugins  # type: bool
143
144    changes = load_changes()
145    plugins = load_plugins(version=changes.latest_version, force_reload=reload_plugins)
146    fragments = load_fragments()
147    generate_changelog(changes, plugins, fragments)
148
149
150def load_changes():
151    """Load changes metadata.
152    :rtype: ChangesMetadata
153    """
154    changes = ChangesMetadata(CHANGES_PATH)
155
156    return changes
157
158
159def load_plugins(version, force_reload):
160    """Load plugins from ansible-doc.
161    :type version: str
162    :type force_reload: bool
163    :rtype: list[PluginDescription]
164    """
165    plugin_cache_path = os.path.join(CHANGELOG_DIR, '.plugin-cache.yaml')
166    plugins_data = {}
167
168    if not force_reload and os.path.exists(plugin_cache_path):
169        with open(plugin_cache_path, 'r') as plugin_cache_fd:
170            plugins_data = yaml.safe_load(plugin_cache_fd)
171
172            if version != plugins_data['version']:
173                LOGGER.info('version %s does not match plugin cache version %s', version, plugins_data['version'])
174                plugins_data = {}
175
176    if not plugins_data:
177        LOGGER.info('refreshing plugin cache')
178
179        plugins_data['version'] = version
180        plugins_data['plugins'] = {}
181
182        for plugin_type in C.DOCUMENTABLE_PLUGINS:
183            output = subprocess.check_output([os.path.join(BASE_DIR, 'bin', 'ansible-doc'),
184                                              '--json', '--metadata-dump', '-t', plugin_type])
185            plugins_data['plugins'][plugin_type] = json.loads(to_text(output))
186
187        # remove empty namespaces from plugins
188        for section in plugins_data['plugins'].values():
189            for plugin in section.values():
190                if plugin['namespace'] is None:
191                    del plugin['namespace']
192
193        with open(plugin_cache_path, 'w') as plugin_cache_fd:
194            yaml.safe_dump(plugins_data, plugin_cache_fd, default_flow_style=False)
195
196    plugins = PluginDescription.from_dict(plugins_data['plugins'])
197
198    return plugins
199
200
201def load_fragments(paths=None, exceptions=None):
202    """
203    :type paths: list[str] | None
204    :type exceptions: list[tuple[str, Exception]] | None
205    """
206    if not paths:
207        config = ChangelogConfig(CONFIG_PATH)
208        fragments_dir = os.path.join(CHANGELOG_DIR, config.notes_dir)
209        paths = [os.path.join(fragments_dir, path) for path in os.listdir(fragments_dir)]
210
211    fragments = []
212
213    for path in paths:
214        bn_path = os.path.basename(path)
215        if bn_path.startswith('.') or not bn_path.endswith(('.yml', '.yaml')):
216            continue
217        try:
218            fragments.append(ChangelogFragment.load(path))
219        except Exception as ex:
220            if exceptions is not None:
221                exceptions.append((path, ex))
222            else:
223                raise
224
225    return fragments
226
227
228def lint_fragments(fragments, exceptions):
229    """
230    :type fragments: list[ChangelogFragment]
231    :type exceptions: list[tuple[str, Exception]]
232    """
233    config = ChangelogConfig(CONFIG_PATH)
234    linter = ChangelogFragmentLinter(config)
235
236    errors = [(ex[0], 0, 0, 'yaml parsing error') for ex in exceptions]
237
238    for fragment in fragments:
239        errors += linter.lint(fragment)
240
241    messages = sorted(set('%s:%d:%d: %s' % (error[0], error[1], error[2], error[3]) for error in errors))
242
243    for message in messages:
244        print(message)
245
246
247def add_release(changes, plugins, fragments, version, codename, date):
248    """Add a release to the change metadata.
249    :type changes: ChangesMetadata
250    :type plugins: list[PluginDescription]
251    :type fragments: list[ChangelogFragment]
252    :type version: str
253    :type codename: str
254    :type date: datetime.date
255    """
256    # make sure the version parses
257    packaging.version.Version(version)
258
259    LOGGER.info('release version %s is a %s version', version, 'release' if is_release_version(version) else 'pre-release')
260
261    # filter out plugins which were not added in this release
262    plugins = list(filter(lambda p: version.startswith('%s.' % p.version_added), plugins))
263
264    changes.add_release(version, codename, date)
265
266    for plugin in plugins:
267        changes.add_plugin(plugin.type, plugin.name, version)
268
269    for fragment in fragments:
270        changes.add_fragment(fragment.name, version)
271
272    changes.save()
273
274
275def generate_changelog(changes, plugins, fragments):
276    """Generate the changelog.
277    :type changes: ChangesMetadata
278    :type plugins: list[PluginDescription]
279    :type fragments: list[ChangelogFragment]
280    """
281    config = ChangelogConfig(CONFIG_PATH)
282
283    changes.prune_plugins(plugins)
284    changes.prune_fragments(fragments)
285    changes.save()
286
287    major_minor_version = '.'.join(changes.latest_version.split('.')[:2])
288    changelog_path = os.path.join(CHANGELOG_DIR, 'CHANGELOG-v%s.rst' % major_minor_version)
289
290    generator = ChangelogGenerator(config, changes, plugins, fragments)
291    rst = generator.generate()
292
293    with open(changelog_path, 'wb') as changelog_fd:
294        changelog_fd.write(to_bytes(rst))
295
296
297class ChangelogFragmentLinter(object):
298    """Linter for ChangelogFragments."""
299    def __init__(self, config):
300        """
301        :type config: ChangelogConfig
302        """
303        self.config = config
304
305    def lint(self, fragment):
306        """Lint a ChangelogFragment.
307        :type fragment: ChangelogFragment
308        :rtype: list[(str, int, int, str)]
309        """
310        errors = []
311
312        for section, lines in fragment.content.items():
313            if section == self.config.prelude_name:
314                if not isinstance(lines, string_types):
315                    errors.append((fragment.path, 0, 0, 'section "%s" must be type str not %s' % (section, type(lines).__name__)))
316            else:
317                # doesn't account for prelude but only the RM should be adding those
318                if not isinstance(lines, list):
319                    errors.append((fragment.path, 0, 0, 'section "%s" must be type list not %s' % (section, type(lines).__name__)))
320
321                if section not in self.config.sections:
322                    errors.append((fragment.path, 0, 0, 'invalid section: %s' % section))
323
324            if isinstance(lines, list):
325                for line in lines:
326                    if not isinstance(line, string_types):
327                        errors.append((fragment.path, 0, 0, 'section "%s" list items must be type str not %s' % (section, type(line).__name__)))
328                        continue
329
330                    results = rstcheck.check(line, filename=fragment.path, report_level=docutils.utils.Reporter.WARNING_LEVEL)
331                    errors += [(fragment.path, 0, 0, result[1]) for result in results]
332            elif isinstance(lines, string_types):
333                results = rstcheck.check(lines, filename=fragment.path, report_level=docutils.utils.Reporter.WARNING_LEVEL)
334                errors += [(fragment.path, 0, 0, result[1]) for result in results]
335
336        return errors
337
338
339def is_release_version(version):
340    """Deterine the type of release from the given version.
341    :type version: str
342    :rtype: bool
343    """
344    config = ChangelogConfig(CONFIG_PATH)
345
346    tag_format = 'v%s' % version
347
348    if re.search(config.pre_release_tag_re, tag_format):
349        return False
350
351    if re.search(config.release_tag_re, tag_format):
352        return True
353
354    raise Exception('unsupported version format: %s' % version)
355
356
357class PluginDescription(object):
358    """Plugin description."""
359    def __init__(self, plugin_type, name, namespace, description, version_added):
360        self.type = plugin_type
361        self.name = name
362        self.namespace = namespace
363        self.description = description
364        self.version_added = version_added
365
366    @staticmethod
367    def from_dict(data):
368        """Return a list of PluginDescription objects from the given data.
369        :type data: dict[str, dict[str, dict[str, any]]]
370        :rtype: list[PluginDescription]
371        """
372        plugins = []
373
374        for plugin_type, plugin_data in data.items():
375            for plugin_name, plugin_details in plugin_data.items():
376                plugins.append(PluginDescription(
377                    plugin_type=plugin_type,
378                    name=plugin_name,
379                    namespace=plugin_details.get('namespace'),
380                    description=plugin_details['description'],
381                    version_added=plugin_details['version_added'],
382                ))
383
384        return plugins
385
386
387class ChangelogGenerator(object):
388    """Changelog generator."""
389    def __init__(self, config, changes, plugins, fragments):
390        """
391        :type config: ChangelogConfig
392        :type changes: ChangesMetadata
393        :type plugins: list[PluginDescription]
394        :type fragments: list[ChangelogFragment]
395        """
396        self.config = config
397        self.changes = changes
398        self.plugins = {}
399        self.modules = []
400
401        for plugin in plugins:
402            if plugin.type == 'module':
403                self.modules.append(plugin)
404            else:
405                if plugin.type not in self.plugins:
406                    self.plugins[plugin.type] = []
407
408                self.plugins[plugin.type].append(plugin)
409
410        self.fragments = dict((fragment.name, fragment) for fragment in fragments)
411
412    def generate(self):
413        """Generate the changelog.
414        :rtype: str
415        """
416        latest_version = self.changes.latest_version
417        codename = self.changes.releases[latest_version]['codename']
418        major_minor_version = '.'.join(latest_version.split('.')[:2])
419
420        release_entries = collections.OrderedDict()
421        entry_version = latest_version
422        entry_fragment = None
423
424        for version in sorted(self.changes.releases, reverse=True, key=packaging.version.Version):
425            release = self.changes.releases[version]
426
427            if is_release_version(version):
428                entry_version = version  # next version is a release, it needs its own entry
429                entry_fragment = None
430            elif not is_release_version(entry_version):
431                entry_version = version  # current version is a pre-release, next version needs its own entry
432                entry_fragment = None
433
434            if entry_version not in release_entries:
435                release_entries[entry_version] = dict(
436                    fragments=[],
437                    modules=[],
438                    plugins={},
439                )
440
441            entry_config = release_entries[entry_version]
442
443            fragment_names = []
444
445            # only keep the latest prelude fragment for an entry
446            for fragment_name in release.get('fragments', []):
447                fragment = self.fragments[fragment_name]
448
449                if self.config.prelude_name in fragment.content:
450                    if entry_fragment:
451                        LOGGER.info('skipping fragment %s in version %s due to newer fragment %s in version %s',
452                                    fragment_name, version, entry_fragment, entry_version)
453                        continue
454
455                    entry_fragment = fragment_name
456
457                fragment_names.append(fragment_name)
458
459            entry_config['fragments'] += fragment_names
460            entry_config['modules'] += release.get('modules', [])
461
462            for plugin_type, plugin_names in release.get('plugins', {}).items():
463                if plugin_type not in entry_config['plugins']:
464                    entry_config['plugins'][plugin_type] = []
465
466                entry_config['plugins'][plugin_type] += plugin_names
467
468        builder = RstBuilder()
469        builder.set_title('Ansible %s "%s" Release Notes' % (major_minor_version, codename))
470        builder.add_raw_rst('.. contents:: Topics\n\n')
471
472        for version, release in release_entries.items():
473            builder.add_section('v%s' % version)
474
475            combined_fragments = ChangelogFragment.combine([self.fragments[fragment] for fragment in release['fragments']])
476
477            for section_name in self.config.sections:
478                self._add_section(builder, combined_fragments, section_name)
479
480            self._add_plugins(builder, release['plugins'])
481            self._add_modules(builder, release['modules'])
482
483        return builder.generate()
484
485    def _add_section(self, builder, combined_fragments, section_name):
486        if section_name not in combined_fragments:
487            return
488
489        section_title = self.config.sections[section_name]
490
491        builder.add_section(section_title, 1)
492
493        content = combined_fragments[section_name]
494
495        if isinstance(content, list):
496            for rst in sorted(content):
497                builder.add_raw_rst('- %s' % rst)
498        else:
499            builder.add_raw_rst(content)
500
501        builder.add_raw_rst('')
502
503    def _add_plugins(self, builder, plugin_types_and_names):
504        if not plugin_types_and_names:
505            return
506
507        have_section = False
508
509        for plugin_type in sorted(self.plugins):
510            plugins = dict((plugin.name, plugin) for plugin in self.plugins[plugin_type] if plugin.name in plugin_types_and_names.get(plugin_type, []))
511
512            if not plugins:
513                continue
514
515            if not have_section:
516                have_section = True
517                builder.add_section('New Plugins', 1)
518
519            builder.add_section(plugin_type.title(), 2)
520
521            for plugin_name in sorted(plugins):
522                plugin = plugins[plugin_name]
523
524                builder.add_raw_rst('- %s - %s' % (plugin.name, plugin.description))
525
526            builder.add_raw_rst('')
527
528    def _add_modules(self, builder, module_names):
529        if not module_names:
530            return
531
532        modules = dict((module.name, module) for module in self.modules if module.name in module_names)
533        previous_section = None
534
535        modules_by_namespace = collections.defaultdict(list)
536
537        for module_name in sorted(modules):
538            module = modules[module_name]
539
540            modules_by_namespace[module.namespace].append(module.name)
541
542        for namespace in sorted(modules_by_namespace):
543            parts = namespace.split('.')
544
545            section = parts.pop(0).replace('_', ' ').title()
546
547            if not previous_section:
548                builder.add_section('New Modules', 1)
549
550            if section != previous_section:
551                builder.add_section(section, 2)
552
553            previous_section = section
554
555            subsection = '.'.join(parts)
556
557            if subsection:
558                builder.add_section(subsection, 3)
559
560            for module_name in modules_by_namespace[namespace]:
561                module = modules[module_name]
562
563                builder.add_raw_rst('- %s - %s' % (module.name, module.description))
564
565            builder.add_raw_rst('')
566
567
568class ChangelogFragment(object):
569    """Changelog fragment loader."""
570    def __init__(self, content, path):
571        """
572        :type content: dict[str, list[str]]
573        :type path: str
574        """
575        self.content = content
576        self.path = path
577        self.name = os.path.basename(path)
578
579    @staticmethod
580    def load(path):
581        """Load a ChangelogFragment from a file.
582        :type path: str
583        """
584        with open(path, 'r') as fragment_fd:
585            content = yaml.safe_load(fragment_fd)
586
587        return ChangelogFragment(content, path)
588
589    @staticmethod
590    def combine(fragments):
591        """Combine fragments into a new fragment.
592        :type fragments: list[ChangelogFragment]
593        :rtype: dict[str, list[str] | str]
594        """
595        result = {}
596
597        for fragment in fragments:
598            for section, content in fragment.content.items():
599                if isinstance(content, list):
600                    if section not in result:
601                        result[section] = []
602
603                    result[section] += content
604                else:
605                    result[section] = content
606
607        return result
608
609
610class ChangelogConfig(object):
611    """Configuration for changelogs."""
612    def __init__(self, path):
613        """
614        :type path: str
615        """
616        with open(path, 'r') as config_fd:
617            self.config = yaml.safe_load(config_fd)
618
619        self.notes_dir = self.config.get('notesdir', 'fragments')
620        self.prelude_name = self.config.get('prelude_section_name', 'release_summary')
621        self.prelude_title = self.config.get('prelude_section_title', 'Release Summary')
622        self.new_plugins_after_name = self.config.get('new_plugins_after_name', '')
623        self.release_tag_re = self.config.get('release_tag_re', r'((?:[\d.ab]|rc)+)')
624        self.pre_release_tag_re = self.config.get('pre_release_tag_re', r'(?P<pre_release>\.\d+(?:[ab]|rc)+\d*)$')
625
626        self.sections = collections.OrderedDict([(self.prelude_name, self.prelude_title)])
627
628        for section_name, section_title in self.config['sections']:
629            self.sections[section_name] = section_title
630
631
632class RstBuilder(object):
633    """Simple RST builder."""
634    def __init__(self):
635        self.lines = []
636        self.section_underlines = '''=-~^.*+:`'"_#'''
637
638    def set_title(self, title):
639        """Set the title.
640        :type title: str
641        """
642        self.lines.append(self.section_underlines[0] * len(title))
643        self.lines.append(title)
644        self.lines.append(self.section_underlines[0] * len(title))
645        self.lines.append('')
646
647    def add_section(self, name, depth=0):
648        """Add a section.
649        :type name: str
650        :type depth: int
651        """
652        self.lines.append(name)
653        self.lines.append(self.section_underlines[depth] * len(name))
654        self.lines.append('')
655
656    def add_raw_rst(self, content):
657        """Add a raw RST.
658        :type content: str
659        """
660        self.lines.append(content)
661
662    def generate(self):
663        """Generate RST content.
664        :rtype: str
665        """
666        return '\n'.join(self.lines)
667
668
669class ChangesMetadata(object):
670    """Read, write and manage change metadata."""
671    def __init__(self, path):
672        self.path = path
673        self.data = self.empty()
674        self.known_fragments = set()
675        self.known_plugins = set()
676        self.load()
677
678    @staticmethod
679    def empty():
680        """Empty change metadata."""
681        return dict(
682            releases=dict(
683            ),
684        )
685
686    @property
687    def latest_version(self):
688        """Latest version in the changes.
689        :rtype: str
690        """
691        return sorted(self.releases, reverse=True, key=packaging.version.Version)[0]
692
693    @property
694    def releases(self):
695        """Dictionary of releases.
696        :rtype: dict[str, dict[str, any]]
697        """
698        return self.data['releases']
699
700    def load(self):
701        """Load the change metadata from disk."""
702        if os.path.exists(self.path):
703            with open(self.path, 'r') as meta_fd:
704                self.data = yaml.safe_load(meta_fd)
705        else:
706            self.data = self.empty()
707
708        for version, config in self.releases.items():
709            self.known_fragments |= set(config.get('fragments', []))
710
711            for plugin_type, plugin_names in config.get('plugins', {}).items():
712                self.known_plugins |= set('%s/%s' % (plugin_type, plugin_name) for plugin_name in plugin_names)
713
714            module_names = config.get('modules', [])
715
716            self.known_plugins |= set('module/%s' % module_name for module_name in module_names)
717
718    def prune_plugins(self, plugins):
719        """Remove plugins which are not in the provided list of plugins.
720        :type plugins: list[PluginDescription]
721        """
722        valid_plugins = collections.defaultdict(set)
723
724        for plugin in plugins:
725            valid_plugins[plugin.type].add(plugin.name)
726
727        for version, config in self.releases.items():
728            if 'modules' in config:
729                invalid_modules = set(module for module in config['modules'] if module not in valid_plugins['module'])
730                config['modules'] = [module for module in config['modules'] if module not in invalid_modules]
731                self.known_plugins -= set('module/%s' % module for module in invalid_modules)
732
733            if 'plugins' in config:
734                for plugin_type in config['plugins']:
735                    invalid_plugins = set(plugin for plugin in config['plugins'][plugin_type] if plugin not in valid_plugins[plugin_type])
736                    config['plugins'][plugin_type] = [plugin for plugin in config['plugins'][plugin_type] if plugin not in invalid_plugins]
737                    self.known_plugins -= set('%s/%s' % (plugin_type, plugin) for plugin in invalid_plugins)
738
739    def prune_fragments(self, fragments):
740        """Remove fragments which are not in the provided list of fragments.
741        :type fragments: list[ChangelogFragment]
742        """
743        valid_fragments = set(fragment.name for fragment in fragments)
744
745        for version, config in self.releases.items():
746            if 'fragments' not in config:
747                continue
748
749            invalid_fragments = set(fragment for fragment in config['fragments'] if fragment not in valid_fragments)
750            config['fragments'] = [fragment for fragment in config['fragments'] if fragment not in invalid_fragments]
751            self.known_fragments -= set(config['fragments'])
752
753    def sort(self):
754        """Sort change metadata in place."""
755        for release, config in self.data['releases'].items():
756            if 'fragments' in config:
757                config['fragments'] = sorted(config['fragments'])
758
759            if 'modules' in config:
760                config['modules'] = sorted(config['modules'])
761
762            if 'plugins' in config:
763                for plugin_type in config['plugins']:
764                    config['plugins'][plugin_type] = sorted(config['plugins'][plugin_type])
765
766    def save(self):
767        """Save the change metadata to disk."""
768        self.sort()
769
770        with open(self.path, 'w') as config_fd:
771            yaml.safe_dump(self.data, config_fd, default_flow_style=False)
772
773    def add_release(self, version, codename, release_date):
774        """Add a new releases to the changes metadata.
775        :type version: str
776        :type codename: str
777        :type release_date: datetime.date
778        """
779        if version not in self.releases:
780            self.releases[version] = dict(
781                codename=codename,
782                release_date=str(release_date),
783            )
784        else:
785            LOGGER.warning('release %s already exists', version)
786
787    def add_fragment(self, fragment_name, version):
788        """Add a changelog fragment to the change metadata.
789        :type fragment_name: str
790        :type version: str
791        """
792        if fragment_name in self.known_fragments:
793            return False
794
795        self.known_fragments.add(fragment_name)
796
797        if 'fragments' not in self.releases[version]:
798            self.releases[version]['fragments'] = []
799
800        fragments = self.releases[version]['fragments']
801        fragments.append(fragment_name)
802        return True
803
804    def add_plugin(self, plugin_type, plugin_name, version):
805        """Add a plugin to the change metadata.
806        :type plugin_type: str
807        :type plugin_name: str
808        :type version: str
809        """
810        composite_name = '%s/%s' % (plugin_type, plugin_name)
811
812        if composite_name in self.known_plugins:
813            return False
814
815        self.known_plugins.add(composite_name)
816
817        if plugin_type == 'module':
818            if 'modules' not in self.releases[version]:
819                self.releases[version]['modules'] = []
820
821            modules = self.releases[version]['modules']
822            modules.append(plugin_name)
823        else:
824            if 'plugins' not in self.releases[version]:
825                self.releases[version]['plugins'] = {}
826
827            plugins = self.releases[version]['plugins']
828
829            if plugin_type not in plugins:
830                plugins[plugin_type] = []
831
832            plugins[plugin_type].append(plugin_name)
833
834        return True
835
836
837if __name__ == '__main__':
838    main()
839