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