Skip to content
This repository was archived by the owner on Jan 26, 2023. It is now read-only.

Commit f0552f0

Browse files
first draft of Python Gitcoin API client
1 parent 3737624 commit f0552f0

File tree

10 files changed

+332
-7
lines changed

10 files changed

+332
-7
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ __pycache__/
99

1010
# Distribution / packaging
1111
.Python
12+
bin/
1213
build/
1314
develop-eggs/
1415
dist/
@@ -25,6 +26,8 @@ wheels/
2526
.installed.cfg
2627
*.egg
2728
MANIFEST
29+
pip-selfcheck.json
30+
pyvenv.cfg
2831

2932
# PyInstaller
3033
# Usually these files are written by a python script from a template

Makefile

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ test: ## Run pytest.
1919
@python setup.py test
2020

2121
fix-isort: ## Run isort against python files in the project directory.
22-
@isort -rc --atomic .
22+
@isort -rc --atomic ./gitcoin ./tests
2323

2424
fix-yapf: ## Run yapf against any included or newly introduced Python code.
25-
@yapf -i -r -p .
25+
@yapf -i -r -p ./gitcoin ./tests
2626

2727
fix: fix-isort fix-yapf ## Attempt to run all fixes against the project directory.
2828

README.md

+5-5
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ api.bounties.filter(pk__gt=100).all()
2727

2828
## Todo
2929

30-
- [ ] Add base gitcoin.Gitcoin client
31-
- [ ] Add `bounties` api filter
32-
- [ ] Implement all filter fields present in `gitcoinco/web/app/dashboard/router.py`
30+
- [x] Add base gitcoin.Gitcoin client
31+
- [x] Add `bounties` api filter
32+
- [x] Implement all filter fields present in `gitcoinco/web/app/dashboard/router.py`
3333
- [ ] Add `universe` api filter
3434
- [ ] Implement all filter fields present in `gitcoinco/web/app/external_bounties/router.py`
35-
- [ ] Add sorting/order_by
36-
- [ ] Add pagination (page/limit)
35+
- [x] Add sorting/order_by
36+
- [x] Add pagination (page/limit)
3737
- [ ] Add travis-ci.com project and twine/pypi credentials.
3838
- [ ] Add codecov.io project.
3939
- [ ] Cut first release (Tag github release, push changes, and let CI deploy to pypi)

gitcoin/__init__.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from gitcoin.client import Config
2+
from gitcoin.client import BountyConfig
3+
from gitcoin.client import Endpoint
4+
from gitcoin.client import Gitcoin
5+
6+
__all__ = [
7+
'Config', 'BountyConfig', 'Endpoint', 'Gitcoin',
8+
]

