Compare commits

...

5 commits

Author SHA1 Message Date
18f7fa6c51 bump: version 0.1.0 → 0.2.0 2025-08-10 16:01:45 -05:00
ec8fe97cff fix: add dependency for requests 2025-08-10 15:20:00 -05:00
0f42626cb1 feat: add native API client for common submission use case
this should work for simple API endpoint which requires an auth token.
if more is needed, you'll have to override telemetry handler
2025-08-10 15:13:20 -05:00
e0dd704247 fix: remove submit_uuid from telemetry config profile
the `submit_url` should be all we expect by default, since even that
has no built-in behavior yet
2025-08-10 11:57:28 -05:00
7b6dbac1fa fix: tweak log verbiage 2025-08-09 13:11:26 -05:00
11 changed files with 455 additions and 23 deletions

View file

@ -5,6 +5,18 @@ All notable changes to WuttaTell will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.2.0 (2025-08-10)
### Feat
- add native API client for common submission use case
### Fix
- add dependency for `requests`
- remove `submit_uuid` from telemetry config profile
- tweak log verbiage
## v0.1.0 (2025-08-09)
### Feat

View file

@ -0,0 +1,6 @@
``wuttatell.client``
====================
.. automodule:: wuttatell.client
:members:

View file

@ -28,6 +28,7 @@ templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
intersphinx_mapping = {
'requests': ('https://requests.readthedocs.io/en/latest/', None),
'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
}

View file

@ -25,4 +25,5 @@ project.
api/wuttatell.app
api/wuttatell.cli
api/wuttatell.cli.tell
api/wuttatell.client
api/wuttatell.telemetry

View file

@ -12,15 +12,13 @@ Install the WuttaTell package to your virtual environment:
pip install WuttaTell
Edit your :term:`config file` to add telemetry submission info, and
related settings. Please note, the following example is just that -
and will not work as-is:
related settings:
.. code-block:: ini
[wutta.telemetry]
default.collect_keys = os, python
default.submit_url = /nodes/telemetry
default.submit_uuid = 06897767-eb70-7790-8000-13f368a40ea3
default.submit_url = https://example.com/api/my-node/telemetry
.. note::

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaTell"
version = "0.1.0"
version = "0.2.0"
description = "Telemetry submission for Wutta Framework"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
@ -26,7 +26,8 @@ classifiers = [
]
requires-python = ">= 3.8"
dependencies = [
"WuttJamaican",
"requests",
"WuttJamaican>=0.23.0",
]

View file

