Add YAML configuration parsing

While discussing I7539fdeada14a73ae4e18a125bb0e3947f08e8d1 Doug and
Harry realized that it would be much better if some of these values
could be specified in a config file, whether INI or YAML.

This adds a simple function to allow options to be specified in the
config file named "config.yml" in the --rel-notes-dir.

Change-Id: Ie25e1eb3da66cc627d93af585b0893469d6a7b2e
This commit is contained in:
Ian Cordasco 2016-07-11 16:33:06 -05:00 committed by Doug Hellmann
parent 3b9cf44feb
commit 750bdc021b
8 changed files with 234 additions and 5 deletions

View File

@ -147,3 +147,28 @@ repeated).
Notes are output in the order they are found by ``git log`` looking
over the history of the branch. This is deterministic, but not
necessarily predictable or mutable.
Configuring Reno
================
Reno looks for an optional ``config.yml`` file in your release notes
directory. This file may contain optional flags that you might use with a
command. If the values do not apply to the command, they are ignored in the
configuration file. For example, a couple reno commands allow you to specify
- ``--branch``
- ``--earliest-version``
- ``--collapse-pre-releases``/``--no-collapse-pre-releases``
- ``--ignore-cache``
So you might write a config file (if you use these often) like:
.. code-block:: yaml
---
branch: master
earliest_version: 12.0.0
collapse_pre_releases: false
These will be parsed first and then the CLI options will be applied after
the config files.

View File

@ -0,0 +1,10 @@
---
prelude: >
Reno now supports having a configuration file!
features:
- |
Reno now supports having a ``config.yaml`` file in your release notes
directory. It will search for file in the directory specified by
``--rel-notes-dir`` and parse it. It will apply whatever options are
valid for that particular command. If an option is not relevant to a
particular sub-command, it will not attempt to apply them.

73
reno/config.py Normal file
View File

@ -0,0 +1,73 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
import os.path
import yaml
from reno import defaults
LOG = logging.getLogger(__name__)
def get_config_path(relnotesdir):
"""Generate the path to the config file.
:param str relnotesdir:
The directory containing release notes.
:returns:
The path to the config file in the release notes directory.
:rtype:
str
"""
return os.path.join(relnotesdir, defaults.RELEASE_NOTES_CONFIG_FILENAME)
def read_config(config_file):
"""Read and parse the config file.
:param str config_file:
The path to the config file to parse.
:returns:
The YAML parsed into a dictionary, otherwise, or an empty dictionary
if the path does not exist.
:rtype:
dict
"""
if not os.path.exists(config_file):
return {}
with open(config_file, 'r') as fd:
return yaml.safe_load(fd)
def parse_config_into(parsed_arguments):
"""Parse the user config onto the namespace arguments.
:param parsed_arguments:
The result of calling :meth:`argparse.ArgumentParser.parse_args`.
:type parsed_arguments:
argparse.Namespace
"""
config_path = get_config_path(parsed_arguments.relnotesdir)
config_values = read_config(config_path)
for key in config_values.keys():
try:
getattr(parsed_arguments, key)
except AttributeError:
LOG.info('Option "%s" does not apply to this particular command.'
'. Ignoring...', key)
continue
setattr(parsed_arguments, key, config_values[key])
parsed_arguments._config = config_values

View File

@ -12,3 +12,4 @@
RELEASE_NOTES_SUBDIR = 'releasenotes'
NOTES_SUBDIR = 'notes'
RELEASE_NOTES_CONFIG_FILENAME = 'config.yml'

View File

@ -32,6 +32,7 @@ def list_cmd(args):
branch=args.branch,
collapse_pre_releases=collapse,
earliest_version=args.earliest_version,
config=args._config
)
if args.version:
versions = args.version

View File