gitcoin/client.py

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import requests
2+
3+
class Config:
4+
"""Define Base Class for API Endpoint Config."""
5+
params = {}
6+
7+
def has(self, name):
8+
"""Tell if a setting for 'name' was defined."""
9+
return name in self.params
10+
11+
def get(self, name):
12+
"""Get the setting for 'name'."""
13+
if self.has(name):
14+
return self.params[name]
15+
else:
16+
msg = 'Unknown config "{name}"'
17+
raise KeyError(msg.format(name=name))
18+
19+
20+
class BountyConfig(Config):
21+
"""Define 'bounties' API Endpoint Config."""
22+
params = {
23+
'raw_data': (True, str),
24+
'experience_level': (True, str),
25+
'project_length': (True, str),
26+
'bounty_type': (True, str),
27+
'bounty_owner_address': (True, str),
28+
'idx_status': (True, str),
29+
'network': (True, str),
30+
'bounty_owner_github_username': (True, str),
31+
'standard_bounties_id': (True, str),
32+
'pk__gt': (False, int),
33+
'started': (False, str),
34+
'is_open': (False, bool),
35+
'github_url': (True, str),
36+
'fulfiller_github_username': (False, str),
37+
'interested_github_username': (False, str),
38+
'order_by': (False, str),
39+
'limit': (False, int),
40+
'offset': (False, int)
41+
}
42+
43+
44+
class Endpoint:
45+
"""Wrap one Gitcoin API end point."""
46+
47+
def __init__(self, url, config):
48+
"""Inject URL and Config, default to no query parameters."""
49+
self.url = url
50+
self.config = config
51+
self.params = {}
52+
53+
def add_param(self, name, value):
54+
"""Add query parameter with safeguards."""
55+
if self.config.has(name):
56+
is_multiple, normalize = self.config.get(name)
57+
if not is_multiple:
58+
self.del_param(name) # Throw away all previous values, if any.
59+
if callable(normalize):
60+
value = normalize(value)
61+
self.add_param_unchecked(name, value)
62+
return self
63+
else:
64+
msg = 'Tried to filter by unknown param "{name}".'
65+
raise KeyError(msg.format(name=name))
66+
67+
def del_param(self, name):
68+
"""Delete query parameter."""
69+
if name in self.params:
70+
del self.params[name]
71+
return self
72+
73+
def add_param_unchecked(self, name, value):
74+
"""Add query parameter without safeguards.
75+
76+
This is available in case this API client is out-of-sync with the API.
77+
"""
78+
if name not in self.params:
79+
self.params[name] = []
80+
self.params[name].append(str(value))
81+
return self
82+
83+
def filter(self, **kwargs):
84+
"""Filter the result set."""
85+
for name, value in kwargs.items():
86+
self.add_param(name, value)
87+
return self
88+
89+
def order_by(self, sort):
90+
"""Sort the result set."""
91+
self.add_param('order_by', sort)
92+
return self
93+
94+
def get_page(self, number=1, per_page=25):
95+
"""Get a page of the result set."""
96+
self.add_param('limit', per_page)
97+
self.add_param('offset', (number - 1) * per_page)
98+
return self._request_get()
99+
100+
def all(self):
101+
"""Get the complete result set."""
102+
self.del_param('limit')
103+
self.del_param('offset')
104+
return self._request_get()
105+
106+
def get(self, pk):
107+
"""Get 1 object by primary key."""
108+
return self._request_get('/'.join((self.url, str(pk))))
109+
110+
def _request_get(self, url=None):
111+
"""Fire the actual HTTP GET request as configured."""
112+
url = url if url else self.url
113+
params = self._prep_get_params()
114+
response = requests.get(url, params=params)
115+
response.raise_for_status() # Let API consumer know about HTTP errors.
116+
return response.json()
117+
118+
def _prep_get_params(self):
119+
"""Send multi-value fields separated by comma."""
120+
return {name: ','.join(value) for name, value in self.params.items()}
121+
122+
123+
class Gitcoin:
124+
"""Provide main API entry point."""
125+
126+
def __init__(self):
127+
"""Set defaults."""
128+
self.classes = {}
129+
self.set_class('endpoint', Endpoint)
130+
self.set_class('bounties_list_config', BountyConfig)
131+
self.urls = {}
132+
self.set_url('bounties', 'https://gitcoin.co/api/v0.1/bounties')
133+
134+
def set_class(self, id, cls):
135+
"""Inject class dependency, overriding the default class."""
136+
self.classes[id] = cls
137+
138+
def set_url(self, id, url):
139+
"""Configure API URL, overriding the default URL."""
140+
self.urls[id] = url
141+
142+
@property
143+
def bounties(self):
144+
"""Wrap the 'bounties' API endpoint."""
145+
url = self.urls['bounties']
146+
endpointClass = self.classes['endpoint']
147+
configClass = self.classes['bounties_list_config']
148+
return endpointClass(url, configClass())

requirements.txt

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
apipkg==1.4
2+
attrs==18.1.0
3+
certifi==2018.4.16
4+
chardet==3.0.4
5+
coverage==4.5.1
6+
execnet==1.5.0
7+
flake8==3.5.0
8+
idna==2.6
9+
isort==4.3.4
10+
mccabe==0.6.1
11+
more-itertools==4.1.0
12+
pluggy==0.6.0
13+
py==1.5.3
14+
pycodestyle==2.3.1
15+
pyflakes==1.6.0
16+
pytest==3.5.1
17+
pytest-cache==1.0
18+
pytest-cov==2.5.1
19+
pytest-isort==0.2.0
20+
pytest-runner==4.2
21+
requests==2.18.4
22+
six==1.11.0
23+
urllib3==1.22
24+
yapf==0.22.0

tests/__init__.py

Whitespace-only changes.

tests/mocks/__init__.py

Whitespace-only changes.

tests/mocks/requests.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
2+
3+
class MockResponse:
4+
def __init__(self, url, params, kwargs):
5+
self.url = url
6+
self.params = params
7+
self.kwargs = kwargs
8+
9+
def json(self):
10+
return {
11+
'url': self.url,
12+
'params': self.params,
13+
'kwargs': self.kwargs,
14+
}
15+
16+
def raise_for_status(self):
17+
if 'raise_for_status' in self.params:
18+
if self.params['raise_for_status']:
19+
raise ValueError('Mock HTTP Error')

