From 05d5b73b1ef4e63c5a1318cbb1d03b8016c41412 Mon Sep 17 00:00:00 2001 From: "Tim D. Smith" Date: Thu, 18 May 2017 09:57:15 -0700 Subject: [PATCH 1/3] Remove standalone listener capability --- README.md | 11 +- setup.py | 1 - snooze/repository_listener.py | 172 ------------------------ snooze/snooze.py | 53 -------- snooze/test/test_repository_listener.py | 110 --------------- 5 files changed, 1 insertion(+), 346 deletions(-) delete mode 100644 snooze/repository_listener.py delete mode 100644 snooze/snooze.py delete mode 100644 snooze/test/test_repository_listener.py diff --git a/README.md b/README.md index 90e72a2..1074fa6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Add a "snooze" label to an issue, and github-snooze-button will remove the label * a pull request receives a comment on a diff, or * a pull request branch is updated. -github-snooze-button can operate in two modes: deployed to AWS Lambda, or polling a Amazon SQS queue locally. +github-snooze-button runs on AWS Lambda. ## Configuration file @@ -50,15 +50,6 @@ The AWS credentials in the config file are sent to Github and used to push notif And now you're live. -## Option 2: Polling mode - -1. Generate a Github authentication token with `public_repo` and `admin:repo_hook` scopes. -1. In AWS IAM, create a Amazon AWS user with all the AmazonSQS* and AmazonSNS* policies (and possibly fewer?) -1. Install github-snooze-button: `pip install git+https://github.com/tdsmith/github-snooze-button.git` -1. Launch with `snooze_listen /path/to/config.ini` - -Note that the queue will continue collecting events unless you disconnect the repository from SNS. - ## Teardown The fastest way to disable github-snooze-button is by deleting the Amazon SNS service from your repository's "Webhooks & services" configuration page. It will be automatically recreated the next time you run snooze in either mode. diff --git a/setup.py b/setup.py index 1310615..e387717 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,6 @@ install_requires=['boto3', 'requests'], entry_points={ 'console_scripts': [ - 'snooze_listen = snooze.snooze:main', 'snooze_deploy = snooze.deploy_lambda:main', ], }, diff --git a/snooze/repository_listener.py b/snooze/repository_listener.py deleted file mode 100644 index c213cc8..0000000 --- a/snooze/repository_listener.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import absolute_import - -import json -import pprint -import logging - -import boto3 -import requests - -from snooze.constants import GITHUB_HEADERS - -try: - basestring -except NameError: - basestring = str - - -class RepositoryListener(object): - """Sets up infrastructure for listening to a Github repository.""" - - def __init__(self, repository_name, - github_username, github_token, - aws_key, aws_secret, aws_region, - events, callbacks=None, **kwargs): - """Instantiates a RepositoryListener. - Additionally: - * Creates or connects to a AWS SQS queue named for the repository - * Creates or connects to a AWS SNS topic named for the repository - * Connects the AWS SNS topic to the AWS SQS queue - * Configures the Github repository to push hooks to the SNS topic - - Args: - repository_name (str): name of a Github repository, like - "tdsmith/homebrew-pypi-poet" - github_username (str): Github username - github_token (str): Github authentication token from - https://github.com/settings/tokens/new with admin:org_hook - privileges - aws_key (str): AWS key - aws_secret (str): AWS secret - aws_region (str): AWS region (e.g. 'us-west-2') - events (list): List of Github webhook events to monitor for - activity, from https://developer.github.com/webhooks/#events. - callbacks (list): - functions to call with a decoded Github JSON payload when a - webhook event lands. You can register these after instantiation - with register_callback. - """ - self.repository_name = repository_name - self.github_username = github_username - self.github_token = github_token - self.aws_key = aws_key - self.aws_secret = aws_secret - self.aws_region = aws_region - - # create or reuse sqs queue - sqs_resource = boto3.resource("sqs", region_name=self.aws_region) - self.sqs_queue = sqs_resource.create_queue( - QueueName="snooze__{}".format(self._to_topic(repository_name)) - ) - - # create or reuse sns topic - sns_resource = boto3.resource("sns", region_name=self.aws_region) - sns_topic = sns_resource.create_topic( - Name=self._to_topic(repository_name) - ) - sns_topic.subscribe( - Protocol='sqs', - Endpoint=self.sqs_queue.attributes["QueueArn"] - ) - - # configure repository to push to the sns topic - connect_github_to_sns(aws_key, aws_secret, aws_region, - github_username, github_token, repository_name, - sns_topic.arn, events) - - # register callbacks - self._callbacks = [] - if callbacks: - [self.register_callback(f) for f in callbacks] - - def poll(self, wait=True): - """Checks for messages from the Github repository. - - Args: - wait (bool): Use SQS long polling, i.e. wait up to 20 seconds for a - message to be received before returning an empty list. - - Returns: None - """ - messages = self.sqs_queue.receive_messages(WaitTimeSeconds=20*wait) - for message in messages: - body = message.body - logging.debug( - "Queue {} received message: {}".format( - self.sqs_queue.url, body)) - try: - decoded_full_body = json.loads(body) - decoded_body = json.loads(decoded_full_body["Message"]) - event_type = decoded_full_body["MessageAttributes"]["X-Github-Event"]["Value"] - except ValueError: - logging.error("Queue {} received non-JSON message: {}".format( - self.sqs_queue.url, body)) - else: - for callback in self._callbacks: - try: - callback(event_type, decoded_body) - except Exception as e: - logging.error( - "Queue {} encountered exception {} while " - "processing message {}: {}".format( - self.sqs_queue.url, e.__class__.__name__, - pprint.pformat(decoded_body), str(e) - )) - finally: - message.delete() - - def _to_topic(self, repository_name): - """Converts a repository_name to a valid SNS topic name. - - Args: - repository_name: Name of a Github repository - - Returns: str - """ - return repository_name.replace("/", "__") - - def register_callback(self, callback): - """Registers a callback on a webhook received event. - - All callbacks are always called, in the order registered, for all events - received. - - Args: - callback (function(str, Object)): function accepting an event_type - argument with the name of the triggered event and an event_payload - object with the JSON-decoded payload body - """ - self._callbacks.append(callback) - - -def connect_github_to_sns(aws_key, aws_secret, aws_region, - github_username, github_token, repository_name, - sns_topic_arn, events, **_): - """Connects a Github repository to a SNS topic. - - Args: - sns_topic_arn: ARN of an existing SNS topic - events (list | str): Github webhook events to monitor for - activity, from https://developer.github.com/webhooks/#events. - - Returns: None - """ - auth = requests.auth.HTTPBasicAuth(github_username, github_token) - if isinstance(events, basestring): - events = [events] - payload = { - "name": "amazonsns", - "config": { - "aws_key": aws_key, - "aws_secret": aws_secret, - "sns_topic": sns_topic_arn, - "sns_region": aws_region, - }, - "events": events, - } - r = requests.post( - "https://api.github.com/repos/{}/hooks".format(repository_name), - data=json.dumps(payload), - headers=GITHUB_HEADERS, - auth=auth) - r.raise_for_status() diff --git a/snooze/snooze.py b/snooze/snooze.py deleted file mode 100644 index 7a1ce18..0000000 --- a/snooze/snooze.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import absolute_import - -import argparse -import logging -import sys -import threading -import time - -from snooze.callbacks import github_callback -from snooze.config import parse_config -from snooze.constants import LISTEN_EVENTS -from snooze.repository_listener import RepositoryListener - -logging.basicConfig(level=logging.DEBUG) - - -def poll_forever(repo_listener, wait): - while True: - repo_listener.poll() - logging.debug("Waiting {}s before polling {}". - format(wait, repo_listener.repository_name)) - time.sleep(wait) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("config") - args = parser.parse_args() - - config = parse_config(args.config) - for name, repo in config.items(): - github_auth = (repo["github_username"], repo["github_password"]) - snooze_label = repo["snooze_label"] - ignore_members_of = repo["ignore_members_of"] - callback = lambda event, message: github_callback(event, message, github_auth, - snooze_label, ignore_members_of) - listener = RepositoryListener( - callbacks=[callback], - events=LISTEN_EVENTS, - **repo) - t = threading.Thread(target=poll_forever, args=(listener, repo["poll_interval"])) - t.daemon = True - t.start() - while True: - # wait forever for a signal or an unusual termination - if threading.active_count() < len(config) + 1: - logging.error("Child polling thread quit!") - return False - time.sleep(1) - return True - -if __name__ == "__main__": - sys.exit(not main()) diff --git a/snooze/test/test_repository_listener.py b/snooze/test/test_repository_listener.py deleted file mode 100644 index 2785205..0000000 --- a/snooze/test/test_repository_listener.py +++ /dev/null @@ -1,110 +0,0 @@ -import json -import logging -from textwrap import dedent -import types - -import boto3 -import moto -import pytest -import responses -from testfixtures import LogCapture -import six - -import snooze - -logging.getLogger("botocore").setLevel(logging.INFO) - - -class MockAPIMetaclass(type): - """Metaclass which wraps all methods of its instances with decorators - that redirect SNS and SQS calls to moto and activates responses.""" - def __new__(cls, name, bases, attrs): - for attr_name, attr_value in attrs.items(): - if isinstance(attr_value, types.FunctionType): - attrs[attr_name] = cls.decorate(attr_value) - return super(MockAPIMetaclass, cls).__new__(cls, name, bases, attrs) - - @classmethod - def decorate(cls, func): - return moto.mock_sqs(moto.mock_sns(responses.activate(func))) - - -@six.add_metaclass(MockAPIMetaclass) -class TestRepositoryListenener(object): - @pytest.fixture - def config(self, tmpdir): - config = tmpdir.join("config.txt") - config.write(dedent("""\ - [tdsmith/test_repo] - github_username: frodo - github_token: baggins - aws_key: shire - aws_secret: precious - aws_region: us-west-2 - snooze_label: snooze - """)) - return snooze.parse_config(str(config)) - - @pytest.fixture - def trivial_message(self): - a = {"key": "value"} - b = {"Message": json.dumps(a)} - (b.setdefault("MessageAttributes", {}). - setdefault("X-Github-Event", {}). - setdefault("Value", "spam")) - return json.dumps(b) - - def test_constructor(self, config): - sqs = boto3.resource("sqs", region_name="us-west-2") - sns = boto3.resource("sns", region_name="us-west-2") - assert len(list(sqs.queues.all())) == 0 - assert len(list(sns.topics.all())) == 0 - - responses.add(responses.POST, "https://api.github.com/repos/tdsmith/test_repo/hooks") - snooze.RepositoryListener(events=snooze.LISTEN_EVENTS, **config["tdsmith/test_repo"]) - assert len(list(sqs.queues.all())) > 0 - assert len(list(sns.topics.all())) > 0 - - def test_poll(self, config, trivial_message): - self._test_poll_was_polled = False - - def my_callback(event, message): - self._test_poll_was_polled = True - - responses.add(responses.POST, "https://api.github.com/repos/tdsmith/test_repo/hooks") - repo_listener = snooze.RepositoryListener( - events=snooze.LISTEN_EVENTS, - callbacks=[my_callback], **config["tdsmith/test_repo"]) - - sqs = boto3.resource("sqs", region_name="us-west-2") - sqs_queue = list(sqs.queues.all())[0] - - sqs_queue.send_message(MessageBody=trivial_message) - assert int(sqs_queue.attributes["ApproximateNumberOfMessages"]) > 0 - - repo_listener.poll() - sqs_queue.reload() - assert int(sqs_queue.attributes["ApproximateNumberOfMessages"]) == 0 - assert self._test_poll_was_polled - - def test_bad_message_is_logged(self, config, trivial_message): - responses.add(responses.POST, "https://api.github.com/repos/tdsmith/test_repo/hooks") - repo_listener = snooze.RepositoryListener( - events=snooze.LISTEN_EVENTS, - **config["tdsmith/test_repo"]) - - sqs = boto3.resource("sqs", region_name="us-west-2") - sqs_queue = list(sqs.queues.all())[0] - sqs_queue.send_message(MessageBody="this isn't a json message at all") - - with LogCapture() as l: - repo_listener.poll() - assert 'ERROR' in str(l) - - def my_callback(event, message): - raise ValueError("I object!") - sqs_queue.send_message(MessageBody=trivial_message) - repo_listener.register_callback(my_callback) - with LogCapture() as l: - repo_listener.poll() - assert 'I object!' in str(l) From 5b53f34b1a1e92e469df8cba2f6be310d48b0807 Mon Sep 17 00:00:00 2001 From: "Tim D. Smith" Date: Thu, 18 May 2017 09:57:44 -0700 Subject: [PATCH 2/3] Make tox faster --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 9700842..36092ec 100644 --- a/tox.ini +++ b/tox.ini @@ -22,17 +22,17 @@ setenv = [testenv:clean] commands = coverage erase deps = coverage -skipsdist = True +skip_install = True [testenv:lint] commands = flake8 snooze deps = flake8 -skipsdist = True +skip_install = True [testenv:coverage_report] commands = coverage report -m deps = coverage -skipsdist = True +skip_install = True [testenv:pypy] From 5439b01797fe74fa6421eec5019aa356242508bc Mon Sep 17 00:00:00 2001 From: "Tim D. Smith" Date: Thu, 18 May 2017 09:59:47 -0700 Subject: [PATCH 3/3] Move things around Stop relying on import *'s --- setup.py | 2 +- snooze/__init__.py | 5 ---- snooze/deploy_lambda.py | 45 +++++++++++++++++++++++++++++++---- snooze/test/test_callbacks.py | 36 ++++++++++++++-------------- snooze/test/test_snooze.py | 8 +++---- 5 files changed, 64 insertions(+), 32 deletions(-) diff --git a/setup.py b/setup.py index e387717..8f3e982 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.5' ], - install_requires=['boto3', 'requests'], + install_requires=['boto3', 'requests', 'six'], entry_points={ 'console_scripts': [ 'snooze_deploy = snooze.deploy_lambda:main', diff --git a/snooze/__init__.py b/snooze/__init__.py index aa3ab33..e69de29 100644 --- a/snooze/__init__.py +++ b/snooze/__init__.py @@ -1,5 +0,0 @@ -# flake8:noqa -from .snooze import * -from .repository_listener import * -from .config import * -from .version import __version__ diff --git a/snooze/deploy_lambda.py b/snooze/deploy_lambda.py index 771e0ea..edae338 100644 --- a/snooze/deploy_lambda.py +++ b/snooze/deploy_lambda.py @@ -2,6 +2,7 @@ import argparse import glob +import json import logging import os import shutil @@ -13,8 +14,11 @@ import boto3 from botocore.exceptions import ClientError import pkg_resources +import requests +import six -import snooze +from .config import parse_config +from .constants import GITHUB_HEADERS, LISTEN_EVENTS LAMBDA_ROLE_TRUST_POLICY = """\ @@ -57,6 +61,39 @@ def create_or_get_lambda_role(): return role +def connect_github_to_sns(aws_key, aws_secret, aws_region, + github_username, github_token, repository_name, + sns_topic_arn, events, **_): + """Connects a Github repository to a SNS topic. + + Args: + sns_topic_arn: ARN of an existing SNS topic + events (list | str): Github webhook events to monitor for + activity, from https://developer.github.com/webhooks/#events. + + Returns: None + """ + auth = requests.auth.HTTPBasicAuth(github_username, github_token) + if isinstance(events, six.string_types): + events = [events] + payload = { + "name": "amazonsns", + "config": { + "aws_key": aws_key, + "aws_secret": aws_secret, + "sns_topic": sns_topic_arn, + "sns_region": aws_region, + }, + "events": events, + } + r = requests.post( + "https://api.github.com/repos/{}/hooks".format(repository_name), + data=json.dumps(payload), + headers=GITHUB_HEADERS, + auth=auth) + r.raise_for_status() + + def create_deployment_packages(config): """Builds deployment packages for each configured repository. @@ -171,7 +208,7 @@ def main(): parser.add_argument("config") args = parser.parse_args() - config = snooze.parse_config(args.config) + config = parse_config(args.config) create_deployment_packages(config) iam_role = create_or_get_lambda_role() @@ -180,9 +217,9 @@ def main(): # set up SNS topic and connect Github sns = boto3.resource("sns", region_name=repo["aws_region"]) topic = sns.create_topic(Name=repository_name.replace("/", "__")) - snooze.connect_github_to_sns( + connect_github_to_sns( sns_topic_arn=topic.arn, - events=snooze.constants.LISTEN_EVENTS, + events=LISTEN_EVENTS, **repo) # upload a Lambda package diff --git a/snooze/test/test_callbacks.py b/snooze/test/test_callbacks.py index fe4ac0c..4b7cb0e 100644 --- a/snooze/test/test_callbacks.py +++ b/snooze/test/test_callbacks.py @@ -6,8 +6,8 @@ import requests from testfixtures import LogCapture -import snooze -import snooze.callbacks +from snooze.callbacks import github_callback, is_member_of +from snooze.config import parse_config import github_responses @@ -24,7 +24,7 @@ def config(self, tmpdir): aws_region: us-west-2 snooze_label: snooze """)) - return snooze.parse_config(str(config))["baxterthehacker/public-repo"] + return parse_config(str(config))["baxterthehacker/public-repo"] @responses.activate def test_issue_comment_callback(self, config): @@ -33,7 +33,7 @@ def test_issue_comment_callback(self, config): responses.add( responses.PATCH, "https://api.github.com/repos/baxterthehacker/public-repo/issues/2") - r = snooze.github_callback( + r = github_callback( "issue_comment", json.loads(github_responses.SNOOZED_ISSUE_COMMENT), (config["github_username"], config["github_token"]), @@ -44,7 +44,7 @@ def test_issue_comment_callback(self, config): org_url = "https://api.github.com/orgs/fellowship/members/baxterthehacker" responses.add(responses.GET, org_url, status=204) # is a member - r = snooze.github_callback( + r = github_callback( "issue_comment", json.loads(github_responses.SNOOZED_ISSUE_COMMENT), (config["github_username"], config["github_token"]), @@ -54,7 +54,7 @@ def test_issue_comment_callback(self, config): orc_url = "https://api.github.com/orgs/orcs/members/baxterthehacker" responses.add(responses.GET, orc_url, status=404) # is not a member - r = snooze.github_callback( + r = github_callback( "issue_comment", json.loads(github_responses.SNOOZED_ISSUE_COMMENT), (config["github_username"], config["github_token"]), @@ -65,7 +65,7 @@ def test_issue_comment_callback(self, config): @responses.activate def test_issue_comment_callback_not_snoozed(self, config): """Don't do anything on receiving an unsnoozed message.""" - r = snooze.github_callback( + r = github_callback( "issue_comment", json.loads(github_responses.UNSNOOZED_ISSUE_COMMENT), (config["github_username"], config["github_token"]), @@ -85,7 +85,7 @@ def test_pr_synchronize_callback(self, config): responses.GET, "https://api.github.com/repos/baxterthehacker/public-repo/issues/1", body=github_responses.SNOOZED_ISSUE_GET) - r = snooze.github_callback( + r = github_callback( "pull_request", json.loads(github_responses.PULL_REQUEST), (config["github_username"], config["github_token"]), @@ -102,7 +102,7 @@ def test_pr_synchronize_callback_not_snoozed(self, config): responses.GET, "https://api.github.com/repos/baxterthehacker/public-repo/issues/1", body=github_responses.UNSNOOZED_ISSUE_GET) - r = snooze.github_callback( + r = github_callback( "pull_request", json.loads(github_responses.PULL_REQUEST), (config["github_username"], config["github_token"]), @@ -122,7 +122,7 @@ def test_pr_commit_comment_callback(self, config): responses.GET, "https://api.github.com/repos/baxterthehacker/public-repo/issues/1", body=github_responses.SNOOZED_ISSUE_GET) - r = snooze.github_callback( + r = github_callback( "pull_request_review_comment", json.loads(github_responses.PULL_REQUEST_REVIEW_COMMENT), (config["github_username"], config["github_token"]), @@ -133,7 +133,7 @@ def test_pr_commit_comment_callback(self, config): org_url = "https://api.github.com/orgs/fellowship/members/baxterthehacker" responses.add(responses.GET, org_url, status=204) # is a member - r = snooze.github_callback( + r = github_callback( "pull_request_review_comment", json.loads(github_responses.PULL_REQUEST_REVIEW_COMMENT), (config["github_username"], config["github_token"]), @@ -143,7 +143,7 @@ def test_pr_commit_comment_callback(self, config): orc_url = "https://api.github.com/orgs/orcs/members/baxterthehacker" responses.add(responses.GET, orc_url, status=404) # is not a member - r = snooze.github_callback( + r = github_callback( "pull_request_review_comment", json.loads(github_responses.PULL_REQUEST_REVIEW_COMMENT), (config["github_username"], config["github_token"]), @@ -159,7 +159,7 @@ def test_pr_commit_comment_callback_not_snoozed(self, config): responses.GET, "https://api.github.com/repos/baxterthehacker/public-repo/issues/1", body=github_responses.UNSNOOZED_ISSUE_GET) - r = snooze.github_callback( + r = github_callback( "pull_request_review_comment", json.loads(github_responses.PULL_REQUEST_REVIEW_COMMENT), (config["github_username"], config["github_token"]), @@ -170,7 +170,7 @@ def test_pr_commit_comment_callback_not_snoozed(self, config): def test_bad_callback_type_is_logged(self, config): with LogCapture() as l: - snooze.github_callback("foobar", None, None, None, None) + github_callback("foobar", None, None, None, None) assert "WARNING" in str(l) @@ -188,7 +188,7 @@ def config(self, tmpdir): snooze_label: snooze ignore_members_of: fellowship """)) - return snooze.parse_config(str(config))["baxterthehacker/public-repo"] + return parse_config(str(config))["baxterthehacker/public-repo"] @pytest.fixture def github_auth(self, config): @@ -198,17 +198,17 @@ def github_auth(self, config): def test_is_member_true(self, config, github_auth): url = "https://api.github.com/orgs/fellowship/members/bilbo" responses.add(responses.GET, url, status=204) - assert snooze.callbacks.is_member_of(github_auth, "bilbo", "fellowship") + assert is_member_of(github_auth, "bilbo", "fellowship") @responses.activate def test_is_member_false(self, config, github_auth): url = "https://api.github.com/orgs/fellowship/members/sauron" responses.add(responses.GET, url, status=404) - assert snooze.callbacks.is_member_of(github_auth, "sauron", "fellowship") is False + assert is_member_of(github_auth, "sauron", "fellowship") is False @responses.activate def test_is_member_raises(self, config, github_auth): url = "https://api.github.com/orgs/fellowship/members/bilbo" responses.add(responses.GET, url, status=200) with pytest.raises(requests.exceptions.HTTPError): - snooze.callbacks.is_member_of(github_auth, "bilbo", "fellowship") + is_member_of(github_auth, "bilbo", "fellowship") diff --git a/snooze/test/test_snooze.py b/snooze/test/test_snooze.py index c912f02..4961597 100644 --- a/snooze/test/test_snooze.py +++ b/snooze/test/test_snooze.py @@ -2,7 +2,7 @@ import pytest -import snooze +from snooze.config import parse_config class TestConfigParser(object): @@ -16,7 +16,7 @@ def test_parse_config(self, tmpdir): aws_secret: secret snooze_label: snooze """)) - parsed = snooze.parse_config(str(config)) + parsed = parse_config(str(config)) assert parsed["tdsmith/test_repo"]["repository_name"] == "tdsmith/test_repo" assert parsed["tdsmith/test_repo"]["github_username"] == "tdsmith" @@ -32,7 +32,7 @@ def test_parse_config_defaults(self, tmpdir): aws_key: key aws_secret: secret """)) - parsed = snooze.parse_config(str(config)) + parsed = parse_config(str(config)) assert parsed["tdsmith/test_repo"]["github_username"] == "tdsmith" assert parsed["tdsmith/test_repo"]["poll_interval"] == 0 assert parsed["tdsmith/test_repo"]["ignore_members_of"] is None @@ -45,4 +45,4 @@ def test_parse_config_raises(self, tmpdir): config = tmpdir.join("config.txt") config.write("[tdsmith/test_repo]\ngithub_username: tdsmith\n") with pytest.raises(configparser.NoOptionError): - snooze.parse_config(str(config)) + parse_config(str(config))