def load_config(distribution): """Utility method to parse distutils/setuptools configuration. This is for use by other libraries to extract the command configuration. :param distribution: A :class:`distutils.dist.Distribution` object :returns: A tuple of a :class:`reno.config.Config` object, the output path of the human-readable release notes file, and the output file of the reno cache file """ option_dict = distribution.get_option_dict(COMMAND_NAME) if option_dict.get('repo_root') is not None: repo_root = option_dict.get('repo_root')[1] else: repo_root = defaults.REPO_ROOT if option_dict.get('rel_notes_dir') is not None: rel_notes_dir = option_dict.get('rel_notes_dir')[1] else: rel_notes_dir = defaults.RELEASE_NOTES_SUBDIR if option_dict.get('output_file') is not None: output_file = option_dict.get('output_file')[1] else: output_file = defaults.RELEASE_NOTES_FILENAME conf = config.Config(repo_root, rel_notes_dir) cache_file = loader.get_cache_filename(conf) return (conf, output_file, cache_file)
def test_override(self): c = config.Config(self.tempdir.path) c.override(collapse_pre_releases=False, ) actual = c.options expected = {o.name: o.default for o in config._OPTIONS} expected['collapse_pre_releases'] = False self.assertEqual(expected, actual)
def test_load_file_empty(self): config_path = self.tempdir.join('reno.yaml') with open(config_path, 'w') as fd: fd.write('# Add reno config here') self.addCleanup(os.unlink, config_path) c = config.Config(self.tempdir.path) self.assertEqual(True, c.collapse_pre_releases)
def _run_override_from_parsed_args(self, argv): parser = argparse.ArgumentParser() main._build_query_arg_group(parser) args = parser.parse_args(argv) c = config.Config(self.tempdir.path) c.override_from_parsed_args(args) return c
def test_override_from_parsed_args_ignore_non_options(self): parser = argparse.ArgumentParser() main._build_query_arg_group(parser) parser.add_argument('not_a_config_option') args = parser.parse_args(['value']) c = config.Config(self.tempdir.path) c.override_from_parsed_args(args) self.assertFalse(hasattr(c, 'not_a_config_option'))
def setUp(self): super(TestValidate, self).setUp() self.logger = self.useFixture( fixtures.FakeLogger( format='%(message)s', level=logging.WARNING, )) self.c = config.Config('reporoot')
def test_override_multiple(self): c = config.Config(self.tempdir.path) c.override(notesdir='value1', ) c.override(notesdir='value2', ) actual = c.options expected = {o.name: o.default for o in config._OPTIONS} expected['notesdir'] = 'value2' self.assertEqual(expected, actual)
def test_defaults(self): c = config.Config(self.tempdir.path) actual = c.options expected = { o.name: o.default for o in config._OPTIONS } self.assertEqual(expected, actual)
def setUp(self): super(TestCache, self).setUp() self.useFixture( fixtures.MockPatch('reno.scanner.Scanner.get_file_at_commit', new=self._get_note_body)) self.useFixture( fixtures.MockPatch('reno.scanner.Scanner.get_version_dates', new=self._get_dates)) self.c = config.Config('.')
def setUp(self): super(TestFormatterBase, self).setUp() def _load(ldr): ldr._scanner_output = self.scanner_output ldr._cache = {'file-contents': self.note_bodies} self.c = config.Config('reporoot') with mock.patch('reno.loader.Loader._load_data', _load): self.ldr = loader.Loader( self.c, ignore_cache=False, )
def run(self): conf = config.Config(self.repo_root, self.rel_notes_dir) # Generate the cache using the configuration options found # in the release notes directory and the default output # filename. cache_filename = cache.write_cache_db( conf=conf, versions_to_include=[], # include all versions outfilename=None, # generate the default name ) log.info('wrote cache file to %s', cache_filename) ldr = loader.Loader(conf) text = formatter.format_report( ldr, conf, ldr.versions, title=self.distribution.metadata.name, ) with open(self.output_file, 'w') as f: f.write(text) log.info('wrote release notes to %s', self.output_file)
def setUp(self): super(TestConfigProperties, self).setUp() # Temporary directory to store our config self.tempdir = self.useFixture(fixtures.TempDir()) self.c = config.Config('releasenotes')
def run(self): env = self.state.document.settings.env app = env.app def info(msg): app.info('[reno] %s' % (msg, )) title = ' '.join(self.content) branch = self.options.get('branch') reporoot_opt = self.options.get('reporoot', '.') reporoot = os.path.abspath(reporoot_opt) # When building on RTD.org the root directory may not be # the current directory, so look for it. reporoot = repo.Repo.discover(reporoot).path relnotessubdir = self.options.get('relnotessubdir', defaults.RELEASE_NOTES_SUBDIR) ignore_notes = [ name.strip() for name in self.options.get('ignore-notes', '').split(',') ] conf = config.Config(reporoot, relnotessubdir) opt_overrides = {} if 'notesdir' in self.options: opt_overrides['notesdir'] = self.options.get('notesdir') version_opt = self.options.get('version') # FIXME(dhellmann): Force these flags True for now and figure # out how Sphinx passes a "false" flag later. # 'collapse-pre-releases' in self.options opt_overrides['collapse_pre_releases'] = True # Only stop at the branch base if we have not been told # explicitly which versions to include. opt_overrides['stop_at_branch_base'] = (version_opt is None) if 'earliest-version' in self.options: opt_overrides['earliest_version'] = self.options.get( 'earliest-version') if branch: opt_overrides['branch'] = branch if ignore_notes: opt_overrides['ignore_notes'] = ignore_notes conf.override(**opt_overrides) notesdir = os.path.join(relnotessubdir, conf.notesdir) info('scanning %s for %s release notes' % (os.path.join( conf.reporoot, notesdir), branch or 'current branch')) ldr = loader.Loader(conf) if version_opt is not None: versions = [v.strip() for v in version_opt.split(',')] else: versions = ldr.versions info('got versions %s' % (versions, )) text = formatter.format_report( ldr, conf, versions, title=title, ) source_name = '<%s %s>' % (__name__, branch or 'current branch') result = statemachine.ViewList() for line in text.splitlines(): result.append(line, source_name) node = nodes.section() node.document = self.state.document nested_parse_with_titles(self.state, result, node) return node.children
def main(argv=sys.argv[1:]): parser = argparse.ArgumentParser() parser.add_argument( '-v', '--verbose', dest='verbosity', default=logging.INFO, help='produce more output', action='store_const', const=logging.DEBUG, ) parser.add_argument( '-q', '--quiet', dest='verbosity', action='store_const', const=logging.WARN, help='produce less output', ) parser.add_argument( '--rel-notes-dir', '-d', dest='relnotesdir', default=defaults.RELEASE_NOTES_SUBDIR, help='location of release notes YAML files', ) subparsers = parser.add_subparsers(title='commands', ) do_new = subparsers.add_parser( 'new', help='create a new note', ) do_new.add_argument( '--edit', action='store_true', help='Edit note after its creation (require EDITOR env variable)', ) do_new.add_argument( '--from-template', help='Template to get the release note from.', ) do_new.add_argument( 'slug', help='descriptive title of note (keep it short)', ) do_new.add_argument( 'reporoot', default='.', nargs='?', help='root of the git repository', ) do_new.set_defaults(func=create.create_cmd) do_list = subparsers.add_parser( 'list', help='list notes files based on query arguments', ) _build_query_arg_group(do_list) do_list.add_argument( 'reporoot', default='.', nargs='?', help='root of the git repository', ) do_list.set_defaults(func=lister.list_cmd) do_report = subparsers.add_parser( 'report', help='generate release notes report', ) do_report.add_argument( 'reporoot', default='.', nargs='?', help='root of the git repository', ) do_report.add_argument( '--output', '-o', default=None, help='output filename, defaults to stdout', ) do_report.add_argument( '--no-show-source', dest='show_source', default=True, action='store_false', help='do not show the source for notes', ) do_report.add_argument( '--title', default='Release Notes', help='set the main title of the generated report', ) _build_query_arg_group(do_report) do_report.set_defaults(func=report.report_cmd) do_cache = subparsers.add_parser( 'cache', help='generate release notes cache', ) do_cache.add_argument( 'reporoot', default='.', nargs='?', help='root of the git repository', ) do_cache.add_argument( '--output', '-o', default=None, help=('output filename, ' 'defaults to the cache file within the notesdir, ' 'use "-" for stdout'), ) _build_query_arg_group(do_cache) do_cache.set_defaults(func=cache.cache_cmd) do_linter = subparsers.add_parser( 'lint', help='check some common mistakes', ) do_linter.add_argument( 'reporoot', default='.', nargs='?', help='root of the git repository', ) do_linter.set_defaults(func=linter.lint_cmd) args = parser.parse_args(argv) conf = config.Config(args.reporoot, args.relnotesdir) conf.override_from_parsed_args(args) logging.basicConfig( level=args.verbosity, format='%(message)s', ) return args.func(args, conf)
def run(self): # type: () -> nodes.Node """ Run to generate the output from .. ddtrace-release-notes:: directive 1. Determine the max version cutoff we need to report for We determine this by traversing the git log until we find the first dev or release branch ref. If we are generating for 1.x branch we will use 2.0 as the cutoff. If we are generating for 0.60 branch we will use 0.61 as the cutoff. We do this to ensure if we are generating notes for older versions we do no include all up to date release notes. Think releasing 0.57.2 when there is 0.58.0, 0.59.0, 1.0.0, etc we only want notes for < 0.58. 2. Iterate through all release branches A release branch is one that matches the ``^[0-9]+.[0-9]+``` pattern Skip any that do not meet the max version cutoff. 3. Determine the earliest version to report for each release branch If the release has only RC releases then use ``.0rc1`` as the earliest version. If there are non-RC releases then use ``.0`` version as the earliest. We do this because we want reno to only report notes that are for that given release branch but it will collapse RC releases if there is a non-RC tag on that branch. So there isn't a consistent "earliest version" we can use for in-progress/dev branches as well as released branches. 4. Generate a reno config for reporting and generate the notes for each branch """ # This is where we will aggregate the generated notes title = " ".join(self.content) result = statemachine.ViewList() # Determine the max version we want to report for max_version = self._get_report_max_version() LOG.info("capping max report version to %r", max_version) # For each release branch, starting with the newest for version, ref in self._release_branches: # If this version is equal to or greater than the max version we want to report for if max_version is not None and version >= max_version: LOG.info("skipping %s >= %s", version, max_version) continue # Older versions did not have reno release notes # DEV: Reno will fail if we try to run on a branch with no notes if (version.major, version.minor) < (0, 44): LOG.info("skipping older version %s", version) continue # Parse the branch name from the ref, we want origin/{major}.{minor}[-dev] _, _, branch = ref.partition("refs/remotes/") # Determine the earliest release tag for this version earliest_version = self._get_earliest_version(version) if not earliest_version: LOG.info("no release tags found for %s", version) continue # Setup reno config conf = config.Config(self._repo.path, "releasenotes") conf.override( branch=branch, collapse_pre_releases=True, stop_at_branch_base=True, earliest_version=earliest_version, ) LOG.info( "scanning %s for %s release notes, stopping at %s", os.path.join(self._repo.path, "releasenotes/notes"), branch, earliest_version, ) # Generate the formatted RST with loader.Loader(conf) as ldr: versions = ldr.versions LOG.info("got versions %s", versions) text = formatter.format_report( ldr, conf, versions, title=title, branch=branch, ) source_name = "<%s %s>" % (__name__, branch or "current branch") for line_num, line in enumerate(text.splitlines(), 1): LOG.debug("%4d: %s", line_num, line) result.append(line, source_name, line_num) # Generate the RST nodes to return for rendering node = nodes.section() node.document = self.state.document nested_parse_with_titles(self.state, result, node) return node.children
def _test_load_file(self, config_path): with open(config_path, 'w') as fd: fd.write(self.EXAMPLE_CONFIG) self.addCleanup(os.unlink, config_path) c = config.Config(self.tempdir.path) self.assertEqual(False, c.collapse_pre_releases)
def test_load_file_not_present(self): missing = 'reno.config.Config._report_missing_config_files' with mock.patch(missing) as error_handler: config.Config(self.tempdir.path) self.assertEqual(1, error_handler.call_count)
def run(self): title = ' '.join(self.content) branch = self.options.get('branch') relnotessubdir = self.options.get( 'relnotessubdir', defaults.RELEASE_NOTES_SUBDIR, ) reporoot = self._find_reporoot( self.options.get('reporoot', '.'), relnotessubdir, ) ignore_notes = [ name.strip() for name in self.options.get('ignore-notes', '').split(',') ] conf = config.Config(reporoot, relnotessubdir) opt_overrides = {} if 'notesdir' in self.options: opt_overrides['notesdir'] = self.options.get('notesdir') version_opt = self.options.get('version') # FIXME(dhellmann): Force these flags True for now and figure # out how Sphinx passes a "false" flag later. # 'collapse-pre-releases' in self.options opt_overrides['collapse_pre_releases'] = True # Only stop at the branch base if we have not been told # explicitly which versions to include. opt_overrides['stop_at_branch_base'] = (version_opt is None) if 'earliest-version' in self.options: opt_overrides['earliest_version'] = self.options.get( 'earliest-version') if 'unreleased-version-title' in self.options: opt_overrides['unreleased_version_title'] = self.options.get( 'unreleased-version-title') if branch: opt_overrides['branch'] = branch if ignore_notes: opt_overrides['ignore_notes'] = ignore_notes conf.override(**opt_overrides) notesdir = os.path.join(relnotessubdir, conf.notesdir) LOG.info('scanning %s for %s release notes' % (os.path.join( conf.reporoot, notesdir), branch or 'current branch')) ldr = loader.Loader(conf) if version_opt is not None: versions = [v.strip() for v in version_opt.split(',')] else: versions = ldr.versions LOG.info('got versions %s' % (versions, )) text = formatter.format_report( ldr, conf, versions, title=title, branch=branch, ) source_name = '<%s %s>' % (__name__, branch or 'current branch') result = statemachine.ViewList() for line_num, line in enumerate(text.splitlines(), 1): LOG.debug('%4d: %s', line_num, line) result.append(line, source_name, line_num) node = nodes.section() node.document = self.state.document nested_parse_with_titles(self.state, result, node) return node.children
def test_load_file_not_present(self): with mock.patch.object(config.LOG, 'info') as logger: config.Config(self.tempdir.path) self.assertEqual(1, logger.call_count)
def generate_release_notes( repo, repo_path, start_revision, end_revision, show_dates, skip_requirement_merges, is_stable, series, email, email_from, email_reply_to, email_tags, include_pypi_link, changes_only, first_release, deliverable_file, description, publishing_dir_name, ): """Return the text of the release notes. :param repo: The name of the repo. :param repo_path: Path to the repo repository on disk. :param start_revision: First reference for finding change log. :param end_revision: Final reference for finding change log. :param show_dates: Boolean indicating whether or not to show dates in the output. :param skip_requirement_merges: Boolean indicating whether to skip merge commits for requirements changes. :param is_stable: Boolean indicating whether this is a stable series or not. :param series: String holding the name of the series. :param email: Boolean indicating whether the output format should be an email message. :param email_from: String containing the sender email address. :param email_reply_to: String containing the email reply-to address. :param email_tags: String containing the email header topic tags to add. :param include_pypi_link: Boolean indicating whether or not to include an automatically generated link to the PyPI package page. :param changes_only: Boolean indicating whether to limit output to the list of changes, without any extra data. :param first_release: Boolean indicating whether this is the first release of the project :param deliverable_file: The deliverable file path from the repo root. :param description: Description of the repo :param publishing_dir_name: The directory on publishings.openstack.org containing the package. """ repo_name = repo.split('/')[-1] # Determine if this is a release candidate or not. is_release_candidate = 'rc' in end_revision # Do not mention the series in independent model since there is none if series == 'independent': series = '' if not email_from: raise RuntimeError('No email-from specified') # Get the commits that are in the desired range... git_range = "%s..%s" % (start_revision, end_revision) if show_dates: format = "--format=%h %ci %s" else: format = "--oneline" cmd = ["git", "log", "--no-color", format, "--no-merges", git_range] stdout = run_cmd(cmd, cwd=repo_path) changes = [] for commit_line in stdout.splitlines(): commit_line = commit_line.strip() if not commit_line or is_skippable_commit(skip_requirement_merges, commit_line): continue else: changes.append(commit_line) # Filter out any requirement file changes... requirement_changes = [] requirement_files = list( glob.glob(os.path.join(repo_path, '*requirements*.txt'))) if requirement_files: cmd = ['git', 'diff', '-U0', '--no-color', git_range] cmd.extend(requirement_files) stdout = run_cmd(cmd, cwd=repo_path) requirement_changes = [ line.strip() for line in stdout.splitlines() if line.strip() ] # Get statistics about the range given... cmd = ['git', 'diff', '--stat', '--no-color', git_range] stdout = run_cmd(cmd, cwd=repo_path) diff_stats = [] for line in stdout.splitlines(): line = line.strip() if not line or line.find("tests") != -1 or line.startswith("doc"): continue diff_stats.append(line) # Extract + valdiate needed sections... sections = parse_deliverable(series, repo_name, deliverable_file=deliverable_file) change_header = ["Changes in %s %s" % (repo, git_range)] change_header.append("-" * len(change_header[0])) # Look for reno notes for this version. if not changes_only: logging.getLogger('reno').setLevel(logging.WARNING) cfg = reno_config.Config(reporoot=repo_path, ) branch = None if is_stable and series: branch = 'origin/stable/%s' % series cfg.override(branch=branch) ldr = loader.Loader(conf=cfg, ignore_cache=True) if end_revision in ldr.versions: rst_notes = formatter.format_report( loader=ldr, config=cfg, versions_to_include=[end_revision], ) reno_notes = rst2txt.convert(rst_notes).decode('utf-8') else: LOG.warning( ('Did not find revision %r in list of versions ' 'with release notes %r, skipping reno'), end_revision, ldr.versions, ) reno_notes = '' else: reno_notes = '' # The recipient for announcements should always be the # [email protected] ML (except for # release-test) email_to = '*****@*****.**' if repo_name == 'openstack-release-test': email_to = '*****@*****.**' params = dict(sections) params.update({ 'project': repo, 'description': description, 'end_rev': end_revision, 'range': git_range, 'lib': repo_path, 'skip_requirement_merges': skip_requirement_merges, 'changes': changes, 'requirement_changes': requirement_changes, 'diff_stats': diff_stats, 'change_header': "\n".join(change_header), 'emotion': random.choice(EMOTIONS), 'stable_series': is_stable, 'series': series, 'email': email, 'email_from': email_from, 'email_to': email_to, 'email_reply_to': email_reply_to, 'email_tags': email_tags, 'reno_notes': reno_notes, 'first_release': first_release, 'publishing_dir_name': publishing_dir_name, }) if include_pypi_link: params['pypi_url'] = PYPI_URL_TPL % repo_name else: params['pypi_url'] = None response = [] if changes_only: response.append(expand_template(CHANGES_ONLY_TPL, params)) else: if email: email_header = expand_template(EMAIL_HEADER_TPL.strip(), params) response.append(email_header.lstrip()) if is_release_candidate: response.append(expand_template(RELEASE_CANDIDATE_TPL, params)) else: header = expand_template(HEADER_RELEASE_TPL.strip(), params) response.append(parawrap.fill(header)) response.append(expand_template(CHANGE_RELEASE_TPL, params)) return '\n'.join(response)