@ -58,7 +58,7 @@ def tell(
telemetry = app.get_telemetry_handler()
data = telemetry.collect_all_data(profile=profile)
log.info("data collected okay: %s", ', '.join(sorted(data)))
log.info("data collected for: %s", ', '.join(sorted(data)))
log.debug("%s", data)
if dry_run:

197
src/wuttatell/client.py Normal file
View file

@ -0,0 +1,197 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaTell -- Telemetry submission for Wutta Framework
# Copyright © 2025 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Simple API Client
"""
import json
from urllib.parse import urlparse
import requests
class SimpleAPIClient:
"""
Simple client for "typical" API service.
This basically assumes telemetry can be submitted to a single API
endpoint, and the request should contain an auth token.
:param config: App :term:`config object`.
:param base_url: Base URL of the API.
:param token: Auth token for the API.
:param ssl_verify: Whether the SSL cert presented by the server
should be verified. This is effectively true by default, but
may be disabled for testing with self-signed certs etc.
:param max_retries: Maximum number of retries each connection
should attempt. This value is ultimately given to the
:class:`~requests:requests.adapters.HTTPAdapter` instance.
Most params may be omitted, if config specifies instead:
.. code-block:: ini
[wutta.api]
base_url = https://my.example.com/api
token = XYZPDQ12345
ssl_verify = false
max_retries = 5
Upon instantiation, :attr:`session` will be ``None`` until the
first request is made. (Technically when :meth:`init_session()`
first happens.)
.. attribute:: session
:class:`requests:requests.Session` instance being used to make
API requests.
"""
def __init__(self, config, base_url=None, token=None, ssl_verify=None, max_retries=None):
self.config = config
self.base_url = base_url or self.config.require(f'{self.config.appname}.api.base_url')
self.base_url = self.base_url.rstrip('/')
self.token = token or self.config.require(f'{self.config.appname}.api.token')
if max_retries is not None:
self.max_retries = max_retries
else:
self.max_retries = self.config.get_int(f'{self.config.appname}.api.max_retries')
if ssl_verify is not None:
self.ssl_verify = ssl_verify
else:
self.ssl_verify = self.config.get_bool(f'{self.config.appname}.api.ssl_verify',
default=True)
self.session = None
def init_session(self):
"""
Initialize the HTTP session with the API.
This method is invoked as part of :meth:`make_request()`.
It first checks :attr:`session` and will skip if already initialized.
For initialization, it establishes a new
:class:`requests:requests.Session` instance, and modifies it
as needed per config.
"""
if self.session:
return
self.session = requests.Session()
# maybe *disable* SSL cert verification
# (should only be used for testing e.g. w/ self-signed certs)
if not self.ssl_verify:
self.session.verify = False
# maybe set max retries, e.g. for flaky connections
if self.max_retries is not None:
adapter = requests.adapters.HTTPAdapter(max_retries=self.max_retries)
self.session.mount(self.base_url, adapter)
# TODO: is this a good idea, or hacky security risk..?
# without it, can get error response:
# 400 Client Error: Bad CSRF Origin for url
parts = urlparse(self.base_url)
self.session.headers.update({
'Origin': f'{parts.scheme}://{parts.netloc}',
})
# authenticate via token only (for now?)
self.session.headers.update({
'Authorization': f'Bearer {self.token}',
})
def make_request(self, request_method, api_method, params=None, data=None):
"""
Make a request to the API, and return the response.
This first calls :meth:`init_session()` to establish the
session if needed.
:param request_method: HTTP request method; for now only
``'GET'`` and ``'POST'`` are supported.
:param api_method: API method endpoint to use,
e.g. ``'/my/telemetry'``
:param params: Dict of query string params for the request, if
applicable.
:param data: Payload data for the request, if applicable.
Should be JSON-serializable, e.g. a list or dict.
:rtype: :class:`requests:requests.Response` instance.
"""
self.init_session()
api_method = api_method.lstrip('/')
url = f'{self.base_url}/{api_method}'
if request_method == 'GET':
response = self.session.get(url, params=params)
elif request_method == 'POST':
response = self.session.post(url, params=params,
data=json.dumps(data))
else:
raise NotImplementedError(f"unsupported request method: {request_method}")
response.raise_for_status()
return response
def get(self, api_method, params=None):
"""
Perform a GET request for the given API method, and return the
response.
This calls :meth:`make_request()` for the heavy lifting.
:param api_method: API method endpoint to use,
e.g. ``'/my/telemetry'``
:param params: Dict of query string params for the request, if
applicable.
:rtype: :class:`requests:requests.Response` instance.
"""
return self.make_request('GET', api_method, params=params)
def post(self, api_method, **kwargs):
"""
Perform a POST request for the given API method, and return
the response.
This calls :meth:`make_request()` for the heavy lifting.
:param api_method: API method endpoint to use,
e.g. ``'/my/telemetry'``
:rtype: :class:`requests:requests.Response` instance.
"""
return self.make_request('POST', api_method, **kwargs)

View file

@ -31,6 +31,8 @@ import subprocess
from wuttjamaican.app import GenericHandler
from wuttjamaican.conf import WuttaConfigProfile
from wuttatell.client import SimpleAPIClient
class TelemetryHandler(GenericHandler):
"""
@ -220,14 +222,21 @@ class TelemetryHandler(GenericHandler):
"""
Submit telemetry data to the configured collection service.
Default logic is not implemented; subclass must override.
Default logic will use
:class:`~wuttatell.client.SimpleAPIClient` and submit all
collected data to the configured API endpoint.
:param profile: :class:`TelemetryProfile` instance.
:param data: Data dict as obtained by
:meth:`collect_all_data()`.
"""
raise NotImplementedError
profile = self.get_profile(profile)
if data is None:
data = self.collect_all_data(profile)
client = SimpleAPIClient(self.config)
client.post(profile.submit_url, data=data)
class TelemetryProfile(WuttaConfigProfile):
@ -251,13 +260,6 @@ class TelemetryProfile(WuttaConfigProfile):
.. attribute:: submit_url
URL to which collected telemetry data should be submitted.
.. attribute:: submit_uuid
UUID identifying the record to update when submitting telemetry
data. This value will only make sense in the context of the
collection service responsible for receiving telemetry
submissions.
"""
@property
@ -270,4 +272,3 @@ class TelemetryProfile(WuttaConfigProfile):
keys = self.get_str('collect.keys', default='os,python')
self.collect_keys = self.config.parse_list(keys)
self.submit_url = self.get_str('submit.url')
self.submit_uuid = self.get_str('submit.uuid')

203
tests/test_client.py Normal file
View file

@ -0,0 +1,203 @@
# -*- coding: utf-8; -*-
import json
import threading
import time
from http import HTTPStatus
from http.server import HTTPServer, BaseHTTPRequestHandler
import requests
from urllib3.util.retry import Retry
from wuttjamaican.testing import ConfigTestCase
from wuttatell import client as mod
class TestSimpleAPIClient(ConfigTestCase):
def make_client(self, **kw):
return mod.SimpleAPIClient(self.config, **kw)
def test_constructor(self):
# caller specifies params
client = self.make_client(base_url='https://example.com/api/',
token='XYZPDQ12345',
ssl_verify=False,
max_retries=5)
self.assertEqual(client.base_url, 'https://example.com/api') # no trailing slash
self.assertEqual(client.token, 'XYZPDQ12345')
self.assertFalse(client.ssl_verify)
self.assertEqual(client.max_retries, 5)
self.assertIsNone(client.session)
# now with some defaults
client = self.make_client(base_url='https://example.com/api/',
token='XYZPDQ12345')
self.assertEqual(client.base_url, 'https://example.com/api') # no trailing slash
self.assertEqual(client.token, 'XYZPDQ12345')
self.assertTrue(client.ssl_verify)
self.assertIsNone(client.max_retries)
self.assertIsNone(client.session)
# now from config
self.config.setdefault('wutta.api.base_url', 'https://another.com/api/')
self.config.setdefault('wutta.api.token', '9843243q4')
self.config.setdefault('wutta.api.ssl_verify', 'false')
self.config.setdefault('wutta.api.max_retries', '4')
client = self.make_client()
self.assertEqual(client.base_url, 'https://another.com/api') # no trailing slash
self.assertEqual(client.token, '9843243q4')
self.assertFalse(client.ssl_verify)
self.assertEqual(client.max_retries, 4)
self.assertIsNone(client.session)
def test_init_session(self):
# client begins with no session
client = self.make_client(base_url='https://example.com/api', token='1234')
self.assertIsNone(client.session)
# session is created here
client.init_session()
self.assertIsInstance(client.session, requests.Session)
self.assertTrue(client.session.verify)
self.assertTrue(all([a.max_retries.total == 0 for a in client.session.adapters.values()]))
self.assertIn('Authorization', client.session.headers)
self.assertEqual(client.session.headers['Authorization'], 'Bearer 1234')
# session is never re-created
orig_session = client.session
client.init_session()
self.assertIs(client.session, orig_session)
# new client/session with no ssl_verify
client = self.make_client(base_url='https://example.com/api', token='1234', ssl_verify=False)
client.init_session()
self.assertFalse(client.session.verify)
# new client/session with max_retries
client = self.make_client(base_url='https://example.com/api', token='1234', max_retries=5)
client.init_session()
self.assertEqual(client.session.adapters['https://example.com/api'].max_retries.total, 5)
def test_make_request_get(self):
# start server
threading.Thread(target=start_server).start()
while not SERVER['running']:
time.sleep(0.02)
# server returns our headers
client = self.make_client(base_url=f'http://127.0.0.1:{SERVER["port"]}', token='1234', ssl_verify=False)
response = client.make_request('GET', '/telemetry')
result = response.json()
self.assertIn('headers', result)
self.assertIn('Authorization', result['headers'])
self.assertEqual(result['headers']['Authorization'], 'Bearer 1234')
self.assertNotIn('payload', result)
def test_make_request_post(self):
# start server
threading.Thread(target=start_server).start()
while not SERVER['running']:
time.sleep(0.02)
# server returns our headers + payload
client = self.make_client(base_url=f'http://127.0.0.1:{SERVER["port"]}', token='1234', ssl_verify=False)
response = client.make_request('POST', '/telemetry', data={'os': {'name': 'debian'}})
result = response.json()
self.assertIn('headers', result)
self.assertIn('Authorization', result['headers'])
self.assertEqual(result['headers']['Authorization'], 'Bearer 1234')
self.assertIn('payload', result)
self.assertEqual(json.loads(result['payload']), {'os': {'name': 'debian'}})
def test_make_request_unsupported(self):
# start server
threading.Thread(target=start_server).start()
while not SERVER['running']:
time.sleep(0.02)
# e.g. DELETE is not implemented
client = self.make_client(base_url=f'http://127.0.0.1:{SERVER["port"]}', token='1234', ssl_verify=False)
self.assertRaises(NotImplementedError, client.make_request, 'DELETE', '/telemetry')
# nb. issue valid request to stop the server
client.make_request('GET', '/telemetry')
def test_get(self):
# start server
threading.Thread(target=start_server).start()
while not SERVER['running']:
time.sleep(0.02)
# server returns our headers
client = self.make_client(base_url=f'http://127.0.0.1:{SERVER["port"]}', token='1234', ssl_verify=False)
response = client.get('/telemetry')
result = response.json()
self.assertIn('headers', result)
self.assertIn('Authorization', result['headers'])
self.assertEqual(result['headers']['Authorization'], 'Bearer 1234')
self.assertNotIn('payload', result)
def test_post(self):
# start server
threading.Thread(target=start_server).start()
while not SERVER['running']:
time.sleep(0.02)
# server returns our headers + payload
client = self.make_client(base_url=f'http://127.0.0.1:{SERVER["port"]}', token='1234', ssl_verify=False)
response = client.post('/telemetry', data={'os': {'name': 'debian'}})
result = response.json()
self.assertIn('headers', result)
self.assertIn('Authorization', result['headers'])
self.assertEqual(result['headers']['Authorization'], 'Bearer 1234')
self.assertIn('payload', result)
self.assertEqual(json.loads(result['payload']), {'os': {'name': 'debian'}})
class FakeRequestHandler(BaseHTTPRequestHandler):
""" """
def do_GET(self):
headers = dict([(k, v) for k, v in self.headers.items()])
result = {'headers': headers}
result = json.dumps(result).encode('utf_8')
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", 'text/json')
self.send_header("Content-Length", str(len(result)))
self.end_headers()
self.wfile.write(result)
def do_POST(self):
headers = dict([(k, v) for k, v in self.headers.items()])
length = int(self.headers.get('Content-Length'))
payload = self.rfile.read(length).decode('utf_8')
result = {'headers': headers, 'payload': payload}
result = json.dumps(result).encode('utf_8')
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", 'text/json')
self.send_header("Content-Length", str(len(result)))
self.end_headers()
self.wfile.write(result)
SERVER = {'running': False, 'port': 7314}
def start_server():
if SERVER['running']:
raise RuntimeError("http server is already running")
with HTTPServer(('127.0.0.1', SERVER['port']), FakeRequestHandler) as httpd:
SERVER['running'] = True
httpd.handle_request()
SERVER['running'] = False

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
from unittest.mock import patch, MagicMock
from wuttjamaican.testing import ConfigTestCase
@ -160,9 +160,24 @@ class TestTelemetryHandler(ConfigTestCase):
self.assertNotIn('errors', data)
def test_submit_all_data(self):
profile = self.handler.get_profile('default')
profile.submit_url = '/testing'
# not (yet?) implemented
self.assertRaises(NotImplementedError, self.handler.submit_all_data)
with patch.object(mod, 'SimpleAPIClient') as SimpleAPIClient:
client = MagicMock()
SimpleAPIClient.return_value = client
# collecting all data
with patch.object(self.handler, 'collect_all_data') as collect_all_data:
collect_all_data.return_value = []
self.handler.submit_all_data(profile)
collect_all_data.assert_called_once_with(profile)
client.post.assert_called_once_with('/testing', data=[])
# use data from caller
client.post.reset_mock()
self.handler.submit_all_data(profile, data=['foo'])
client.post.assert_called_once_with('/testing', data=['foo'])
class TestTelemetryProfile(ConfigTestCase):
@ -187,13 +202,10 @@ class TestTelemetryProfile(ConfigTestCase):
profile = self.make_profile()
self.assertEqual(profile.collect_keys, ['os', 'python'])
self.assertIsNone(profile.submit_url)
self.assertIsNone(profile.submit_uuid)
# configured
self.config.setdefault('wutta.telemetry.default.collect.keys', 'os,network,python')
self.config.setdefault('wutta.telemetry.default.submit.url', '/nodes/telemetry')
self.config.setdefault('wutta.telemetry.default.submit.uuid', '06897669-272b-7c74-8000-4d311924d24f')
profile = self.make_profile()
self.assertEqual(profile.collect_keys, ['os', 'network', 'python'])
self.assertEqual(profile.submit_url, '/nodes/telemetry')
self.assertEqual(profile.submit_uuid, '06897669-272b-7c74-8000-4d311924d24f')