Skip to content

Commit

Permalink
Make Cardinal configurable only via file (closes #146)
Browse files Browse the repository at this point in the history
  • Loading branch information
johnmaguire committed Feb 22, 2020
1 parent 9c9a38c commit c08dabe
Show file tree
Hide file tree
Showing 6 changed files with 75 additions and 215 deletions.
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Plus, Cardinal is still in active development! Features are being added as quick

### Configuration

Copy the `config/config.example.json` (virtualenv) or `config/config.docker.json` (Docker) file to `config.json` (or, if you are using Cardinal on multiple networks, something like `config.freenode.json` -- you will need to pass the `--config` option in this case) and modify it to suit your needs, or view Cardinal's command line options with `./cardinal -h`. Not all options may be configured from the command line.
Copy the `config/config.example.json` (virtualenv) or `config/config.docker.json` (Docker) file to `config/config.json` (or, if you are using Cardinal on multiple networks, something like `config.freenode.json`) and modify it to suit your needs.

You should also add your nick and vhost to the `plugins/admin/config.json` file in the format `nick@vhost` in order to take advantage of admin-only commands.

Expand All @@ -43,17 +43,15 @@ You can run Cardinal as a Docker container, or install Cardinal inside of a [Pyt

First, install [Docker](https://docs.docker.com/install/) and [docker-compose](https://docs.docker.com/compose/install/).

After configuring Cardinal (see above), simply run `docker-compose up -d` if you are storing your config as `config.json`. Otherwise, you will need to create a `docker-compose.override.yml` file like so:
After configuring Cardinal (see above), simply run `docker-compose up -d` if you are storing your config as `config/config.json`. Otherwise, you will need to create a `docker-compose.override.yml` file like so:

```yaml
version: "2.1"
services:
cardinal:
command: --config /config/config.darkscience.json
command: config/config.darkscience.json
```

The `config/` directory is mounted at `/config/`, so simply set the filename appropriately, then run `docker-compose up -d`.

#### virtualenv

`virtualenv -p /usr/bin/python2.7 . && source bin/activate`
Expand All @@ -62,7 +60,7 @@ Make sure you have Python 2.7 installed, and run `pip install -r requirements.tx

**Note:** Make sure you have `libssl-dev` and `libffi-dev` installed on Debian (or the equivelant package for your distro) or installation of some dependencies may not work correctly.

After installation, simply type `./cardinal.py`.
After installation, simply type `./cardinal.py config/config.json` (change `config/config.json` to your config location).

## Writing Plugins

Expand Down
123 changes: 38 additions & 85 deletions cardinal.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,61 +5,42 @@
import argparse
import logging
import logging.config
from getpass import getpass

from twisted.internet import reactor

from cardinal.config import ConfigParser, ConfigSpec
from cardinal.bot import CardinalBotFactory

if __name__ == "__main__":

def setup_logging(config=None):
if config is None:
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
else:
logging.config.dictConfig(config)

return logging.getLogger(__name__)


if __name__ == "__main__":
# Create a new instance of ArgumentParser with a description about Cardinal
arg_parser = argparse.ArgumentParser(description="""
Cardinal IRC bot
A Python/Twisted-powered modular IRC bot. Aimed to be simple to use, simple
to develop. For information on developing plugins, visit the project page
below.
A Twisted IRC bot designed to be simple to use and and easy to extend.
https://github.com/JohnMaguire/Cardinal
""", formatter_class=argparse.RawDescriptionHelpFormatter)

# Add all the possible arguments
arg_parser.add_argument('-n', '--nickname', metavar='nickname',
help='nickname to connect as')

arg_parser.add_argument('--password', action='store_true',
help='set this flag to get a password prompt for '
'identifying')

arg_parser.add_argument('-u', '--username', metavar='username',
help='username (ident) of the bot')

arg_parser.add_argument('-r', '--realname', metavar='realname',
help='Real name of the bot')

arg_parser.add_argument('-i', '--network', metavar='network',
help='network to connect to')

arg_parser.add_argument('-o', '--port', type=int, metavar='port',
help='network port to connect to')

arg_parser.add_argument('-P', '--spassword', metavar='server_password',
help='password to connect to the network with')

arg_parser.add_argument('-s', '--ssl', action='store_true',
help='you must set this flag for SSL connections')

arg_parser.add_argument('-c', '--channels', nargs='*', metavar='channel',
help='list of channels to connect to on startup')

arg_parser.add_argument('-p', '--plugins', nargs='*', metavar='plugin',
help='list of plugins to load on startup')

arg_parser.add_argument('--config', metavar='config',
arg_parser.add_argument('config', metavar='config',
help='custom config location')

# Parse command-line arguments
args = arg_parser.parse_args()
config_file = args.config

# Define the config spec and create a parser for our internal config
spec = ConfigSpec()
spec.add_option('nickname', basestring, 'Cardinal')
Expand Down Expand Up @@ -93,60 +74,32 @@

parser = ConfigParser(spec)

# Parse command-line arguments
args = arg_parser.parse_args()

# Attempt to load config.json for config options
config_file = args.config
if config_file is None:
config_file = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
'config.json'
)

# Load config file
parser.load_config(config_file)
try:
config = parser.load_config(config_file)
except Exception:
# Need to setup a logger early
logger = setup_logging()
logger.exception("Unable to load config: {}".format(config_file))
os.exit(1)

# If SSL is set to false, set it to None (small hack - action 'store_true'
# in arg_parse defaults to False. False instead of None will overwrite our
# config settings.)
if not args.ssl:
args.ssl = None
# Config loaded, setup the logger
logger = setup_logging(config['logging'])

# If the password flag was set, let the user safely type in their password
if args.password:
args.password = getpass('NickServ password: ')
else:
args.password = None

# Merge the args into the config object
config = parser.merge_argparse_args_into_config(args)

# If user defined logging config, use it, otherwise use default
if config['logging'] is not None:
logging.config.dictConfig(config['logging'])
else:
# Set default log level to INFO and get some pretty formatting
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger.info("Config loaded: {}".format(config_file))

# Get a logger!
logger = logging.getLogger(__name__)

# Set the storage directory
# Determine storage directory
storage_path = None
if config['storage'] is not None:
if config['storage'].startswith('/'):
storage_path = config['storage']
else:
storage_path = os.path.join(
os.path.dirname(os.path.realpath(sys.argv[0])),
os.path.dirname(os.path.realpath(__file__)),
config['storage']
)

logger.info("Storage path set to %s" % storage_path)
logger.info("Storage path: {}".format(storage_path))

directories = [
os.path.join(storage_path, 'database'),
Expand All @@ -156,20 +109,20 @@
for directory in directories:
if not os.path.exists(directory):
logger.info(
"Storage directory %s does not exist, creating it..",
directory)

"Initializing storage directory: {}".format(directory))
os.makedirs(directory)

"""If no username is supplied, set it to the nickname. """
# If no username is supplied, default to nickname
if config['username'] is None:
config['username'] = config['nickname']

# Instance a new factory, and connect with/without SSL
logger.debug("Instantiating CardinalBotFactory")
factory = CardinalBotFactory(config['network'], config['server_password'],
factory = CardinalBotFactory(config['network'],
config['server_password'],
config['channels'],
config['nickname'], config['password'],
config['nickname'],
config['password'],
config['username'],
config['realname'],
config['plugins'],
Expand Down
61 changes: 9 additions & 52 deletions cardinal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,59 +150,16 @@ def load_config(self, file_):
"""
# Attempt to load and parse the config file
try:
f = open(file_, 'r')
json_config = self._utf8_json(json.load(f))
f.close()
# File did not exist or we can't open it for another reason
except IOError:
self.logger.warning(
"Can't open %s (using defaults / command-line values)" % file_
)
# Thrown by json.load() when the content isn't valid JSON
except ValueError:
self.logger.warning(
"Invalid JSON in %s, (using defaults / command-line values)" %
file_
)
else:
# For every option,
for option in self.spec.options:
# If the option wasn't defined in the config, default
if option not in json_config:
json_config[option] = None

self.config[option] = self.spec.return_value_or_default(
option, json_config[option])

# If we didn't load the config earlier, or there was nothing in it...
if self.config == {} and self.spec.options != {}:
for option in self.spec.options:
# Grab the default
self.config[option] = self.spec.options[option][1]
f = open(file_, 'r')
json_config = self._utf8_json(json.load(f))
f.close()

return self.config

def merge_argparse_args_into_config(self, args):
"""Merges the args returned by argparse.ArgumentParser into the config.
Keyword arguments:
args -- The args object returned by argsparse.parse_args().
Returns:
dict -- Dictionary object of the entire config.
"""
# For every option,
for option in self.spec.options:
try:
# If the value exists in args and is set, then update the
# config's value
value = getattr(args, option)
if value is not None:
self.config[option] = value
except AttributeError:
self.logger.debug(
"Option %s not in CLI arguments -- not updated" % option
)
# If the option wasn't defined in the config, default
value = json_config[option] if option in json_config else None

self.config[option] = self.spec.return_value_or_default(
option, value)

return self.config
82 changes: 17 additions & 65 deletions cardinal/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,11 @@ def test_return_value_or_default_value(self):


class TestConfigParser(object):
DEFAULT = '_default_'

def setup_method(self):
config_spec = self.config_spec = ConfigSpec()
config_spec.add_option("not_in_json", basestring)
config_spec.add_option("not_in_json", basestring, default=self.DEFAULT)
config_spec.add_option("string", basestring)
config_spec.add_option("int", int)
config_spec.add_option("bool", bool)
Expand All @@ -72,26 +74,22 @@ def test_constructor(self):
ConfigParser("not a ConfigSpec")

def test_load_config_nonexistent_file(self):
# For some reason, this silently fails
self.config_parser.load_config(
os.path.join(FIXTURE_DIRECTORY, 'nonexistent.json'))
# if the config file doesn't exist, error
with pytest.raises(IOError):
self.config_parser.load_config(
os.path.join(FIXTURE_DIRECTORY, 'nonexistent.json'))

# should all be set to defaults
assert self.config_parser.config['string'] is None
assert self.config_parser.config['int'] is None
assert self.config_parser.config['bool'] is None
assert self.config_parser.config['dict'] is None
# nothing loaded
assert self.config_parser.config == {}

def test_load_config_invalid_file(self):
# For some reason, this silently fails
self.config_parser.load_config(
os.path.join(FIXTURE_DIRECTORY, 'invalid-json-config.json'))
# if the config is invalid, error
with pytest.raises(ValueError):
self.config_parser.load_config(
os.path.join(FIXTURE_DIRECTORY, 'invalid-json-config.json'))

# should all be set to defaults
assert self.config_parser.config['string'] is None
assert self.config_parser.config['int'] is None
assert self.config_parser.config['bool'] is None
assert self.config_parser.config['dict'] is None
# nothing loaded
assert self.config_parser.config == {}

def test_load_config_picks_up_values(self):
self.config_parser.load_config(
Expand All @@ -105,54 +103,8 @@ def test_load_config_picks_up_values(self):
'list': ['foo', 'bar', 'baz'],
}

# This should get set to None when it's not found in the file
assert self.config_parser.config['not_in_json'] is None

# This was in the file but not the spec and should not appear in config
assert 'ignored_string' not in self.config_parser.config

def test_merge_argparse_args_into_config(self):
class args:
string = 'value'
int = 3
bool = False
dict = {'foo': 'bar'}
ignored_string = 'asdf'

self.config_parser.merge_argparse_args_into_config(args)

assert self.config_parser.config['string'] == 'value'
assert self.config_parser.config['int'] == 3
assert self.config_parser.config['bool'] is False
assert self.config_parser.config['dict'] == {'foo': 'bar'}

# defaults only get set by load_config, not
# merge_argparse_args_into_config
assert 'not_in_json' not in self.config_parser.config
# If not found in the file, default
assert self.config_parser.config['not_in_json'] == self.DEFAULT

# This was in the file but not the spec and should not appear in config
assert 'ignored_string' not in self.config_parser.config

def test_merge_argparse_args_into_config_overwrites_config(self):
self.config_parser.load_config(
os.path.join(FIXTURE_DIRECTORY, 'config.json'))

assert self.config_parser.config['string'] == 'value'
assert self.config_parser.config['int'] == 3
assert self.config_parser.config['bool'] is False
assert self.config_parser.config['dict'] == {
'dict': {'string': 'value'},
'list': ['foo', 'bar', 'baz'],
}

class args:
string = 'new_value'
int = 5
dict = {'foo': 'bar'}

self.config_parser.merge_argparse_args_into_config(args)

assert self.config_parser.config['string'] == 'new_value'
assert self.config_parser.config['int'] == 5
assert self.config_parser.config['bool'] is False # no value to update
assert self.config_parser.config['dict'] == {'foo': 'bar'}

0 comments on commit c08dabe

Please sign in to comment.