tests/test_dry_run.py

+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import pytest
2+
import requests
3+
from gitcoin import BountyConfig, Gitcoin
4+
from tests.mocks.requests import MockResponse
5+
6+
7+
def mock_requests_get(url, params=None, **kwargs):
8+
return MockResponse(url, params, kwargs)
9+
10+
11+
class TestGitcoinDryRun():
12+
13+
@pytest.fixture(autouse=True)
14+
def no_requests(self, monkeypatch):
15+
monkeypatch.setattr(requests, 'get', mock_requests_get)
16+
17+
def test_cfg_raises_on_unknown_param(self):
18+
cfg = BountyConfig()
19+
with pytest.raises(KeyError):
20+
cfg.get('does_not_exist')
21+
22+
def test_api_raises_on_unknown_param(self):
23+
api = Gitcoin()
24+
with pytest.raises(KeyError):
25+
api.bounties.filter(does_not_exist=True)
26+
27+
def test_all(self):
28+
api = Gitcoin()
29+
expected = {
30+
'url': 'https://gitcoin.co/api/v0.1/bounties',
31+
'params': {},
32+
'kwargs': {},
33+
}
34+
result = api.bounties.all()
35+
assert expected == result
36+
37+
def test_filter_pk__gt(self):
38+
api = Gitcoin()
39+
expected = {
40+
'url': 'https://gitcoin.co/api/v0.1/bounties',
41+
'params': {
42+
'pk__gt': '100'
43+
},
44+
'kwargs': {},
45+
}
46+
result = api.bounties.filter(pk__gt=100).all()
47+
assert expected == result
48+
49+
def test_filter_2x_bounty_type_paged(self):
50+
api = Gitcoin()
51+
expected = {
52+
'url': 'https://gitcoin.co/api/v0.1/bounties',
53+
'params': {
54+
'bounty_type': 'Feature,Bug',
55+
'offset': '0',
56+
'limit': '25',
57+
},
58+
'kwargs': {},
59+
}
60+
result = api.bounties.filter(bounty_type='Feature').filter(bounty_type='Bug').get_page()
61+
assert expected == result
62+
63+
def test_del_param(self):
64+
api = Gitcoin()
65+
expected = {
66+
'url': 'https://gitcoin.co/api/v0.1/bounties',
67+
'params': {
68+
'bounty_type': 'Bug',
69+
'offset': '0',
70+
'limit': '25',
71+
},
72+
'kwargs': {},
73+
}
74+
result = api.bounties.filter(bounty_type='Feature').del_param('bounty_type').filter(bounty_type='Bug').get_page()
75+
assert expected == result
76+
77+
def test_order_by(self):
78+
api = Gitcoin()
79+
expected = {
80+
'url': 'https://gitcoin.co/api/v0.1/bounties',
81+
'params': {
82+
'order_by': '-project_length',
83+
'offset': '0',
84+
'limit': '25',
85+
},
86+
'kwargs': {},
87+
}
88+
result = api.bounties.order_by('-project_length').get_page()
89+
assert expected == result
90+
91+
def test_get(self):
92+
api = Gitcoin()
93+
expected = {
94+
'url': 'https://gitcoin.co/api/v0.1/bounties/123',
95+
'params': {},
96+
'kwargs': {},
97+
}
98+
result = api.bounties.get(123)
99+
assert expected == result
100+
101+
def test_no_normalize(self):
102+
class ExtendedBountyConfig(BountyConfig):
103+
def __init__(self):
104+
self.params['no_normalize'] = (True, None)
105+
106+
api = Gitcoin()
107+
api.set_class('bounties_list_config', ExtendedBountyConfig)
108+
expected = {
109+
'url': 'https://gitcoin.co/api/v0.1/bounties',
110+
'params': {
111+
'no_normalize': 'not_normal',
112+
'offset': '0',
113+
'limit': '25',
114+
},
115+
'kwargs': {},
116+
}
117+
result = api.bounties.filter(no_normalize='not_normal').get_page()
118+
assert expected == result
119+
120+
def test_raise_for_status(self):
121+
api = Gitcoin()
122+
with pytest.raises(ValueError): # ValueError only in mock setup
123+
result = api.bounties.add_param_unchecked('raise_for_status', True).all()

0 commit comments

Comments
 (0)