@ -16,6 +16,7 @@ import os.path
import six
import yaml
from reno import config
from reno import scanner
LOG = logging.getLogger(__name__)
@ -31,7 +32,8 @@ class Loader(object):
def __init__(self, reporoot, notesdir, branch=None,
collapse_pre_releases=True,
earliest_version=None,
ignore_cache=False):
ignore_cache=False,
parsedconfig=None):
"""Initialize a Loader.
The versions are presented in reverse chronological order.
@ -52,13 +54,22 @@ class Loader(object):
:type earliest_version: str
:param ignore_cache: Do not load a cache file if it is present.
:type ignore_cache: bool
:param parsedconfig: Parsed configuration from file
:type parsedconfig: dict
"""
self._reporoot = reporoot
self._notesdir = notesdir
self._branch = branch
self._collapse_pre_releases = collapse_pre_releases
self._earliest_version = earliest_version
self._config = parsedconfig
if parsedconfig is None:
# NOTE(sigmavirus24): This should only happen when it is being
# used by the reStructuredText directive in our sphinx extension.
notesconfig = config.get_config_path(notesdir)
self._config = config.read_config(notesconfig)
self._collapse_pre_releases = self._value_from_config(
'collapse_pre_releases', collapse_pre_releases, default=True)
self._earliest_version = self._value_from_config('earliest_version',
earliest_version)
self._ignore_cache = ignore_cache
self._cache = None
@ -67,6 +78,13 @@ class Loader(object):
self._load_data()
def _value_from_config(self, name, value, default=None):
# NOTE(sigmavirus24): If it's the default value for the parameter
# definition then we might want to look at the config.
if value is default:
value = self._config.get(name, default)
return value
def _load_data(self):
cache_file_exists = os.path.exists(self._cache_filename)

View File

@ -15,6 +15,7 @@ import logging
import sys
from reno import cache
from reno import config
from reno import create
from reno import defaults
from reno import lister
@ -134,7 +135,13 @@ def main(argv=sys.argv[1:]):
_build_query_arg_group(do_cache)
do_cache.set_defaults(func=cache.cache_cmd)
args = parser.parse_args()
original_args = parser.parse_args(argv)
config.parse_config_into(original_args)
# NOTE(sigmavirus24): We parse twice to avoid having to guess if a parsed
# option is the default value or not. This allows us to apply the config
# to the proper command and then make sure that the command-line values
# take precedence
args = parser.parse_args(argv, original_args)
logging.basicConfig(
level=args.verbosity,

94
reno/tests/test_config.py Normal file
View File

@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import argparse
import os
import fixtures
from reno import config
from reno import defaults
from reno.tests import base
class TestConfig(base.TestCase):
EXAMPLE_CONFIG = """
branch: master
collapse_pre_releases: false
earliest_version: true
"""
def setUp(self):
super(TestConfig, self).setUp()
# Temporary directory to store our config
self.tempdir = self.useFixture(fixtures.TempDir())
# Argument parser and parsed arguments for our config function
parser = argparse.ArgumentParser()
parser.add_argument('--branch')
parser.add_argument('--collapse-pre-releases')
parser.add_argument('--earliest-version')
self.args = parser.parse_args([])
self.args.relnotesdir = self.tempdir.path
config_path = self.tempdir.join(defaults.RELEASE_NOTES_CONFIG_FILENAME)
with open(config_path, 'w') as fd:
fd.write(self.EXAMPLE_CONFIG)
self.addCleanup(os.unlink, config_path)
self.config_path = config_path
def test_applies_relevant_config_values(self):
"""Verify that our config function overrides default values."""
config.parse_config_into(self.args)
del self.args._config
expected_value = {
'relnotesdir': self.tempdir.path,
'branch': 'master',
'collapse_pre_releases': False,
'earliest_version': True,
}
self.assertDictEqual(expected_value, vars(self.args))
def test_does_not_add_extra_options(self):
"""Show that earliest_version is not set when missing."""
del self.args.earliest_version
self.assertEqual(0, getattr(self.args, 'earliest_version', 0))
config.parse_config_into(self.args)
del self.args._config
expected_value = {
'relnotesdir': self.tempdir.path,
'branch': 'master',
'collapse_pre_releases': False,
}
self.assertDictEqual(expected_value, vars(self.args))
def test_get_congfig_path(self):
"""Show that we generate the path appropriately."""
self.assertEqual('releasenotes/config.yml',
config.get_config_path('releasenotes'))
def test_read_config_shortcircuits(self):
"""Verify we don't try to open a non-existent file."""
self.assertDictEqual({},
config.read_config('fake/path/to/config.yml'))
def test_read_config(self):
"""Verify we read and parse the config file specified if it exists."""
self.assertDictEqual({'branch': 'master',
'collapse_pre_releases': False,
'earliest_version': True},
config.read_config(self.config_path))