Skip to content

Commit e1864bb

Browse files
committed
Add python sdk
1 parent 7fd3c24 commit e1864bb

15 files changed

+817
-0
lines changed

packages/python/.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.eggs/*
2+
dist/
3+
*.egg-info
4+
build/*

packages/python/LICENSE

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Copyright (c) 2020, ReadMe
2+
3+
Permission to use, copy, modify, and/or distribute this software for any purpose
4+
with or without fee is hereby granted, provided that the above copyright notice
5+
and this permission notice appear in all copies.
6+
7+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
8+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
9+
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
10+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
11+
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
12+
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
13+
THIS SOFTWARE.

packages/python/building.md

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Building: `python3 setup.py sdist bdist_wheel`
2+
Uploading to pypi: `python3 -m twine upload dist/*`

packages/python/metrics/Metrics.py

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import queue
2+
import threading
3+
import requests
4+
import json
5+
from pprint import pprint
6+
7+
from werkzeug import Request
8+
from pip._vendor.requests import __version__
9+
10+
from metrics import MetricsApiConfig, ResponseInfoWrapper
11+
from metrics.PayloadBuilder import PayloadBuilder
12+
13+
14+
class Metrics:
15+
"""
16+
This is the internal central controller class invoked by the WSGI middleware. It handles the creation, queueing,
17+
and submission of the requests.
18+
"""
19+
PACKAGE_NAME: str = 'readme/metrics'
20+
METRICS_API: str = 'https://metrics.readme.io'
21+
22+
def __init__(self, config: MetricsApiConfig):
23+
"""
24+
Constructs and initializes the ReadMe Metrics controller class with the specified configuration.
25+
:param config: Running configuration
26+
"""
27+
28+
self.config = config
29+
self.payload_builder = PayloadBuilder(config.BLACKLIST, config.WHITELIST, config.IS_DEVELOPMENT_MODE, config.GROUPING_FUNCTION)
30+
self.queue = queue.Queue()
31+
32+
def process(self, request: Request, response: ResponseInfoWrapper) -> None:
33+
"""
34+
Enqueues a request/response combination to be submitted to the ReadMe Metrics API.
35+
:param request: werkzeug.Request request object
36+
:param response: ResponseInfoWrapper response object
37+
"""
38+
self.queue.put(self.payload_builder(request, response))
39+
40+
if(self.queue.qsize() >= self.config.BUFFER_LENGTH):
41+
if(self.config.IS_BACKGROUND_MODE):
42+
threading.Thread(target=self._processAll, daemon=True).start()
43+
else:
44+
self._processAll()
45+
46+
def _processAll(self) -> None:
47+
result_list = []
48+
while not self.queue.empty():
49+
obj = self.queue.get_nowait()
50+
if obj:
51+
result_list.append(obj)
52+
53+
payload = json.dumps(result_list)
54+
55+
# print("Posting: " + payload)
56+
57+
readme_result = requests.post(self.METRICS_API + "/request", auth=(self.config.README_API_KEY, ""), data = payload, headers = {
58+
'Content-Type': 'application/json',
59+
'User-Agent': 'readme-metrics-' + __version__
60+
})
61+
62+
# print("Response: " + readme_result.text)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from typing import List
2+
3+
4+
class MetricsApiConfig:
5+
"""
6+
ReadMe Metrics API configuration object
7+
8+
...
9+
Attributes
10+
----------
11+
README_API_KEY: str
12+
(required) Your ReadMe API key
13+
GROUPING_FUNCTION = lambda
14+
(required)
15+
Grouping function to construct an identity object. It receives the current request as a parameter, and must
16+
return a dictionary containing at least an "id" field, and optionally "label" and "email" fields.
17+
18+
The main purpose of the identity object is to identify the API's caller.
19+
BUFFER_LENGTH: int
20+
(optional, default = 10)
21+
Number of requests to buffer before sending data to ReadMe.
22+
IS_DEVELOPMENT_MODE: bool
23+
(optional, default = False) Determines whether you are running in development mode.
24+
IS_BACKGROUND_MODE: bool
25+
(optional, default = True) Determines whether to issue the call to the ReadMe API in a background thread.
26+
BLACKLIST: List[str]
27+
(optional) An array of headers and JSON body properties to skip sending to ReadMe.
28+
29+
If you configure a blacklist, it will override any whitelist configuration.
30+
WHITELIST: List[str]
31+
(optional) An array of headers and JSON body properties to send to ReadMe.
32+
33+
If this option is configured, ONLY the whitelisted properties will be sent.
34+
"""
35+
README_API_KEY: str = None
36+
BUFFER_LENGTH: int = 10
37+
GROUPING_FUNCTION = lambda req: None
38+
IS_DEVELOPMENT_MODE: bool = False
39+
IS_BACKGROUND_MODE: bool = True
40+
BLACKLIST: List[str] = []
41+
WHITELIST: List[str] = []
42+
43+
def __init__(self,
44+
api_key: str,
45+
grouping_function,
46+
buffer_length:int = 10,
47+
development_mode:bool = False,
48+
background_worker_mode:bool = True,
49+
blacklist:List[str] = None,
50+
whitelist:List[str] = None):
51+
"""
52+
Initializes an instance of the MetricsApiConfig object, with defaults set where possible.
53+
:param api_key: (required) Your ReadMe API key
54+
:param grouping_function: (required)
55+
Grouping function to construct an identity object. It receives the current request as a parameter, and must
56+
return a dictionary containing at least an "id" field, and optionally "label" and "email" fields.
57+
58+
The main purpose of the identity object is to identify the API's caller.
59+
:param buffer_length: (optional, default = 10) Number of requests to buffer before sending data to ReadMe.
60+
:param development_mode: (optional, default = False) Determines whether you are running in development mode.
61+
:param background_worker_mode: (optional, default = True)
62+
Determines whether to issue the call to the ReadMe API in a background thread.
63+
:param blacklist: (optional)
64+
An array of headers and JSON body properties to skip sending to ReadMe.
65+
66+
If you configure a blacklist, it will override any whitelist configuration.
67+
:param whitelist: (optional)
68+
An array of headers and JSON body properties to send to ReadMe.
69+
70+
If this option is configured, ONLY the whitelisted properties will be sent.
71+
"""
72+
73+
self.README_API_KEY = api_key
74+
self.GROUPING_FUNCTION = grouping_function
75+
self.BUFFER_LENGTH = buffer_length
76+
self.IS_DEVELOPMENT_MODE = development_mode
77+
self.IS_BACKGROUND_MODE = background_worker_mode
78+
self.BLACKLIST = blacklist or []
79+
self.WHITELIST = whitelist or []
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from metrics.Metrics import Metrics
2+
from metrics.MetricsApiConfig import MetricsApiConfig
3+
from metrics.ResponseInfoWrapper import ResponseInfoWrapper
4+
from werkzeug import Request, Response
5+
import io
6+
import time
7+
import datetime
8+
9+
class MetricsMiddleware:
10+
"""
11+
Core middleware class for ReadMe Metrics
12+
13+
...
14+
Attributes
15+
----------
16+
config : MetricsApiConfig
17+
contains the configuration settings for the running middleware instance
18+
"""
19+
def __init__(
20+
self,
21+
wsgi_app_reference,
22+
config: MetricsApiConfig):
23+
"""
24+
Constructs and initializes MetricsMiddleware WSGI middleware to be passed into the
25+
currently running WSGI web server.
26+
27+
:param wsgi_app_reference: Reference to the current WSGI application, which will be wrapped
28+
:param config: Instance of MetricsApiConfig object
29+
"""
30+
31+
self.config = config
32+
self.app = wsgi_app_reference
33+
self.metrics_core = Metrics(config)
34+
35+
def __call__(self, environ, start_response):
36+
"""
37+
Method that is called by the running WSGI server.
38+
You should NOT be calling this method yourself under normal circumstances.
39+
"""
40+
response_headers = {}
41+
response_status = 0
42+
iterable = None
43+
req = Request(environ)
44+
45+
def _start_response(_status, _response_headers, *args):
46+
write = start_response(_status, _response_headers, *args)
47+
48+
# Populate response info (headers & status)
49+
nonlocal response_headers, response_status
50+
51+
response_headers = _response_headers
52+
response_status = _status
53+
54+
return write
55+
56+
try:
57+
58+
req.rm_start_dt = str(datetime.datetime.now())
59+
req.rm_start_ts = int(time.time() * 1000)
60+
61+
if req.method == 'POST':
62+
63+
# The next 4 lines are a workaround for a serious shortcoming in the WSGI spec.
64+
# The data can only be read once, after which the socket is exhausted and cannot be read again.
65+
# As such, we read the data and then repopulate the variable so that it can be used by other
66+
# code down the pipeline.
67+
# For more info: https://stackoverflow.com/a/13106009/643951
68+
69+
content_length = int(environ['CONTENT_LENGTH'])
70+
content_body = environ['wsgi.input'].read(content_length)
71+
72+
environ['wsgi.input'].close()
73+
environ['wsgi.input'] = io.BytesIO(content_body)
74+
75+
req.rm_content_length = content_length
76+
req.rm_body = content_body
77+
78+
79+
iterable = self.app(environ, _start_response)
80+
81+
82+
for data in iterable:
83+
res_ctype = ''
84+
res_clength = 0
85+
86+
htype = next((h for h in response_headers if h[0] == 'Content-Type'), None)
87+
hlength = next((h for h in response_headers if h[0] == 'Content-Length'), None)
88+
89+
if htype and hlength:
90+
res_ctype = htype[1]
91+
res_clength = int(hlength[1])
92+
93+
# Populate response body
94+
res = ResponseInfoWrapper(response_headers, response_status, res_ctype, res_clength, data.decode('utf-8'))
95+
96+
# Send off data to be queued (and processed) by ReadMe
97+
self.metrics_core.process(req, res)
98+
99+
yield data
100+
101+
finally:
102+
# Undocumented in WSGI spec but the iterable has to be closed
103+
if hasattr(iterable, 'close'):
104+
iterable.close()

0 commit comments

Comments
 (0)