├── README ├── gnippy ├── test │ ├── __init__.py │ ├── test_config.py │ ├── test_utils.py │ ├── test_powertrackclient.py │ └── test_rules.py ├── __init__.py ├── errors.py ├── powertrackclient.py ├── config.py └── rules.py ├── MANIFEST.in ├── requirements-dev.txt ├── Makefile ├── .gitignore ├── tox.ini ├── .travis.yml ├── LICENSE.txt ├── CHANGES.txt ├── setup.py ├── README.rst └── CLA ├── j-bennet.txt ├── mu.txt └── cla_template.txt /README: -------------------------------------------------------------------------------- 1 | README.rst -------------------------------------------------------------------------------- /gnippy/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | recursive-include docs *.txt -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | mock==1.0.1 2 | nose==1.3.0 3 | tox>=1.9.2 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | 3 | clean: 4 | rm -Rf build 5 | 6 | publish: 7 | python setup.py sdist upload 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /MANIFEST 2 | /dist/* 3 | *.pyc 4 | .idea* 5 | gnippy.egg-info* 6 | .python-version 7 | /.tox 8 | build/ 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py26, py27, py33, py35 3 | [testenv] 4 | deps = py26: unittest2 5 | nose 6 | mock 7 | requests==2.8.1 8 | commands = nosetests 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | - "3.6" 8 | 9 | install: 10 | - if [[ $TRAVIS_PYTHON_VERSION == 2.6 ]]; then pip install unittest2; fi 11 | - pip install -r requirements-dev.txt 12 | - pip install -e . 13 | 14 | script: 15 | - nosetests 16 | -------------------------------------------------------------------------------- /gnippy/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # gnippy - GNIP for Python 3 | from __future__ import absolute_import 4 | 5 | __title__ = 'gnippy' 6 | __version__ = '0.4.1' 7 | __author__ = 'Abhinav Ajgaonkar' 8 | __license__ = 'Apache 2.0' 9 | __copyright__ = 'Copyright 2012-2015 Abhinav Ajgaonkar' 10 | 11 | from .powertrackclient import PowerTrackClient 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2012-2016 Abhinav Ajgaonkar and the Gnippy contributors. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | v0.1.0, 2012-12-06 -- Initial release. 2 | v0.1.1, 2012-12-06 -- Bugfix in constructor. 3 | v0.1.2, 2012-12-06 -- Adding background worker to prevent blocking while data is being fetched. 4 | v0.1.3, 2013-05-14 -- Upgrading to requests v1.2.0. 5 | v0.1.4 2013-05-15 -- Fixing broken pip install. 6 | v0.2.0 2013-05-15 -- Adding support for file based configuration. Adding unit tests. 7 | v0.3.0 2013-05-19 -- Adding initial support for the Rules API and tests. 8 | v0.3.1 2013-05-19 -- Bugfixes for the rules API URL generation. 9 | v0.3.2 2013-05-19 -- Renaming build_rule() to just build(). 10 | v0.3.3 2013-05-20 -- Adding the ability to list all current rules + unit tests 11 | v0.3.4 2013-05-20 -- Adding the ability to delete rules + unit tests 12 | -- 13 | 14 | This file is no longer maintained. See "releases" on GitHub for a list of changes. 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | version = "0.7.0" 7 | 8 | try: 9 | from setuptools import setup 10 | except ImportError: 11 | from distutils.core import setup 12 | 13 | if sys.argv[-1] == "publish": 14 | os.system("python setup.py sdist register upload") 15 | sys.exit(1) 16 | 17 | # These are required because sometimes PyPI refuses to bundle certain files 18 | try: 19 | long_desc = open('README').read() 20 | except: 21 | long_desc = "" 22 | 23 | try: 24 | license = open('LICENSE.txt').read() 25 | except: 26 | license = "Apache 2.0 License" 27 | 28 | setup( 29 | name='gnippy', 30 | version=version, 31 | description='Python library for GNIP.', 32 | long_description=long_desc, 33 | author='Abhinav Ajgaonkar', 34 | author_email='abhinav316@gmail.com', 35 | packages=['gnippy'], 36 | url='http://pypi.python.org/pypi/gnippy/', 37 | license=license, 38 | install_requires=[ 39 | "requests>=2.8.1,<3.0.0", 40 | "six>=1.10.0" 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /gnippy/errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | class ConfigFileNotFoundException(Exception): 4 | """ Raised when an invalid config_file_path argument was passed. """ 5 | pass 6 | 7 | 8 | class IncompleteConfigurationException(Exception): 9 | """ Raised when no .gnippy file is found. """ 10 | pass 11 | 12 | 13 | class BadArgumentException(Exception): 14 | """ Raised when an invalid argument is detected. """ 15 | pass 16 | 17 | 18 | class RuleAddFailedException(Exception): 19 | """ Raised when a rule add fails. """ 20 | pass 21 | 22 | 23 | class RulesListFormatException(Exception): 24 | """ Raised when rules_list is not in the correct format. """ 25 | pass 26 | 27 | 28 | class RulesGetFailedException(Exception): 29 | """ Raised when listing the current rule set fails. """ 30 | pass 31 | 32 | 33 | class BadPowerTrackUrlException(Exception): 34 | """ Raised when the PowerTrack URL looks like its incorrect. """ 35 | pass 36 | 37 | 38 | class RuleDeleteFailedException(Exception): 39 | """ Raised when a rule delete fails. """ 40 | pass -------------------------------------------------------------------------------- /gnippy/test/test_config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | try: 3 | import unittest2 as unittest 4 | except ImportError: 5 | import unittest 6 | 7 | from gnippy import config as gnippy_config 8 | from gnippy.test import test_utils 9 | 10 | 11 | class ConfigTestCase(unittest.TestCase): 12 | 13 | def setUp(self): 14 | test_utils.unset_environment_config_vars() 15 | 16 | def tearDown(self): 17 | """ Delete the test config file at test_config_path """ 18 | test_utils.delete_test_config() 19 | test_utils.unset_environment_config_vars() 20 | 21 | def test_default_path(self): 22 | """ 23 | Infer the user's home directory (either in /Users/.. or /home/..) 24 | and check if the default path produces the corrrect output. 25 | """ 26 | possible_paths = test_utils.get_possible_config_locations() 27 | actual = gnippy_config.get_default_config_file_path() 28 | self.assertTrue(actual in possible_paths) 29 | 30 | def test_config_parsing_full(self): 31 | """ Read the test config and compare values. """ 32 | test_utils.generate_test_config_file() 33 | result = gnippy_config.get_config(test_utils.test_config_path) 34 | self.assertEqual(result['Credentials']['username'], test_utils.test_username) 35 | self.assertEqual(result['Credentials']['password'], test_utils.test_password) 36 | self.assertEqual(result['PowerTrack']['url'], test_utils.test_powertrack_url) 37 | self.assertEqual(result['PowerTrack']['rules_url'], test_utils.test_rules_url) 38 | 39 | def test_config_parsing_halt(self): 40 | """ Read the half config file and compare values. """ 41 | test_utils.generate_test_config_file_with_only_auth() 42 | result = gnippy_config.get_config(test_utils.test_config_path) 43 | self.assertEqual(result['Credentials']['username'], test_utils.test_username) 44 | self.assertEqual(result['Credentials']['password'], test_utils.test_password) 45 | self.assertEqual(result['PowerTrack']['url'], None) 46 | self.assertEqual(result['PowerTrack']['rules_url'], None) 47 | 48 | def test_resolve_file_arg(self): 49 | """ Run the "resolve" method with just a filename and check if all info is loaded. """ 50 | test_utils.generate_test_config_file() 51 | conf = gnippy_config.resolve({"config_file_path": test_utils.test_config_path}) 52 | self.assertEqual(conf['auth'][0], test_utils.test_username) 53 | self.assertEqual(conf['auth'][1], test_utils.test_password) 54 | self.assertEqual(conf['url'], test_utils.test_powertrack_url) 55 | self.assertEqual(conf['rules_url'], test_utils.test_rules_url) 56 | 57 | def test_resolve_conf_from_environment_variables(self): 58 | """ Run the "resolve" method providing env vars and check if all info is loaded. """ 59 | test_utils.delete_test_config() 60 | test_utils.set_environment_config_vars() 61 | conf = gnippy_config.resolve({}) 62 | self.assertEqual(conf['url'], test_utils.test_powertrack_url) 63 | self.assertEqual(conf['rules_url'], test_utils.test_rules_url) 64 | self.assertEqual(conf['auth'][0], test_utils.test_username) 65 | self.assertEqual(conf['auth'][1], test_utils.test_password) 66 | 67 | def test_resolve_conf_from_kwargs1(self): 68 | """ Run the "resolve" method providing kwargs and check if all info is loaded. """ 69 | test_utils.delete_test_config() 70 | rules_url = 'http://gnip.rules.url' 71 | username = 'gnip-username' 72 | password = 'gnip-password' 73 | conf = gnippy_config.resolve({ 74 | 'rules_url': rules_url, 75 | 'auth': (username, password) 76 | }) 77 | self.assertEqual(conf['rules_url'], rules_url) 78 | self.assertEqual(conf['auth'][0], username) 79 | self.assertEqual(conf['auth'][1], password) 80 | 81 | def test_resolve_conf_from_kwargs2(self): 82 | """ Run the "resolve" method providing kwargs and check if all info is loaded. """ 83 | test_utils.delete_test_config() 84 | url = 'http://gnip.url' 85 | username = 'gnip-username' 86 | password = 'gnip-password' 87 | conf = gnippy_config.resolve({ 88 | 'url': url, 89 | 'auth': (username, password) 90 | }) 91 | self.assertEqual(conf['url'], url) 92 | self.assertEqual(conf['auth'][0], username) 93 | self.assertEqual(conf['auth'][1], password) 94 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | gnippy: Python library for GNIP 2 | =============================== 3 | 4 | .. image:: https://badge.fury.io/py/gnippy.svg 5 | :target: https://pypi.python.org/pypi/gnippy 6 | 7 | .. image:: https://img.shields.io/pypi/dm/gnippy.svg 8 | :target: https://pypi.python.org/pypi/gnippy 9 | 10 | .. image:: https://travis-ci.org/abh1nav/gnippy.svg?branch=master 11 | :target: https://travis-ci.org/abh1nav/gnippy 12 | 13 | gnippy provides an easy way to access the `Power Track `_ stream provided by GNIP. 14 | You can also use gnippy to programatically add rules to your Power Track stream. 15 | 16 | Install 17 | ------- 18 | .. code-block:: python 19 | 20 | pip install gnippy 21 | 22 | Quickstart 23 | ---------- 24 | Create a .gnippy file and place it in your home directory. It should contain the following: 25 | 26 | .. code-block:: text 27 | 28 | [Credentials] 29 | username = user@company.com 30 | password = mypassword 31 | 32 | [PowerTrack] 33 | url = https://my.gnip.powertrack/url.json 34 | rules_url = https://api.gnip.powertrack/rules.json 35 | 36 | Fire up the client: 37 | 38 | .. code-block:: python 39 | 40 | #!/usr/bin/env python 41 | import time 42 | from gnippy import PowerTrackClient 43 | 44 | # Define a callback 45 | def callback(activity): 46 | print activity 47 | 48 | # Create the client 49 | client = PowerTrackClient(callback) 50 | client.connect() 51 | 52 | # Wait for 2 minutes and then disconnect 53 | time.sleep(120) 54 | client.disconnect() 55 | 56 | Configuration 57 | ------------- 58 | 59 | If you don't want to create a config file or you want it put it in another location: 60 | 61 | .. code-block:: python 62 | 63 | client = PowerTrackClient(callback, config_file_path="/etc/gnippy") 64 | # OR ... provide the url and authentication credentials to override any config files 65 | client = PowerTrackClient(callback, url="http://my.gnip.powertrack/url.json", auth=("uname", "pwd")) 66 | 67 | You can also configure gnippy using environment variables: 68 | 69 | .. code-block:: text 70 | 71 | GNIPPY_URL 72 | GNIPPY_RULES_URL 73 | GNIPPY_AUTH_USERNAME 74 | GNIPPY_AUTH_PASSWORD 75 | 76 | 77 | 78 | 79 | 80 | Adding PowerTrack Rules 81 | ----------------------- 82 | 83 | If you want to add `rules `_ to your PowerTrack: 84 | 85 | .. code-block:: python 86 | 87 | from gnippy import rules 88 | from gnippy.errors import RuleAddFailedException 89 | 90 | # Synchronously add rules 91 | try: 92 | rules.add_rule('(Hello OR World OR "this is a test") lang:en', tag="MyRule") 93 | rules.add_rule('Rule without a tag') 94 | except RuleAddFailedException: 95 | pass 96 | 97 | # OR ... synchronously add multiple rules at once 98 | rule_list = [] 99 | rule_list.append(rules.build("Hello World", tag="asdf")) 100 | rule_list.append(rules.build("Rule Without a Tag")) 101 | try: 102 | rules.add_rules(rule_list) 103 | except RuleAddFailedException: 104 | pass 105 | 106 | # OR ... manually pass in params - overrides any config files 107 | rules.add_rule("My Rule String", tag="mytag", rules_url="https://api.gnip.powertrack/rules.json", \ 108 | auth=("uname", "pwd")) 109 | 110 | 111 | Listing Active PowerTrack Rules 112 | ------------------------------- 113 | 114 | .. code-block:: python 115 | 116 | from gnippy import rules 117 | from gnippy.errors import RulesGetFailedException 118 | 119 | try: 120 | rules_list = rules.get_rules() 121 | # rules_list is in the format: 122 | # [ 123 | # { "value": "(Hello OR World) AND lang:en" }, 124 | # { "value": "Hello", "tag": "mytag" } 125 | # ] 126 | except RulesGetFailedException: 127 | pass 128 | 129 | Deleting PowerTrack Rules 130 | ------------------------- 131 | 132 | .. code-block:: python 133 | 134 | from gnippy import rules 135 | from gnippy.errors import RuleDeleteFailedException, RulesGetFailedException 136 | 137 | try: 138 | rules_list = rules.get_rules() 139 | # Suppose I want to delete the first rule in the list 140 | rules.delete_rule(rules_list[0]) 141 | # OR ... I want to delete ALL rules 142 | rules.delete_rules(rules_list) 143 | 144 | except RuleDeleteFailedException, RulesGetFailedException: 145 | pass 146 | 147 | Source available on GitHub: http://github.com/abh1nav/gnippy/ 148 | -------------------------------------------------------------------------------- /gnippy/powertrackclient.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from contextlib import closing 4 | import sys 5 | import threading 6 | 7 | try: 8 | import urlparse 9 | from urllib import urlencode 10 | except: # For Python 3 11 | import urllib.parse as urlparse 12 | from urllib.parse import urlencode 13 | 14 | import requests 15 | 16 | from gnippy import config 17 | 18 | 19 | def append_backfill_to_url(url, backfill_minutes): 20 | parsed = list(urlparse.urlparse(url)) 21 | 22 | params = {'backfillMinutes': backfill_minutes} 23 | 24 | qs = dict(urlparse.parse_qsl(parsed[4])) 25 | 26 | qs.update(params) 27 | 28 | parsed[4] = urlencode(qs) 29 | 30 | return urlparse.urlunparse(parsed) 31 | 32 | 33 | class PowerTrackClient: 34 | """ 35 | PowerTrackClient allows you to connect to the GNIP 36 | power track stream and fetch data. 37 | """ 38 | callback = None 39 | url = None 40 | auth = None 41 | 42 | def __init__(self, callback, exception_callback=None, **kwargs): 43 | self.callback = callback 44 | self.exception_callback = exception_callback 45 | c = config.resolve(kwargs) 46 | self.url = c['url'] 47 | self.auth = c['auth'] 48 | 49 | def connect(self, backfill_minutes=None): 50 | 51 | connection_url = self.get_connection_url(backfill_minutes) 52 | 53 | self.worker = Worker( 54 | url=connection_url, 55 | auth=self.auth, 56 | callback=self.callback, 57 | exception_callback=self.exception_callback) 58 | 59 | self.worker.setDaemon(True) 60 | 61 | self.worker.start() 62 | 63 | def get_connection_url(self, backfill_minutes=None): 64 | connection_url = self.url 65 | 66 | if backfill_minutes: 67 | assert type(backfill_minutes) is int, \ 68 | "backfill_minutes is not an integer: {0}".format( 69 | backfill_minutes) 70 | 71 | assert backfill_minutes <= 5, \ 72 | "backfill_minutes should be 5 or less: {0}".format( 73 | backfill_minutes) 74 | 75 | connection_url = append_backfill_to_url( 76 | connection_url, backfill_minutes) 77 | 78 | return connection_url 79 | 80 | def connected(self): 81 | 82 | return not self.worker.stopped() 83 | 84 | def disconnect(self): 85 | self.worker.stop() 86 | self.worker.join() 87 | 88 | def load_config_from_file(self, url, auth, config_file_path): 89 | """ Attempt to load the config from a file. """ 90 | conf = config.get_config(config_file_path=config_file_path) 91 | 92 | if url is None: 93 | conf_url = conf['PowerTrack']['url'] 94 | if conf_url: 95 | self.url = conf['PowerTrack']['url'] 96 | else: 97 | self.url = url 98 | 99 | if auth is None: 100 | conf_uname = conf['Credentials']['username'] 101 | conf_pwd = conf['Credentials']['password'] 102 | self.auth = (conf_uname, conf_pwd) 103 | else: 104 | self.auth = auth 105 | 106 | 107 | class Worker(threading.Thread): 108 | """ Background worker to fetch data without blocking """ 109 | 110 | def __init__(self, url, auth, callback, exception_callback=None): 111 | super(Worker, self).__init__() 112 | self.url = url 113 | self.auth = auth 114 | self.on_data = callback 115 | self.on_error = exception_callback 116 | self._stop_event = threading.Event() 117 | 118 | def stop(self): 119 | self._stop_event.set() 120 | 121 | def stopped(self): 122 | return self._stop_event.isSet() 123 | 124 | def run(self): 125 | try: 126 | with closing( 127 | requests.get(self.url, auth=self.auth, stream=True)) as r: 128 | if r.status_code != 200: 129 | self.stop() 130 | raise Exception( 131 | "GNIP returned HTTP {}".format(r.status_code)) 132 | 133 | for line in r.iter_lines(): 134 | if line: 135 | self.on_data(line) 136 | 137 | if self.stopped(): 138 | break 139 | except Exception: 140 | if self.on_error: 141 | exinfo = sys.exc_info() 142 | self.on_error(exinfo) 143 | else: 144 | # re-raise the last exception as-is 145 | raise 146 | finally: 147 | if not self.stopped(): 148 | # clean up and set the stop event 149 | self.stop() 150 | -------------------------------------------------------------------------------- /gnippy/test/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | try: 4 | import ConfigParser 5 | except ImportError: 6 | import configparser as ConfigParser 7 | import os 8 | import pwd 9 | from six import PY2 10 | 11 | test_config_path = "/tmp/.gnippy" 12 | test_username = "TestUserName" 13 | test_password = "testP@ssw0rd" 14 | test_powertrack_url = "https://stream.gnip.com:443/accounts/Organization/publishers/twitter/streams/track/Production.json" 15 | test_rules_url = "https://api.gnip.com:443/accounts/Organization/publishers/twitter/streams/track/Production/rules.json" 16 | 17 | 18 | def _add_credentials(parser): 19 | """ Add the Credentials section to a ConfigParser. """ 20 | parser.add_section(u"Credentials") 21 | parser.set(u"Credentials", u"username", test_username) 22 | parser.set(u"Credentials", u"password", test_password) 23 | 24 | 25 | def _add_power_track_urls(parser): 26 | """ Add the PowerTrack section to a ConfigParser. """ 27 | parser.add_section("PowerTrack") 28 | parser.set("PowerTrack", "url", test_powertrack_url) 29 | parser.set("PowerTrack", "rules_url", test_rules_url) 30 | 31 | 32 | def _write_config_file(parser): 33 | """ Write out the contents of the provided ConfigParser to the test_config_path. """ 34 | if PY2: 35 | with open(test_config_path, 'wb') as configfile: 36 | parser.write(configfile) 37 | else: 38 | import io 39 | with io.open(test_config_path, 'w', encoding='utf-8') as configfile: 40 | parser.write(configfile) 41 | 42 | 43 | def set_environment_config_vars(): 44 | """ Set GNIPPY env variables. """ 45 | os.environ["GNIPPY_URL"] = test_powertrack_url 46 | os.environ["GNIPPY_RULES_URL"] = test_rules_url 47 | os.environ["GNIPPY_AUTH_USERNAME"] = test_username 48 | os.environ["GNIPPY_AUTH_PASSWORD"] = test_password 49 | 50 | 51 | def unset_environment_config_vars(): 52 | """ Unset GNIPPY env variables. """ 53 | for k in ["GNIPPY_URL", "GNIPPY_RULES_URL", "GNIPPY_AUTH_USERNAME", "GNIPPY_AUTH_PASSWORD"]: 54 | if k in os.environ: 55 | del os.environ[k] 56 | 57 | 58 | def delete_test_config(): 59 | """ Delete the test config if it exists. """ 60 | if os.path.isfile(test_config_path): 61 | os.remove(test_config_path) 62 | 63 | 64 | def generate_test_config_file(): 65 | """ Generate a test config file at test_config_path """ 66 | parser = ConfigParser.SafeConfigParser() 67 | _add_credentials(parser) 68 | _add_power_track_urls(parser) 69 | _write_config_file(parser) 70 | 71 | 72 | def generate_test_config_file_with_only_auth(): 73 | """ Generate a test config file at test_config_path """ 74 | parser = ConfigParser.SafeConfigParser() 75 | _add_credentials(parser) 76 | _write_config_file(parser) 77 | 78 | 79 | def generate_test_config_file_with_only_powertrack(): 80 | """ Generate a test config file at test_config_path """ 81 | parser = ConfigParser.SafeConfigParser() 82 | _add_power_track_urls(parser) 83 | _write_config_file(parser) 84 | 85 | 86 | def get_current_username(): 87 | return pwd.getpwuid(os.getuid())[0] 88 | 89 | 90 | def get_possible_home_dirs(): 91 | username = get_current_username() 92 | return [i % username for i in ["/Users/%s", "/home/%s"]] 93 | 94 | 95 | def get_possible_config_locations(): 96 | return [os.path.join(i, ".gnippy") for i in get_possible_home_dirs()] 97 | 98 | 99 | def os_file_exists_false(path): 100 | """ Used to patch the os.path.isfile method in tests. """ 101 | return False 102 | 103 | 104 | class Response(): 105 | """ Various sub-classes of Response are used by Mocks throughout tests. """ 106 | status_code = None 107 | text = None 108 | json_dict = None 109 | 110 | def __init__(self, response_code, text, json): 111 | self.status_code = response_code 112 | self.text = text 113 | if json: 114 | self.json_dict = json 115 | 116 | def json(self): 117 | return self.json_dict 118 | 119 | 120 | class BadResponse(Response): 121 | def __init__(self, response_code=500, text="Internal Server Error", json=None): 122 | Response.__init__(self, response_code, text, json) 123 | 124 | 125 | class GoodResponseJsonError(Response): 126 | """ A failed request that raises an exception when the JSON method is called. """ 127 | def __init__(self, response_code=200, text="All OK"): 128 | Response.__init__(self, response_code, text, None) 129 | 130 | def json(self): 131 | raise Exception("This Exception was raised intentionally") 132 | 133 | 134 | class GoodResponse(Response): 135 | def __init__(self, response_code=200, text="All OK", json=None): 136 | Response.__init__(self, response_code, text, json) -------------------------------------------------------------------------------- /gnippy/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | try: 4 | import configparser as ConfigParser 5 | 6 | except ImportError: 7 | import ConfigParser 8 | 9 | import os 10 | 11 | from gnippy.errors import ConfigFileNotFoundException, IncompleteConfigurationException 12 | 13 | 14 | # These are all the configurable settings by setting 15 | VALID_OPTIONS = { 16 | "Credentials": ('username', 'password'), 17 | "PowerTrack": ('url', 'rules_url') 18 | } 19 | 20 | 21 | def get_default_config_file_path(): 22 | """ 23 | Returns the absolute path to the default placement of the 24 | config file (~/.gnippy) 25 | """ 26 | # --- This section has been borrowed from boto ------------------------ 27 | # Copyright (c) 2006,2007 Mitch Garnaat http://garnaat.org/ 28 | # Copyright (c) 2011 Chris Moyer http://coredumped.org/ 29 | # https://raw.github.com/boto/boto/develop/boto/pyami/config.py 30 | # 31 | # If running in Google App Engine there is no "user" and 32 | # os.path.expanduser() will fail. Attempt to detect this case and use a 33 | # no-op expanduser function in this case. 34 | try: 35 | os.path.expanduser('~') 36 | expanduser = os.path.expanduser 37 | except (AttributeError, ImportError): 38 | # This is probably running on App Engine. 39 | expanduser = (lambda x: x) 40 | # ---End borrowed section --------------------------------------------- 41 | return os.path.join(expanduser("~"), ".gnippy") 42 | 43 | 44 | def get_config(config_file_path=None): 45 | """ 46 | Parses the .gnippy file at the provided location. 47 | Returns a dictionary with all the possible configuration options, 48 | with None for the options that were not provided. 49 | """ 50 | if not os.path.isfile(config_file_path): 51 | raise ConfigFileNotFoundException("Could not find %s" % config_file_path) 52 | 53 | # Attempt to parse the config file 54 | result = {} 55 | parser = ConfigParser.SafeConfigParser() 56 | parser.read(config_file_path) 57 | 58 | for section in VALID_OPTIONS: 59 | keys = VALID_OPTIONS[section] 60 | values = {} 61 | for key in keys: 62 | try: 63 | values[key] = parser.get(section, key) 64 | except ConfigParser.NoOptionError: 65 | values[key] = None 66 | except ConfigParser.NoSectionError: 67 | values[key] = None 68 | 69 | result[section] = values 70 | 71 | return result 72 | 73 | 74 | def resolve(kwarg_dict): 75 | """ 76 | Look for auth and url info in the kwargs. 77 | If they don't exist check for the environment variables 78 | GNIPPY_URL 79 | GNIPPY_RULES_URL 80 | GNIPPY_AUTH_USERNAME 81 | GNIPPY_AUTH_PASSWORD 82 | If they don't exist, look for a config file path and resolve auth & url info from it. 83 | If no config file path exists, try to load the config file from the default path. 84 | If this method returns without errors, the dictionary is guaranteed to contain: 85 | { 86 | "auth": ("username", "password"), 87 | "url": "PowerTrackUrl" 88 | } 89 | """ 90 | conf = {} 91 | if "auth" in kwarg_dict: 92 | conf['auth'] = kwarg_dict['auth'] 93 | else: 94 | username = os.getenv("GNIPPY_AUTH_USERNAME") 95 | password = os.getenv("GNIPPY_AUTH_PASSWORD") 96 | 97 | if username and password: 98 | conf['auth'] = (username, password) 99 | 100 | if "url" in kwarg_dict: 101 | conf['url'] = kwarg_dict['url'] 102 | elif os.getenv("GNIPPY_URL"): 103 | conf['url'] = os.getenv("GNIPPY_URL") 104 | 105 | if 'rules_url' in kwarg_dict: 106 | conf['rules_url'] = kwarg_dict['rules_url'] 107 | elif os.getenv('GNIPPY_RULES_URL'): 108 | conf['rules_url'] = os.getenv("GNIPPY_RULES_URL") 109 | 110 | if "auth" not in conf or ("url" not in conf and 'rules_url' not in conf): 111 | 112 | if "config_file_path" in kwarg_dict: 113 | file_conf = get_config(config_file_path=kwarg_dict['config_file_path']) 114 | 115 | else: 116 | file_conf = get_config(config_file_path=get_default_config_file_path()) 117 | 118 | if "auth" not in conf: 119 | creds = file_conf['Credentials'] 120 | if creds['username'] and creds['password']: 121 | conf['auth'] = (creds['username'], creds['password']) 122 | else: 123 | raise IncompleteConfigurationException( 124 | "Incomplete authentication information provided. " 125 | "Please provide a username and password.") 126 | 127 | if "url" not in conf and file_conf['PowerTrack']['url']: 128 | # Not raising an exception on missing url, because user may 129 | # only want to manage rules, so streaming url is optional. 130 | conf['url'] = file_conf['PowerTrack']['url'] 131 | 132 | if "rules_url" not in conf and file_conf['PowerTrack']['rules_url']: 133 | # Not raising an exception on missing rules_url, because user may 134 | # only want to consume streaming data with PowerTrack client, 135 | # so rules_url is optional. 136 | conf['rules_url'] = file_conf['PowerTrack']['rules_url'] 137 | 138 | if 'url' not in conf and 'rules_url' not in conf: 139 | raise IncompleteConfigurationException("Please provide a PowerTrack url or rules_url.") 140 | 141 | return conf 142 | -------------------------------------------------------------------------------- /gnippy/rules.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | 5 | try: 6 | from urllib.parse import urlparse 7 | except: 8 | from urlparse import urlparse 9 | 10 | import requests 11 | from six import string_types 12 | 13 | from gnippy import config 14 | from gnippy.errors import * 15 | 16 | 17 | def _generate_post_object(rules_list): 18 | """ Generate the JSON object that gets posted to the Rules API. """ 19 | if isinstance(rules_list, list): 20 | return { "rules": rules_list } 21 | else: 22 | raise BadArgumentException("rules_list must be of type list") 23 | 24 | 25 | def _check_rules_list(rules_list): 26 | """ Checks a rules_list to ensure that all rules are in the correct format. """ 27 | def fail(): 28 | msg = "rules_list is not in the correct format. Please use build_rule to build your rules list." 29 | raise RulesListFormatException(msg) 30 | 31 | if not isinstance(rules_list, list): 32 | fail() 33 | 34 | expected = ("value", "tag", "id", "id_str") 35 | for r in rules_list: 36 | if not isinstance(r, dict): 37 | fail() 38 | 39 | if "value" not in r: 40 | fail() 41 | 42 | if not isinstance(r['value'], string_types): 43 | fail() 44 | 45 | if "tag" in r: 46 | rule_tag = r['tag'] 47 | if rule_tag is None or isinstance(rule_tag, string_types): 48 | pass 49 | else: 50 | fail() 51 | 52 | for k in r: 53 | if k not in expected: 54 | fail() 55 | 56 | 57 | def _post(conf, built_rules): 58 | """ 59 | Generate the Rules URL and POST data and make the POST request. 60 | POST data must look like: 61 | { 62 | "rules": [ 63 | {"value":"rule1", "tag":"tag1"}, 64 | {"value":"rule2"} 65 | ] 66 | } 67 | 68 | Args: 69 | conf: A configuration object that contains auth and url info. 70 | built_rules: A single or list of built rules. 71 | """ 72 | _check_rules_list(built_rules) 73 | rules_url = conf['rules_url'] 74 | post_data = json.dumps(_generate_post_object(built_rules)) 75 | r = requests.post(rules_url, auth=conf['auth'], data=post_data) 76 | if not r.status_code in range(200, 300): 77 | error_text = "HTTP Response Code: %s, Text: '%s'" % (str(r.status_code), r.text) 78 | raise RuleAddFailedException(error_text) 79 | 80 | 81 | def _generate_delete_url(conf): 82 | """ 83 | Generate the Rules URL for a DELETE request. 84 | """ 85 | rules_url = conf['rules_url'] 86 | parsed_url = urlparse(rules_url) 87 | query = parsed_url.query 88 | if query != '': 89 | return rules_url.replace(query, query + "&_method=delete") 90 | else: 91 | return rules_url + "?_method=delete" 92 | 93 | 94 | def _delete(conf, built_rules): 95 | """ 96 | Generate the Delete Rules URL and make a POST request. 97 | POST data must look like: 98 | { 99 | "rules": [ 100 | {"value":"rule1", "tag":"tag1"}, 101 | {"value":"rule2"} 102 | ] 103 | } 104 | 105 | Args: 106 | conf: A configuration object that contains auth and url info. 107 | built_rules: A single or list of built rules. 108 | """ 109 | _check_rules_list(built_rules) 110 | rules_url = _generate_delete_url(conf) 111 | delete_data = json.dumps(_generate_post_object(built_rules)) 112 | r = requests.post(rules_url, auth=conf['auth'], data=delete_data) 113 | if not r.status_code in range(200,300): 114 | error_text = "HTTP Response Code: %s, Text: '%s'" % (str(r.status_code), r.text) 115 | raise RuleDeleteFailedException(error_text) 116 | 117 | 118 | def build(rule_string, tag=None): 119 | """ 120 | Takes a rule string and optional tag and turns it into a "built_rule" that looks like: 121 | { "value": "rule string", "tag": "my tag" } 122 | """ 123 | if rule_string is None: 124 | raise BadArgumentException("rule_string cannot be None") 125 | rule = { "value": rule_string } 126 | if tag: 127 | rule['tag'] = tag 128 | return rule 129 | 130 | 131 | def add_rule(rule_string, tag=None, **kwargs): 132 | """ Synchronously add a single rule to GNIP PowerTrack. """ 133 | conf = config.resolve(kwargs) 134 | rule = build(rule_string, tag) 135 | rules_list = [rule,] 136 | _post(conf, rules_list) 137 | 138 | 139 | def add_rules(rules_list, **kwargs): 140 | """ Synchronously add multiple rules to GNIP PowerTrack in one go. """ 141 | conf = config.resolve(kwargs) 142 | _post(conf, rules_list) 143 | 144 | 145 | def get_rules(**kwargs): 146 | """ 147 | Get all the rules currently applied to PowerTrack. 148 | Optional Args: 149 | rules_url: Specify this arg if you're working with a PowerTrack connection that's not listed in your .gnippy file. 150 | auth: Specify this arg if you want to override the credentials in your .gnippy file. 151 | 152 | Returns: 153 | A list of currently applied rules in the form: 154 | [ 155 | { "value": "(Hello OR World) AND lang:en" }, 156 | { "value": "Hello", "tag": "mytag" } 157 | ] 158 | """ 159 | conf = config.resolve(kwargs) 160 | rules_url = conf['rules_url'] 161 | 162 | def fail(reason): 163 | raise RulesGetFailedException("Could not get current rules for '%s'. Reason: '%s'" % (rules_url, reason)) 164 | 165 | try: 166 | r = requests.get(rules_url, auth=conf['auth']) 167 | except Exception as e: 168 | fail(str(e)) 169 | 170 | if r.status_code not in range(200,300): 171 | fail("HTTP Status Code: %s" % r.status_code) 172 | 173 | try: 174 | rules_json = r.json() 175 | except: 176 | fail("GNIP API returned malformed JSON") 177 | 178 | if "rules" in rules_json: 179 | return rules_json['rules'] 180 | else: 181 | fail("GNIP API response did not return a rules object") 182 | 183 | 184 | def delete_rule(rule_dict, **kwargs): 185 | """ Synchronously delete a single rule from GNIP PowerTrack. """ 186 | conf = config.resolve(kwargs) 187 | rules_list = [rule_dict,] 188 | _delete(conf, rules_list) 189 | 190 | 191 | def delete_rules(rules_list, **kwargs): 192 | """ Synchronously delete multiple rules from GNIP PowerTrack. """ 193 | conf = config.resolve(kwargs) 194 | _delete(conf, rules_list) 195 | -------------------------------------------------------------------------------- /CLA/j-bennet.txt: -------------------------------------------------------------------------------- 1 | Individual Contributor License Agreement ("Agreement") V2.0 2 | http://www.apache.org/licenses/ 3 | 4 | Thank you for your interest in gnippy (the 5 | "Project"). In order to clarify the intellectual property license 6 | granted with Contributions from any person or entity, the Project 7 | must have a Contributor License Agreement ("CLA") on file that has 8 | been signed by each Contributor, indicating agreement to the license 9 | terms below. This license is for your protection as a Contributor as 10 | well as the protection of the Project and its users; it does not 11 | change your rights to use your own Contributions for any other purpose. 12 | Please read this document carefully before signing and keep a copy for 13 | your records. 14 | 15 | Full name: Irina Truong 16 | 17 | GitHub username: j-bennet 18 | 19 | 20 | You accept and agree to the following terms and conditions for Your 21 | present and future Contributions submitted to the Project. In 22 | return, the Project shall not use Your Contributions in a way that 23 | is contrary to the public benefit or inconsistent with its nonprofit 24 | status and bylaws in effect at the time of the Contribution. Except 25 | for the license granted herein to the Project and recipients of 26 | software distributed by the Project, You reserve all right, title, 27 | and interest in and to Your Contributions. 28 | 29 | 1. Definitions. 30 | 31 | "You" (or "Your") shall mean the copyright owner or legal entity 32 | authorized by the copyright owner that is making this Agreement 33 | with the Project. For legal entities, the entity making a 34 | Contribution and all other entities that control, are controlled 35 | by, or are under common control with that entity are considered to 36 | be a single Contributor. For the purposes of this definition, 37 | "control" means (i) the power, direct or indirect, to cause the 38 | direction or management of such entity, whether by contract or 39 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 40 | outstanding shares, or (iii) beneficial ownership of such entity. 41 | 42 | "Contribution" shall mean any original work of authorship, 43 | including any modifications or additions to an existing work, that 44 | is intentionally submitted by You to the Project for inclusion 45 | in, or documentation of, any of the products owned or managed by 46 | the Project (the "Work"). For the purposes of this definition, 47 | "submitted" means any form of electronic, verbal, or written 48 | communication sent to the Project or its representatives, 49 | including but not limited to communication on electronic mailing 50 | lists, source code control systems, and issue tracking systems that 51 | are managed by, or on behalf of, the Project for the purpose of 52 | discussing and improving the Work, but excluding communication that 53 | is conspicuously marked or otherwise designated in writing by You 54 | as "Not a Contribution." 55 | 56 | 2. Grant of Copyright License. Subject to the terms and conditions of 57 | this Agreement, You hereby grant to the Project and to 58 | recipients of software distributed by the Project a perpetual, 59 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 60 | copyright license to reproduce, prepare derivative works of, 61 | publicly display, publicly perform, sublicense, and distribute Your 62 | Contributions and such derivative works. 63 | 64 | 3. Grant of Patent License. Subject to the terms and conditions of 65 | this Agreement, You hereby grant to the Project and to 66 | recipients of software distributed by the Project a perpetual, 67 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 68 | (except as stated in this section) patent license to make, have 69 | made, use, offer to sell, sell, import, and otherwise transfer the 70 | Work, where such license applies only to those patent claims 71 | licensable by You that are necessarily infringed by Your 72 | Contribution(s) alone or by combination of Your Contribution(s) 73 | with the Work to which such Contribution(s) was submitted. If any 74 | entity institutes patent litigation against You or any other entity 75 | (including a cross-claim or counterclaim in a lawsuit) alleging 76 | that your Contribution, or the Work to which you have contributed, 77 | constitutes direct or contributory patent infringement, then any 78 | patent licenses granted to that entity under this Agreement for 79 | that Contribution or Work shall terminate as of the date such 80 | litigation is filed. 81 | 82 | 4. You represent that you are legally entitled to grant the above 83 | license. If your employer(s) has rights to intellectual property 84 | that you create that includes your Contributions, you represent 85 | that you have received permission to make Contributions on behalf 86 | of that employer, that your employer has waived such rights for 87 | your Contributions to the Project, or that your employer has 88 | executed a separate Corporate CLA with the Project. 89 | 90 | 5. You represent that each of Your Contributions is Your original 91 | creation (see section 7 for submissions on behalf of others). You 92 | represent that Your Contribution submissions include complete 93 | details of any third-party license or other restriction (including, 94 | but not limited to, related patents and trademarks) of which you 95 | are personally aware and which are associated with any part of Your 96 | Contributions. 97 | 98 | 6. You are not expected to provide support for Your Contributions, 99 | except to the extent You desire to provide support. You may provide 100 | support for free, for a fee, or not at all. Unless required by 101 | applicable law or agreed to in writing, You provide Your 102 | Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS 103 | OF ANY KIND, either express or implied, including, without 104 | limitation, any warranties or conditions of TITLE, NON- 105 | INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. 106 | 107 | 7. Should You wish to submit work that is not Your original creation, 108 | You may submit it to the Project separately from any 109 | Contribution, identifying the complete details of its source and of 110 | any license or other restriction (including, but not limited to, 111 | related patents, trademarks, and license agreements) of which you 112 | are personally aware, and conspicuously marking the work as 113 | "Submitted on behalf of a third-party: [named here]". 114 | 115 | 8. You agree to notify the Project of any facts or circumstances of 116 | which you become aware that would make these representations 117 | inaccurate in any respect. 118 | 119 | Please sign: Irina Truong Date: 01/06/2016 120 | -------------------------------------------------------------------------------- /CLA/mu.txt: -------------------------------------------------------------------------------- 1 | Individual Contributor License Agreement ("Agreement") V2.0 2 | http://www.apache.org/licenses/ 3 | 4 | Thank you for your interest in gnippy (the 5 | "Project"). In order to clarify the intellectual property license 6 | granted with Contributions from any person or entity, the Project 7 | must have a Contributor License Agreement ("CLA") on file that has 8 | been signed by each Contributor, indicating agreement to the license 9 | terms below. This license is for your protection as a Contributor as 10 | well as the protection of the Project and its users; it does not 11 | change your rights to use your own Contributions for any other purpose. 12 | Please read this document carefully before signing and keep a copy for 13 | your records. 14 | 15 | Full name: Mark Unsworth 16 | 17 | GitHub username: markunsworth 18 | 19 | 20 | You accept and agree to the following terms and conditions for Your 21 | present and future Contributions submitted to the Project. In 22 | return, the Project shall not use Your Contributions in a way that 23 | is contrary to the public benefit or inconsistent with its nonprofit 24 | status and bylaws in effect at the time of the Contribution. Except 25 | for the license granted herein to the Project and recipients of 26 | software distributed by the Project, You reserve all right, title, 27 | and interest in and to Your Contributions. 28 | 29 | 1. Definitions. 30 | 31 | "You" (or "Your") shall mean the copyright owner or legal entity 32 | authorized by the copyright owner that is making this Agreement 33 | with the Project. For legal entities, the entity making a 34 | Contribution and all other entities that control, are controlled 35 | by, or are under common control with that entity are considered to 36 | be a single Contributor. For the purposes of this definition, 37 | "control" means (i) the power, direct or indirect, to cause the 38 | direction or management of such entity, whether by contract or 39 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 40 | outstanding shares, or (iii) beneficial ownership of such entity. 41 | 42 | "Contribution" shall mean any original work of authorship, 43 | including any modifications or additions to an existing work, that 44 | is intentionally submitted by You to the Project for inclusion 45 | in, or documentation of, any of the products owned or managed by 46 | the Project (the "Work"). For the purposes of this definition, 47 | "submitted" means any form of electronic, verbal, or written 48 | communication sent to the Project or its representatives, 49 | including but not limited to communication on electronic mailing 50 | lists, source code control systems, and issue tracking systems that 51 | are managed by, or on behalf of, the Project for the purpose of 52 | discussing and improving the Work, but excluding communication that 53 | is conspicuously marked or otherwise designated in writing by You 54 | as "Not a Contribution." 55 | 56 | 2. Grant of Copyright License. Subject to the terms and conditions of 57 | this Agreement, You hereby grant to the Project and to 58 | recipients of software distributed by the Project a perpetual, 59 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 60 | copyright license to reproduce, prepare derivative works of, 61 | publicly display, publicly perform, sublicense, and distribute Your 62 | Contributions and such derivative works. 63 | 64 | 3. Grant of Patent License. Subject to the terms and conditions of 65 | this Agreement, You hereby grant to the Project and to 66 | recipients of software distributed by the Project a perpetual, 67 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 68 | (except as stated in this section) patent license to make, have 69 | made, use, offer to sell, sell, import, and otherwise transfer the 70 | Work, where such license applies only to those patent claims 71 | licensable by You that are necessarily infringed by Your 72 | Contribution(s) alone or by combination of Your Contribution(s) 73 | with the Work to which such Contribution(s) was submitted. If any 74 | entity institutes patent litigation against You or any other entity 75 | (including a cross-claim or counterclaim in a lawsuit) alleging 76 | that your Contribution, or the Work to which you have contributed, 77 | constitutes direct or contributory patent infringement, then any 78 | patent licenses granted to that entity under this Agreement for 79 | that Contribution or Work shall terminate as of the date such 80 | litigation is filed. 81 | 82 | 4. You represent that you are legally entitled to grant the above 83 | license. If your employer(s) has rights to intellectual property 84 | that you create that includes your Contributions, you represent 85 | that you have received permission to make Contributions on behalf 86 | of that employer, that your employer has waived such rights for 87 | your Contributions to the Project, or that your employer has 88 | executed a separate Corporate CLA with the Project. 89 | 90 | 5. You represent that each of Your Contributions is Your original 91 | creation (see section 7 for submissions on behalf of others). You 92 | represent that Your Contribution submissions include complete 93 | details of any third-party license or other restriction (including, 94 | but not limited to, related patents and trademarks) of which you 95 | are personally aware and which are associated with any part of Your 96 | Contributions. 97 | 98 | 6. You are not expected to provide support for Your Contributions, 99 | except to the extent You desire to provide support. You may provide 100 | support for free, for a fee, or not at all. Unless required by 101 | applicable law or agreed to in writing, You provide Your 102 | Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS 103 | OF ANY KIND, either express or implied, including, without 104 | limitation, any warranties or conditions of TITLE, NON- 105 | INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. 106 | 107 | 7. Should You wish to submit work that is not Your original creation, 108 | You may submit it to the Project separately from any 109 | Contribution, identifying the complete details of its source and of 110 | any license or other restriction (including, but not limited to, 111 | related patents, trademarks, and license agreements) of which you 112 | are personally aware, and conspicuously marking the work as 113 | "Submitted on behalf of a third-party: [named here]". 114 | 115 | 8. You agree to notify the Project of any facts or circumstances of 116 | which you become aware that would make these representations 117 | inaccurate in any respect. 118 | 119 | Please sign: Mark Unsworth Date: 17th Nov 2015 120 | -------------------------------------------------------------------------------- /CLA/cla_template.txt: -------------------------------------------------------------------------------- 1 | Individual Contributor License Agreement ("Agreement") V2.0 2 | http://www.apache.org/licenses/ 3 | 4 | Thank you for your interest in gnippy (the 5 | "Project"). In order to clarify the intellectual property license 6 | granted with Contributions from any person or entity, the Project 7 | must have a Contributor License Agreement ("CLA") on file that has 8 | been signed by each Contributor, indicating agreement to the license 9 | terms below. This license is for your protection as a Contributor as 10 | well as the protection of the Project and its users; it does not 11 | change your rights to use your own Contributions for any other purpose. 12 | Please read this document carefully before signing and keep a copy for 13 | your records. 14 | 15 | Full name: __________________________________________________ 16 | 17 | GitHub username: ____________________________________________ 18 | 19 | 20 | You accept and agree to the following terms and conditions for Your 21 | present and future Contributions submitted to the Project. In 22 | return, the Project shall not use Your Contributions in a way that 23 | is contrary to the public benefit or inconsistent with its nonprofit 24 | status and bylaws in effect at the time of the Contribution. Except 25 | for the license granted herein to the Project and recipients of 26 | software distributed by the Project, You reserve all right, title, 27 | and interest in and to Your Contributions. 28 | 29 | 1. Definitions. 30 | 31 | "You" (or "Your") shall mean the copyright owner or legal entity 32 | authorized by the copyright owner that is making this Agreement 33 | with the Project. For legal entities, the entity making a 34 | Contribution and all other entities that control, are controlled 35 | by, or are under common control with that entity are considered to 36 | be a single Contributor. For the purposes of this definition, 37 | "control" means (i) the power, direct or indirect, to cause the 38 | direction or management of such entity, whether by contract or 39 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 40 | outstanding shares, or (iii) beneficial ownership of such entity. 41 | 42 | "Contribution" shall mean any original work of authorship, 43 | including any modifications or additions to an existing work, that 44 | is intentionally submitted by You to the Project for inclusion 45 | in, or documentation of, any of the products owned or managed by 46 | the Project (the "Work"). For the purposes of this definition, 47 | "submitted" means any form of electronic, verbal, or written 48 | communication sent to the Project or its representatives, 49 | including but not limited to communication on electronic mailing 50 | lists, source code control systems, and issue tracking systems that 51 | are managed by, or on behalf of, the Project for the purpose of 52 | discussing and improving the Work, but excluding communication that 53 | is conspicuously marked or otherwise designated in writing by You 54 | as "Not a Contribution." 55 | 56 | 2. Grant of Copyright License. Subject to the terms and conditions of 57 | this Agreement, You hereby grant to the Project and to 58 | recipients of software distributed by the Project a perpetual, 59 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 60 | copyright license to reproduce, prepare derivative works of, 61 | publicly display, publicly perform, sublicense, and distribute Your 62 | Contributions and such derivative works. 63 | 64 | 3. Grant of Patent License. Subject to the terms and conditions of 65 | this Agreement, You hereby grant to the Project and to 66 | recipients of software distributed by the Project a perpetual, 67 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 68 | (except as stated in this section) patent license to make, have 69 | made, use, offer to sell, sell, import, and otherwise transfer the 70 | Work, where such license applies only to those patent claims 71 | licensable by You that are necessarily infringed by Your 72 | Contribution(s) alone or by combination of Your Contribution(s) 73 | with the Work to which such Contribution(s) was submitted. If any 74 | entity institutes patent litigation against You or any other entity 75 | (including a cross-claim or counterclaim in a lawsuit) alleging 76 | that your Contribution, or the Work to which you have contributed, 77 | constitutes direct or contributory patent infringement, then any 78 | patent licenses granted to that entity under this Agreement for 79 | that Contribution or Work shall terminate as of the date such 80 | litigation is filed. 81 | 82 | 4. You represent that you are legally entitled to grant the above 83 | license. If your employer(s) has rights to intellectual property 84 | that you create that includes your Contributions, you represent 85 | that you have received permission to make Contributions on behalf 86 | of that employer, that your employer has waived such rights for 87 | your Contributions to the Project, or that your employer has 88 | executed a separate Corporate CLA with the Project. 89 | 90 | 5. You represent that each of Your Contributions is Your original 91 | creation (see section 7 for submissions on behalf of others). You 92 | represent that Your Contribution submissions include complete 93 | details of any third-party license or other restriction (including, 94 | but not limited to, related patents and trademarks) of which you 95 | are personally aware and which are associated with any part of Your 96 | Contributions. 97 | 98 | 6. You are not expected to provide support for Your Contributions, 99 | except to the extent You desire to provide support. You may provide 100 | support for free, for a fee, or not at all. Unless required by 101 | applicable law or agreed to in writing, You provide Your 102 | Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS 103 | OF ANY KIND, either express or implied, including, without 104 | limitation, any warranties or conditions of TITLE, NON- 105 | INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. 106 | 107 | 7. Should You wish to submit work that is not Your original creation, 108 | You may submit it to the Project separately from any 109 | Contribution, identifying the complete details of its source and of 110 | any license or other restriction (including, but not limited to, 111 | related patents, trademarks, and license agreements) of which you 112 | are personally aware, and conspicuously marking the work as 113 | "Submitted on behalf of a third-party: [named here]". 114 | 115 | 8. You agree to notify the Project of any facts or circumstances of 116 | which you become aware that would make these representations 117 | inaccurate in any respect. 118 | 119 | Please sign: __________________________________ Date: ________________ 120 | 121 | -------------------------------------------------------------------------------- /gnippy/test/test_powertrackclient.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | from time import sleep 5 | 6 | import mock 7 | 8 | from gnippy.powertrackclient import append_backfill_to_url 9 | 10 | try: 11 | import unittest2 as unittest 12 | except ImportError: 13 | import unittest 14 | 15 | from gnippy import PowerTrackClient 16 | from gnippy.test import test_utils 17 | 18 | 19 | def _dummy_callback(activity): 20 | pass 21 | 22 | 23 | class TestException(Exception): 24 | def __init__(self, message): 25 | self.message = message 26 | 27 | 28 | def get_exception(*args, **kwargs): 29 | raise TestException("This is a test exception") 30 | 31 | 32 | def iter_lines_generator(): 33 | 34 | n = 0 35 | while True: 36 | 37 | yield "arbitrary string value {0}".format(n) 38 | n += 1 39 | 40 | def get_request_stream(url, auth, stream): 41 | 42 | mocked_stream = mock.MagicMock() 43 | 44 | p = mock.PropertyMock() 45 | p.return_value = 200 46 | 47 | type(mocked_stream).status_code = p 48 | 49 | mocked_stream.iter_lines.side_effect = iter_lines_generator 50 | 51 | return mocked_stream 52 | 53 | 54 | config_file = test_utils.test_config_path 55 | 56 | 57 | class PowerTrackClientTestCase(unittest.TestCase): 58 | 59 | def setUp(self): 60 | pass 61 | 62 | def tearDown(self): 63 | """ Remove the test config file. """ 64 | test_utils.delete_test_config() 65 | 66 | def test_constructor_only_file(self): 67 | """ Initialize PowerTrackClient with only a config file path. """ 68 | test_utils.generate_test_config_file() 69 | client = PowerTrackClient(_dummy_callback, config_file_path=config_file) 70 | expected_auth = (test_utils.test_username, test_utils.test_password) 71 | expected_url = test_utils.test_powertrack_url 72 | 73 | self.assertEqual(expected_auth[0], client.auth[0]) 74 | self.assertEqual(expected_auth[1], client.auth[1]) 75 | self.assertEqual(expected_url, client.url) 76 | 77 | def test_constructor_only_url(self): 78 | """ 79 | Initialize PowerTrackClient with only urls. 80 | The config file is provided for testability. 81 | """ 82 | test_utils.generate_test_config_file_with_only_auth() 83 | 84 | client = PowerTrackClient(_dummy_callback, 85 | url=test_utils.test_powertrack_url, 86 | config_file_path=config_file) 87 | expected_auth = (test_utils.test_username, test_utils.test_password) 88 | expected_url = test_utils.test_powertrack_url 89 | 90 | self.assertEqual(expected_auth[0], client.auth[0]) 91 | self.assertEqual(expected_auth[1], client.auth[1]) 92 | self.assertEqual(expected_url, client.url) 93 | 94 | def test_constructor_only_auth(self): 95 | """ 96 | Initialize PowerTrackClient with only a config the auth tuple. 97 | The config file is provided for testability. 98 | """ 99 | test_utils.generate_test_config_file_with_only_powertrack() 100 | 101 | expected_auth = (test_utils.test_username, test_utils.test_password) 102 | expected_url = test_utils.test_powertrack_url 103 | client = PowerTrackClient(_dummy_callback, auth=expected_auth, config_file_path=config_file) 104 | 105 | self.assertEqual(expected_auth[0], client.auth[0]) 106 | self.assertEqual(expected_auth[1], client.auth[1]) 107 | self.assertEqual(expected_url, client.url) 108 | 109 | def test_constructor_all_args(self): 110 | """ Initialize PowerTrackClient with all args. """ 111 | test_utils.generate_test_config_file() 112 | expected_auth = ("hello", "world") 113 | expected_url = "http://wat.com/testing.json" 114 | client = PowerTrackClient(_dummy_callback, auth=expected_auth, url=expected_url, config_file_path=config_file) 115 | 116 | self.assertEqual(expected_auth[0], client.auth[0]) 117 | self.assertEqual(expected_auth[1], client.auth[1]) 118 | self.assertEqual(expected_url, client.url) 119 | 120 | def test_no_args(self): 121 | """ Check if a ~/.gnippy file is present and run a no-arg test. """ 122 | possible_paths = test_utils.get_possible_config_locations() 123 | for config_path in possible_paths: 124 | if os.path.isfile(config_path): 125 | client = PowerTrackClient(_dummy_callback) 126 | self.assertIsNotNone(client.auth) 127 | self.assertIsNotNone(client.url) 128 | self.assertTrue("http" in client.url and "://" in client.url) 129 | 130 | @mock.patch('requests.get', get_exception) 131 | def test_exception_with_callback(self): 132 | """ When exception_callback is provided, worker uses it to communicate errors. """ 133 | test_utils.generate_test_config_file() 134 | 135 | exception_callback = mock.Mock() 136 | 137 | client = PowerTrackClient(_dummy_callback, exception_callback, 138 | config_file_path=config_file) 139 | client.connect() 140 | client.disconnect() 141 | 142 | self.assertTrue(exception_callback.called) 143 | actual_exinfo = exception_callback.call_args[0][0] 144 | actual_ex = actual_exinfo[1] 145 | self.assertIsInstance(actual_ex, Exception) 146 | self.assertEqual(actual_ex.message, "This is a test exception") 147 | 148 | @mock.patch('requests.get', get_exception) 149 | def test_connected_status_after_exception_is_false(self): 150 | """ When an exception is thrown the client is no longer connected. """ 151 | test_utils.generate_test_config_file() 152 | 153 | client = PowerTrackClient(_dummy_callback, 154 | config_file_path=config_file) 155 | client.connect() 156 | 157 | sleep(2) 158 | 159 | self.assertFalse(client.connected()) 160 | 161 | @mock.patch('requests.get', get_request_stream) 162 | def test_connected_status_true_when_running(self): 163 | """ Once the client connect method is called the client reports as connected. """ 164 | test_utils.generate_test_config_file() 165 | 166 | client = PowerTrackClient(_dummy_callback, 167 | config_file_path=config_file) 168 | 169 | client.connect() 170 | 171 | self.assertTrue(client.connected()) 172 | 173 | client.disconnect() 174 | 175 | self.assertFalse(client.connected()) 176 | 177 | @mock.patch('requests.get') 178 | def test_backfill_value_appended_to_url(self, mocked_requests_get): 179 | """When passing a backfill value it is appended to the url call to 180 | GNIP""" 181 | 182 | backfill_minutes = 3 183 | 184 | expected_powertrack_url = "{0}?backfillMinutes={1}".format( 185 | test_utils.test_powertrack_url, backfill_minutes) 186 | 187 | test_utils.generate_test_config_file() 188 | 189 | client = PowerTrackClient(_dummy_callback, config_file_path=config_file) 190 | 191 | client.connect(backfill_minutes=backfill_minutes) 192 | client.disconnect() 193 | 194 | mocked_requests_get.assert_called_with( 195 | expected_powertrack_url, 196 | auth=(test_utils.test_username, test_utils.test_password), 197 | stream=True 198 | ) 199 | 200 | def test_non_integer_backfill_value_raises_exception(self): 201 | """When passing a backfill value that isn't an integer raises an 202 | exception""" 203 | 204 | backfill_minutes = "FOUR" 205 | 206 | test_utils.generate_test_config_file() 207 | 208 | client = PowerTrackClient(_dummy_callback, config_file_path=config_file) 209 | 210 | self.assertRaises(AssertionError, client.connect, backfill_minutes) 211 | 212 | def test_backfill_value_greater_than_five_raises_exception(self): 213 | """When passing a backfill value that isn't an integer raises an 214 | exception""" 215 | 216 | backfill_minutes = 12 217 | 218 | test_utils.generate_test_config_file() 219 | 220 | client = PowerTrackClient(_dummy_callback, config_file_path=config_file) 221 | 222 | self.assertRaises(AssertionError, client.connect, backfill_minutes) 223 | 224 | def test_append_backfill_to_url_appends_backfillMinutes_param(self): 225 | 226 | base_url = "http://www.twitter.com" 227 | backfill_minutes = 2 228 | expected_url = "http://www.twitter.com?backfillMinutes=2" 229 | 230 | returned_value = append_backfill_to_url(base_url, backfill_minutes) 231 | 232 | self.assertEqual(returned_value, expected_url) 233 | 234 | def test_append_backfill_to_url_replaces_existing_backfillMinutes_param( 235 | self): 236 | 237 | base_url = "http://www.twitter.com?backfillMinutes=5" 238 | backfill_minutes = 1 239 | expected_url = "http://www.twitter.com?backfillMinutes=1" 240 | 241 | returned_value = append_backfill_to_url(base_url, backfill_minutes) 242 | 243 | self.assertEqual(returned_value, expected_url) -------------------------------------------------------------------------------- /gnippy/test/test_rules.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | try: 4 | import unittest2 as unittest 5 | except ImportError: 6 | import unittest 7 | 8 | import mock 9 | 10 | from gnippy import rules 11 | from gnippy.errors import * 12 | from gnippy.test import test_utils 13 | 14 | 15 | # Mocks 16 | def bad_post(url, auth, data): 17 | return test_utils.BadResponse() 18 | 19 | 20 | def good_post(url, auth, data): 21 | return test_utils.GoodResponse() 22 | 23 | 24 | def bad_get(url, auth): 25 | return test_utils.BadResponse() 26 | 27 | 28 | def get_exception(url, auth): 29 | raise Exception("This is a test exception") 30 | 31 | 32 | def get_json_exception(url, auth): 33 | return test_utils.GoodResponseJsonError() 34 | 35 | 36 | def good_get_no_rules_field(url, auth): 37 | return test_utils.GoodResponse(json={"hello": "world"}) 38 | 39 | 40 | def good_get_no_rules(url, auth): 41 | return test_utils.GoodResponse(json={"rules": []}) 42 | 43 | 44 | def good_get_one_rule(url, auth): 45 | return test_utils.GoodResponse( 46 | json={"rules": [{"value": "Hello", "tag": "mytag"}]}) 47 | 48 | 49 | def bad_delete(url, auth, data): 50 | return test_utils.BadResponse() 51 | 52 | 53 | def good_delete(url, auth, data): 54 | return test_utils.GoodResponse() 55 | 56 | 57 | class RulesTestCase(unittest.TestCase): 58 | rule_string = "Hello OR World" 59 | tag = "my_tag" 60 | 61 | def _generate_rules_list(self): 62 | rules_list = [] 63 | rules_list.append(rules.build(self.rule_string)) 64 | rules_list.append(rules.build(self.rule_string, self.tag)) 65 | return rules_list 66 | 67 | def setUp(self): 68 | test_utils.generate_test_config_file() 69 | 70 | def tearDown(self): 71 | test_utils.delete_test_config() 72 | 73 | def test_build_post_object(self): 74 | """ Generate rules object to post. """ 75 | rules_list = self._generate_rules_list() 76 | post_obj = rules._generate_post_object(rules_list) 77 | self.assertTrue("rules" in post_obj) 78 | rules._check_rules_list(post_obj['rules']) 79 | 80 | def test_check_one_rule_ok(self): 81 | """ Check list of one rule. """ 82 | l = [{"value": "hello"}] 83 | rules._check_rules_list(l) 84 | 85 | def test_check_many_rules_ok(self): 86 | """ Check list of many rules. """ 87 | l = [ 88 | {"value": "hello", "id": 3}, 89 | {"value": "goodbye", "tag": "w", "id": 4}, 90 | {"value": "hi again", "tag": "x"}, 91 | {"value": "bye again"} 92 | ] 93 | rules._check_rules_list(l) 94 | 95 | def test_check_rule_tag_none(self): 96 | """ Check list of rules both with tag and without. """ 97 | l = [{"value": "hello", "tag": None}, {"value": "h", "tag": "w"}] 98 | rules._check_rules_list(l) 99 | 100 | def test_check_one_rule_typo_values(self): 101 | """ Make sure incorectly formatted rule values fail. """ 102 | l = [{"values": "hello"}] 103 | try: 104 | rules._check_rules_list(l) 105 | except RulesListFormatException: 106 | return 107 | self.fail( 108 | "_check_rules_list was supposed to throw a RuleFormatException") 109 | 110 | def test_check_one_rule_typo_tag(self): 111 | """ Make sure incorrectly formatted rule tags fail. """ 112 | l = [{"value": "hello", "tags": "t"}] 113 | try: 114 | rules._check_rules_list(l) 115 | except RulesListFormatException: 116 | return 117 | self.fail( 118 | "_check_rules_list was supposed to throw a RuleFormatException") 119 | 120 | def test_check_one_rule_extra_stuff_in_rule(self): 121 | """ Make sure rules with unexpected keys fail. """ 122 | l = [{"value": "hello", "wat": "man"}] 123 | try: 124 | rules._check_rules_list(l) 125 | except RulesListFormatException: 126 | return 127 | self.fail( 128 | "_check_rules_list was supposed to throw a RuleFormatException") 129 | 130 | def test_build_rule_bad_args(self): 131 | """ Make sure building rules with unexpected args fail. """ 132 | try: 133 | rules.build(None) 134 | except BadArgumentException: 135 | return 136 | self.fail( 137 | "rules.build_rule was supposed to throw a BadArgumentException") 138 | 139 | def test_build_rule_without_tag(self): 140 | """ Build rule without tag. """ 141 | r = rules.build(self.rule_string) 142 | self.assertEqual(r['value'], self.rule_string) 143 | self.assertFalse("tag" in r) 144 | rules._check_rules_list([r]) 145 | 146 | def test_build_rule_with_tag(self): 147 | """ Build rule with tag. """ 148 | r = rules.build(self.rule_string, tag=self.tag) 149 | self.assertEqual(r['value'], self.rule_string) 150 | self.assertEqual(r['tag'], self.tag) 151 | rules._check_rules_list([r]) 152 | 153 | @mock.patch('os.path.isfile', test_utils.os_file_exists_false) 154 | @mock.patch('requests.post', good_post) 155 | def test_add_one_rule_no_creds(self): 156 | """ Make sure adding rule without credentials fail. """ 157 | try: 158 | rules.add_rule(self.rule_string, self.tag) 159 | except ConfigFileNotFoundException: 160 | return 161 | self.fail( 162 | "Rule Add was supposed to fail and throw a ConfigFileNotFoundException") 163 | 164 | @mock.patch('requests.post', good_post) 165 | def test_add_one_rule_ok(self): 166 | """Add one rule with config. """ 167 | rules.add_rule(self.rule_string, self.tag, 168 | config_file_path=test_utils.test_config_path) 169 | 170 | @mock.patch('requests.post', bad_post) 171 | def test_add_one_rule_not_ok(self): 172 | """Add one rule with exception thrown. """ 173 | try: 174 | rules.add_rule(self.rule_string, self.tag, 175 | config_file_path=test_utils.test_config_path) 176 | except RuleAddFailedException: 177 | return 178 | self.fail("Rule Add was supposed to fail and throw a RuleAddException") 179 | 180 | @mock.patch('os.path.isfile', test_utils.os_file_exists_false) 181 | @mock.patch('requests.post', good_post) 182 | def test_add_many_rules_no_creds(self): 183 | """ Make sure adding rules with non-existent config fails. """ 184 | try: 185 | rules.add_rule(self.rule_string, self.tag) 186 | except ConfigFileNotFoundException: 187 | return 188 | self.fail( 189 | "Rule Add was supposed to fail and throw a ConfigFileNotFoundException") 190 | 191 | @mock.patch('requests.post', good_post) 192 | def test_add_many_rules_ok(self): 193 | """ Add many rules. """ 194 | rules_list = self._generate_rules_list() 195 | rules.add_rules(rules_list, 196 | config_file_path=test_utils.test_config_path) 197 | 198 | @mock.patch('requests.post', bad_post) 199 | def test_add_many_rules_not_ok(self): 200 | """ Add many rules with exception thrown. """ 201 | try: 202 | rules_list = self._generate_rules_list() 203 | rules.add_rules(rules_list, 204 | config_file_path=test_utils.test_config_path) 205 | except RuleAddFailedException: 206 | return 207 | self.fail("Rule Add was supposed to fail and throw a RuleAddException") 208 | 209 | @mock.patch('requests.get', get_exception) 210 | def test_get_rules_requests_get_exception(self): 211 | """ Get rules with exception thrown. """ 212 | try: 213 | r = rules.get_rules(config_file_path=test_utils.test_config_path) 214 | except RulesGetFailedException: 215 | return 216 | self.fail("rules.get() was supposed to throw a RulesGetFailedException") 217 | 218 | @mock.patch('requests.get', bad_get) 219 | def test_get_rules_bad_status_code(self): 220 | """ Get rules with error response. """ 221 | try: 222 | r = rules.get_rules(config_file_path=test_utils.test_config_path) 223 | except RulesGetFailedException as e: 224 | self.assertTrue("HTTP Status Code" in str(e)) 225 | return 226 | self.fail("rules.get() was supposed to throw a RulesGetFailedException") 227 | 228 | @mock.patch('requests.get', get_json_exception) 229 | def test_get_rules_bad_json(self): 230 | """ Get rules with bad json response. """ 231 | try: 232 | r = rules.get_rules(config_file_path=test_utils.test_config_path) 233 | except RulesGetFailedException as e: 234 | self.assertTrue("GNIP API returned malformed JSON" in str(e)) 235 | return 236 | self.fail("rules.get() was supposed to throw a RulesGetFailedException") 237 | 238 | @mock.patch('requests.get', good_get_no_rules_field) 239 | def test_get_rules_no_rules_field_json(self): 240 | """ Get rules with invalid response. """ 241 | try: 242 | r = rules.get_rules(config_file_path=test_utils.test_config_path) 243 | except RulesGetFailedException as e: 244 | self.assertTrue( 245 | "GNIP API response did not return a rules object" in str(e)) 246 | return 247 | self.fail("rules.get() was supposed to throw a RulesGetFailedException") 248 | 249 | @mock.patch('requests.get', good_get_no_rules) 250 | def test_get_rules_success_no_rules(self): 251 | """ Get rules with empty response. """ 252 | r = rules.get_rules(config_file_path=test_utils.test_config_path) 253 | self.assertEqual(0, len(r)) 254 | 255 | @mock.patch('requests.get', good_get_one_rule) 256 | def test_get_rules_success_one_rule(self): 257 | """ Get one rule. """ 258 | r = rules.get_rules(config_file_path=test_utils.test_config_path) 259 | self.assertEqual(1, len(r)) 260 | 261 | @mock.patch('requests.post', good_delete) 262 | def test_delete_rules_single(self): 263 | """ Delete one rule. """ 264 | rules.delete_rule({"value": "Hello World"}, 265 | config_file_path=test_utils.test_config_path) 266 | 267 | @mock.patch('requests.post', good_delete) 268 | def test_delete_rules_multiple(self): 269 | """ Delete multiple rules. """ 270 | rules_list = [ 271 | {"value": "Hello World"}, 272 | {"value": "Hello", "tag": "mytag"} 273 | ] 274 | rules.delete_rules(rules_list, 275 | config_file_path=test_utils.test_config_path) 276 | --------------------------------------------------------------------------------