From 650ac4d53caf8d179a404bba0881ad99aac5c8a4 Mon Sep 17 00:00:00 2001 From: Agustin Pelaez Date: Mon, 23 Feb 2026 21:02:41 -0500 Subject: [PATCH 1/2] Rewrite library as v2.0 Label-based API, Python 3 only, no Paginator/InfoList. Uses v2.0 for device/variable CRUD, v1.6 for data ingestion. Co-Authored-By: Claude Opus 4.6 --- README.md | 132 ++++++++++++ README.rst | 275 ------------------------ setup.py | 50 ++--- tests.py | 245 ---------------------- tests/__init__.py | 0 tests/test_client.py | 196 ++++++++++++++++++ tests/test_http.py | 115 +++++++++++ ubidots/__init__.py | 40 ++-- ubidots/_endpoints.py | 49 +++++ ubidots/_http.py | 67 ++++++ ubidots/apiclient.py | 470 ------------------------------------------ ubidots/client.py | 98 +++++++++ ubidots/exceptions.py | 43 ++++ 13 files changed, 739 insertions(+), 1041 deletions(-) create mode 100644 README.md delete mode 100644 README.rst delete mode 100644 tests.py create mode 100644 tests/__init__.py create mode 100644 tests/test_client.py create mode 100644 tests/test_http.py create mode 100644 ubidots/_endpoints.py create mode 100644 ubidots/_http.py delete mode 100644 ubidots/apiclient.py create mode 100644 ubidots/client.py create mode 100644 ubidots/exceptions.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..91f5d78 --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# ubidots-python + +Python client for the [Ubidots](https://ubidots.com) API. + +## Installation + +```bash +pip install ubidots +``` + +Requires Python 3.8+. + +## Quick Start + +```python +from ubidots import Ubidots + +api = Ubidots(token="BBFF-xxxxx") + +# Send multiple variables to a device in one call +api.send_values("weather-station", { + "temperature": 22.5, + "humidity": 60, + "pressure": 1013.25, +}) + +# Get the last value of a variable +temp = api.get_last_value("weather-station", "temperature") +print(temp) +``` + +## Usage + +### Connecting + +```python +from ubidots import Ubidots + +api = Ubidots(token="BBFF-xxxxx") + +# For on-premise / custom deployments: +api = Ubidots(token="BBFF-xxxxx", base_url="https://my-ubidots.example.com") +``` + +### Sending Data + +```python +# Send multiple variables at once (most common) +api.send_values("weather-station", { + "temperature": 22.5, + "humidity": 60, +}) + +# Send a single value +api.send_value("weather-station", "temperature", 22.5) + +# Send a value with timestamp and context +api.send_value("weather-station", "temperature", { + "value": 22.5, + "timestamp": 1609459200000, + "context": {"lat": 6.25, "lng": -75.56}, +}) +``` + +### Reading Data + +```python +# Get the last value of a variable +temp = api.get_last_value("weather-station", "temperature") + +# Get historical values +values = api.get_values("weather-station", "temperature") +``` + +### Device Management + +```python +api.create_device(label="weather-station", name="Weather Station") +device = api.get_device("weather-station") +devices = api.get_devices() +api.update_device("weather-station", name="New Name") +api.delete_device("weather-station") +``` + +### Variable Management + +```python +api.create_variable("weather-station", label="temperature", name="Temperature") +variable = api.get_variable("weather-station", "temperature") +variables = api.get_variables("weather-station") +api.delete_variable("weather-station", "temperature") +``` + +### Error Handling + +```python +from ubidots import Ubidots, UbidotsError404, UbidotsForbiddenError + +api = Ubidots(token="BBFF-xxxxx") + +try: + device = api.get_device("nonexistent") +except UbidotsError404: + print("Device not found") +except UbidotsForbiddenError: + print("Invalid token or insufficient permissions") +``` + +## API Reference + +All methods use **labels** to identify devices and variables. The library handles the `~` prefix for label-based lookups in v2.0 endpoints automatically. + +| Method | Description | +|--------|-------------| +| `send_values(device_label, data)` | Send multiple variable values to a device | +| `send_value(device_label, variable_label, value)` | Send a single value | +| `get_last_value(device_label, variable_label)` | Get the last value of a variable | +| `get_values(device_label, variable_label, **params)` | Get historical values | +| `get_devices(**params)` | List all devices | +| `get_device(label)` | Get a device by label | +| `create_device(*, label, name=None, **fields)` | Create a device | +| `update_device(label, **fields)` | Update a device | +| `delete_device(label)` | Delete a device | +| `get_variables(device_label=None, **params)` | List variables | +| `get_variable(device_label, variable_label)` | Get a variable by label | +| `create_variable(device_label, *, label, name=None, **fields)` | Create a variable | +| `update_variable(device_label, variable_label, **fields)` | Update a variable | +| `delete_variable(device_label, variable_label)` | Delete a variable | + +## License + +MIT - see [LICENSE](LICENSE) for details. diff --git a/README.rst b/README.rst deleted file mode 100644 index 2460e19..0000000 --- a/README.rst +++ /dev/null @@ -1,275 +0,0 @@ -=================================== -Ubidots Python API Client -=================================== - -The Ubidots Python API Client makes calls to the `Ubidots Api `_. The module is available on `PyPI `_ as "ubidots". - -To follow this quickstart you'll need to have python 2.7 in your machine (be it a computer or an python-capable device), which you can download at ``_. - - -Installing the Python library ------------------------------ - -Ubidots for python is available in PyPI and you can install it from the command line: - -.. code-block:: bash - - $ pip install ubidots==1.6.6 - -Don't forget to use sudo if necessary. - -You can install pip in Linux and Mac using this command: - -.. code-block:: bash - - $ sudo easy_install pip - -If you don't have *easy_install*, you can get it through *apt-get* on Debian-based distributions: - -.. code-block:: bash - - $ sudo apt-get install python-setuptools - -If you are using Microsoft Windows you can install pip from `here `_. - - -Connecting to the API ----------------------- - -Before playing with the API you must be able to connect to it using an API token, which can be found `in your profile `_. - -If you don't have an account yet, you can `create one here `_. - -Once you have your token, you can connect to the API by creating an ApiClient instance. Let's assume your token is: "f9iP6BpxpviO06EbebukACqEZcQMtM". Then your code would look like this: - - -.. code-block:: python - - from ubidots import ApiClient - - api = ApiClient(token='f9iP6BpxpviO06EbebukACqEZcQMtM') - -If you're using an independent container, you'll have to chagne the API BASE URL: - -.. code-block:: python - - from ubidots import ApiClient - - api = ApiClient(token="4b00-xxxxxxxxxxxxxxxxxxx", base_url="http://yourcompanyname.api.ubidots.com/api/v1.6/") - -Now you have an instance of ApiClient ("api") which can be used to connect to the API service. - -Saving a Value to a Variable ----------------------------- - -Retrieve the variable you'd like the value to be saved to: - -.. code-block:: python - - my_variable = api.get_variable('56799cf1231b28459f976417') - -Given the instantiated variable, you can save a new value with the following line: - -.. code-block:: python - - new_value = my_variable.save_value({'value': 10}) - -Here we'll send some GPS coordinates as an example: - -.. code-block:: python - - new_value = my_variable.save_value({'value':10, 'context':{'lat': 33.0822, 'lng': -117.24123}}) - -You can also specify a timestamp (optional): - -.. code-block:: python - - new_value = my_variable.save_value({'value': 10, 'timestamp': 1376061804407}) - -If no timestamp is specified, the API server will assign the current time to it. We think it's always better for you to specify the timestamp so the record reflects the exact time the value was captured, not the time it arrived to our servers. - -Creating a DataSource ----------------------- - -As you might know by now, a data source represents a device or a virtual source. - -This line creates a new data source: - -.. code-block:: python - - new_datasource = api.create_datasource({"name": "myNewDs", "tags": ["firstDs", "new"], "description": "any des"}) - - -The 'name' key is required, but the 'tags' and 'description' keys are optional. This new data source can be used to track different variables, so let's create one. - - -Creating a Variable --------------------- - -A variable is a time-series containing different values over time. Let's create one: - - -.. code-block:: python - - new_variable = new_datasource.create_variable({"name": "myNewVar", "unit": "Nw"}) - -The 'name' and 'unit' keys are required. - -Saving Values in Bulk ---------------------- - -This method used the "collections" API endpoints: http://ubidots.com/docs/api/v1_6/collections - -To save several values to a single variable: - -.. code-block:: python - - new_variable.save_values([ - {'timestamp': 1380558972614, 'value': 20,'context':{'lat': 33.0822, 'lng': -117.24123}}, - {'timestamp': 1380558972915, 'value': 40}, - {'timestamp': 1380558973516, 'value': 50}, - {'timestamp': 1380558973617, 'value': 30} - ]) - -To update several variables in a single request: - -.. code-block:: python - - api.save_collection([{'variable': '557f686f7625426a41a42f49', 'value': 10}, {'variable': '557f68747625426b97263cba', 'value':20}]) - - -Getting Values --------------- - -To get the values of a variable, use the method get_values in an instance of the class Variable. This will return a list like object with an aditional attribute items_in_server that tells you how many values this variable has stored on the server. - -If you only want the last N values call the method with the number of elements you want. - -.. code-block:: python - - # Getting all the values from the server. WARNING: If your variable has millions of datapoints, then this will take forever or break your code! - all_values = new_variable.get_values() - - # If you want just the last 100 values you can use: - some_values = new_variable.get_values(100) - -Getting the Last Value of a Variable ------------------------------------- - -To get the last value of a variable, get a single item in the get_values method: - -.. code-block:: python - - last_value = new_variable.get_values(1) - -Then select the first item of the list (last_value[0]), which is a dict, and retrieve the "value" key: - -.. code-block:: python - - print last_value[0]['value'] - - # Then you can read this value and do something: - - if last_value[0]['value']: - print "Switch is ON" - else: - print "Switch is OFF" - -Getting a group of Data sources --------------------------------- - -If you want to get all your data sources you can a method on the ApiClient instance directly. This method return a Paginator object which you can use to iterate through all the items. - -.. code-block:: python - - # Get all datasources - all_datasources = api.get_datasources() - - # Get the last five created datasources - some_datasources = api.get_datasources(5) - - -Getting a specific Data source -------------------------------- - -Each data source is identified by an ID. A specific data source can be retrieved from the server using this ID. - -For example, if a data source has the id 51c99cfdf91b28459f976414, it can be retrieved as follows: - - -.. code-block:: python - - my_specific_datasource = api.get_datasource('51c99cfdf91b28459f976414') - - -Getting a group of Variables from a Data source -------------------------------------------------- - -With a data source. you can also retrieve some or all of its variables: - -.. code-block:: python - - # Get all variables - all_variables = datasource.get_variables() - - # Get last 10 variables - some_variables = datasource.get_variables(10) - - -Getting a specific Variable ------------------------------- - -As with data sources, you can use your variable's ID to retrieve the details about it: - -.. code-block:: python - - my_specific_variable = api.get_variable('56799cf1231b28459f976417') - - -Managing HTTP Exceptions -------------------------- - -Given that possibility that a request to Ubidots could result in an error, the API client bundles some exceptions to make easier to spot the problems. All exceptions inherit from the base UbidotsError. The full list of exceptions is: - -UbidotsError400, UbidotsError404, UbidotsError500, UbidotsForbiddenError, UbidotsBulkOperationError - -Each error has an attribute 'message' (a general message of the error) and 'detail' (usually JSON from the server providing more detail). - -You can gaurd for these exceptions in this way: - -.. code-block:: python - - try: - my_specific_variable = api.get_variable('56799cf1231b28459f976417') - except UbidotsError400 as e: - print "General Description: %s; and the detail: %s" % (e.message, e.detail) - except UbidotsForbiddenError as e: - print "For some reason my account does not have permission to read this variable" - print "General Description: %s; and the detail: %s" % (e.message, e.detail) - -Other Exceptions ----------------- - -There is anoter exception UbidotsInvalidInputError wich is raised when the parameters to a function call are invalid. The required fields for the parameter of each resource in this API version are: - -Datasource: - Required: - name: string. - Optional: - tags: list of strings. - - description: string. - -Variables: - Required: - name: string. - - unit: string. - -Values: - Required: - value: number (integer or float). - - variable: string with the variable of the id id. - Optional: - timestamp: unix timestamp. diff --git a/setup.py b/setup.py index 0c8efbc..4953de6 100644 --- a/setup.py +++ b/setup.py @@ -1,41 +1,45 @@ from setuptools import setup, find_packages -import multiprocessing # http://bugs.python.org/issue15881#msg170215 + def read_file(name): with open(name, "r") as f: return f.read() + setup( - name='ubidots', - version='1.6.6', - author='Ubidots Team', - author_email='devel@ubidots.com', - url='https://github.com/ubidots/ubidots-python/', - license='MIT', - description='Api Client to connect to ubidots.com api version 1.6', - long_description=read_file("README.rst"), - platforms='any', - packages=find_packages(), + name="ubidots", + version="2.0.0", + author="Ubidots Team", + author_email="devel@ubidots.com", + url="https://github.com/ubidots/ubidots-python/", + license="MIT", + description="Python client for the Ubidots API v2.0", + long_description=read_file("README.md"), + long_description_content_type="text/markdown", + platforms="any", + packages=find_packages(exclude=["tests"]), + python_requires=">=3.8", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 2.5", - "Programming Language :: Python :: 2.6", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: System :: Hardware" + "Topic :: System :: Hardware", ], install_requires=[ - "requests >= 2.5.1", + "requests>=2.20.0", ], - test_suite='nose.collector', - tests_require=[ - 'mock >= 1.0.1', - 'nose >= 1.3.0', - 'requests >= 2.5.1' - ] + extras_require={ + "dev": [ + "pytest>=7.0", + ], + }, ) diff --git a/tests.py b/tests.py deleted file mode 100644 index ef36322..0000000 --- a/tests.py +++ /dev/null @@ -1,245 +0,0 @@ -import unittest -from ubidots.apiclient import ServerBridge -from ubidots.apiclient import try_again -from ubidots.apiclient import raise_informative_exception -from ubidots.apiclient import validate_input -from ubidots.apiclient import UbidotsError400, UbidotsError500, UbidotsInvalidInputError -from ubidots.apiclient import Paginator -from ubidots.apiclient import ApiObject -from ubidots.apiclient import Datasource -from ubidots.apiclient import ApiClient -from mock import patch, MagicMock, Mock, ANY -import json - - -class TestServerBridge(unittest.TestCase): - - def setUp(self): - self.original_initialize = ServerBridge.initialize - ServerBridge.initialize = MagicMock() - apikey = "anyapikey" - self.serverbridge = ServerBridge(apikey) - self.serverbridge._token_header = {'X-AUTH-TOKEN': 'the token'} - - def tearDown(self): - ServerBridge.initialize = self.original_initialize - - def test_when_ServerBridge_initializes_with_key_it_asks_for_a_token(self): - with patch('ubidots.apiclient.requests') as mock_request: - ServerBridge.initialize = self.original_initialize - apikey = "anyapikey" - sb = ServerBridge(apikey) - mock_request.post.assert_called_once_with( - "%s%s" % (sb.base_url, "auth/token"), - headers={'content-type': 'application/json', 'X-UBIDOTS-APIKEY': 'anyapikey'} - ) - - def test_when_ServerBridge_initializes_with_token_it_set_it_correctly(self): - sb = ServerBridge(token="anytoken") - self.assertEqual(sb._token_header, {'X-AUTH-TOKEN': 'anytoken'}) - - def test_get_includes_specific_headers(self): - with patch('ubidots.apiclient.requests') as mock_request: - self.serverbridge.get("any/path") - - mock_request.get.assert_called_once_with( - "%s%s" % (self.serverbridge.base_url, "any/path"), - headers={'content-type': 'application/json', 'X-AUTH-TOKEN': 'the token'} - ) - - def test_post_includes_specific_headers_and_data(self): - with patch('ubidots.apiclient.requests') as mock_request: - data = {"dataone": 1, "datatwo": 2} - self.serverbridge.post("any/path", data) - - mock_request.post.assert_called_once_with( - "%s%s" % (self.serverbridge.base_url, "any/path"), - headers={'content-type': 'application/json', 'X-AUTH-TOKEN': 'the token'}, - data=json.dumps(data) - ) - - def test_delete_includes_specific_headers(self): - with patch('ubidots.apiclient.requests') as mock_request: - self.serverbridge.delete("any/path") - - mock_request.delete.assert_called_once_with( - "%s%s" % (self.serverbridge.base_url, "any/path"), - headers={'content-type': 'application/json', 'X-AUTH-TOKEN': 'the token'}, - ) - - -class TestDecorators(unittest.TestCase): - - def test_try_again_decorator_number_of_tries_or_fail(self): - from collections import namedtuple - from ubidots.apiclient import UbidotsForbiddenError - error_codes = [401] - - response = namedtuple('response', 'status_code') - fn = Mock(side_effect=[response(error_codes[0]) for i in range(10)]) - real_decorator = try_again(error_codes, number_of_tries=10) - - serverbridge_mock = Mock() - wrapper = real_decorator(fn) - self.assertRaises(UbidotsForbiddenError, wrapper, serverbridge_mock) - - def test_raise_informative_exception_decorator(self): - from collections import namedtuple - error_codes = [400, 500] - response = namedtuple('response', 'status_code') - fn = Mock(side_effect=[response(error_codes[0]), response(error_codes[1])]) - real_decorator = raise_informative_exception(error_codes) - wrapper = real_decorator(fn) - self.assertRaises(UbidotsError400, wrapper, Mock()) - self.assertRaises(UbidotsError500, wrapper, Mock()) - - def test_raise_validate_input_decorator_dict(self): - real_decorator = validate_input(dict, ["a", "b", "c"]) - wrapper = real_decorator(lambda *args, **kwargs: 911) - - self.assertRaises(UbidotsInvalidInputError, wrapper, Mock(), []) - self.assertRaises(UbidotsInvalidInputError, wrapper, Mock(), {}) - self.assertRaises(UbidotsInvalidInputError, wrapper, Mock(), {"a": 1}) - self.assertRaises(UbidotsInvalidInputError, wrapper, Mock(), {"a": 1, "b": 1}) - self.assertRaises(UbidotsInvalidInputError, wrapper, Mock(), {"a": 1, "b": 1, "d": 1}) - - self.assertEqual(wrapper(Mock(), {"a": 1, "b": 1, "c": 1}), 911) - - def test_raise_validate_input_decorator_list(self): - real_decorator = validate_input(list, ["p", "q"]) - wrapper = real_decorator(lambda *args, **kwargs: 911) - - self.assertRaises(UbidotsInvalidInputError, wrapper, Mock(), dict) - self.assertRaises(UbidotsInvalidInputError, wrapper, Mock(), [{}]) - self.assertRaises(UbidotsInvalidInputError, wrapper, Mock(), [{"p"}]) - self.assertRaises(UbidotsInvalidInputError, wrapper, Mock(), [{"p": 1, "q": 1}, []]) - self.assertRaises(UbidotsInvalidInputError, wrapper, Mock(), [{"p": 1, "q": 1}, {}]) - self.assertRaises(UbidotsInvalidInputError, wrapper, Mock(), [{"p": 1, "q": 1}, {"p": 2}]) - - self.assertEqual(wrapper(Mock(), [{"p": 1, "q": 1}]), 911) - self.assertEqual(wrapper(Mock(), [{"p": 1, "q": 1}, {"p": 2, "q": 2}]), 911) - -if __name__ == '__main__': - unittest.main() - - -class TestPaginator(unittest.TestCase): - - def setUp(self): - class fakebridge(object): - pass - - def fake_transform_function(items, bridge): - return [index for index, item in enumerate(items)] - - self.fakebridge = fakebridge - self.fake_transform_function = fake_transform_function - self.response = '{"count": 12, "next": null, "previous": "the/end/point/?page = 2", "results": [{"a":1},{"a":2},{"a":3},{"a":4},{"a":5}]}' - self.response = json.loads(self.response) - self.endpoint = "/the/end/point/" - - def test_paginator_calculates_number_of_items_per_page_and_number_of_pages(self): - response = self.response - pag = Paginator(self.fakebridge, response, self.fake_transform_function, self.endpoint) - - self.assertEqual(pag.items_per_page, 5) - self.assertEqual(pag.number_of_pages, 3) - - def test_paginator_can_ask_for_a_specific_page(self): - PAGE = 2 - response = self.response - response_mock = Mock() - response_mock.json = Mock(return_value=self.response) - mock_bridge = Mock() - mock_bridge.get = Mock(return_value=response_mock) - pag = Paginator(mock_bridge, response, self.fake_transform_function, self.endpoint) - response = pag.get_page(PAGE) - - mock_bridge.get.assert_called_once_with("%s?page=%s" % (self.endpoint, PAGE),) - self.assertEqual(response, [0, 1, 2, 3, 4]) - - def test_paginator_returns_exception_if_page_don_not_exist(self): - pag = Paginator(self.fakebridge, self.response, self.fake_transform_function, self.endpoint) - self.assertRaises(Exception, pag.get_page, (1000)) - - def test_paginator_can_ask_for_the_last_x_values(self): - pag = Paginator(self.fakebridge, self.response, self.fake_transform_function, self.endpoint) - pag.get_pages = Mock() - pag.items = {1: [{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}], 2: [{"a": 6}, {"a": 7}, {"a": 8}, {"a": 9} , {"a": 10}], 3: [{"a" :11}, {"a": 12}]} - values = pag.get_last_items(7) - self.assertEqual(values,[{"a":1},{"a":2},{"a":3},{"a":4},{"a":5}, {"a":6}, {"a":7}] ) - - def test_paginator_can_ask_for_all_the_items(self): - pag = Paginator(self.fakebridge, self.response, self.fake_transform_function, self.endpoint) - pag.get_pages = Mock() - pag.items = {1:[{"a":1},{"a":2},{"a":3},{"a":4},{"a":5}], 2:[{"a":6},{"a":7},{"a":8},{"a":9},{"a":10}], 3:[{"a":11},{"a":12}]} - values = pag.get_all_items() - self.assertEqual(values,[{"a":1},{"a":2},{"a":3},{"a":4},{"a":5}, {"a":6}, {"a":7},{"a":8},{"a":9},{"a":10},{"a":11},{"a":12}] ) - - - def test_paginator_can_ask_for_the_range_of_pages(self): - response = self.response - pag = Paginator(self.fakebridge, response, self.fake_transform_function, self.endpoint) - self.assertEqual(pag.pages, [1,2,3]) - - - def test_paginator_make_the_custom_transformation(self): - - class fakebridge(object): - pass - - def transformation_function(items, bridge): - for item in items: - item['a'] = item['a'] + 1 - return items - - - any_dict = '{"count": 4, "next": null, "previous": null, "results": [{"a":1},{"a":2},{"a":3},{"a":4}]}' - any_dict = json.loads(any_dict) - pag = Paginator(fakebridge,any_dict,transformation_function, self.endpoint) - - -class TestApiObject(unittest.TestCase): - - def test_init_method_transfrom_raw_dictionary_items_to_object_attribute(self): - raw_data = {'key1':'val1','key2':'val2'} - bridge = Mock() - newapiobject = ApiObject(raw_data, bridge) - - self.assertEqual(newapiobject.key1, 'val1') - self.assertEqual(newapiobject.key2, 'val2') - - -class TestDatasource(unittest.TestCase): - - def test_method_get_variables_make_a_request_to_an_specific_endpoint(self): - ds_id = 'any_id' - endpoint = 'datasources/' + ds_id + '/variables' - raw_ds = {"id": ds_id, "name":"testds", "tags":['a', 'b', 'c'], "description":"the description"} - bridge = Mock() - ds = Datasource(raw_ds, bridge) - ds.get_new_paginator = Mock() - ds.get_variables() - bridge.get.assert_called_once_with(endpoint) - - def test_method_get_variables_make_a_request_to_an_specific_endpoint(self): - ds_id = 'any_id' - endpoint = 'datasources/' + ds_id + '/variables' - raw_ds = {"id": ds_id, "name":"testds", "tags":['a', 'b', 'c'], "description":"the description"} - bridge = Mock() - ds = Datasource(raw_ds, bridge) - ds.get_new_paginator = Mock() - ds.get_variables() - ds.get_new_paginator.assert_called_once_with(bridge, ANY, ANY, endpoint) - - -class TestVariables(unittest.TestCase): - pass - - -class TestApiClient(unittest.TestCase): - - def test_if_bridge_is_provided_other_arguments_are_not_needed(self): - bridge = Mock() - api = ApiClient(bridge=bridge) - self.assertEqual(api.bridge, bridge) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..dbb7743 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,196 @@ +from unittest.mock import MagicMock, patch, call + +import pytest + +from ubidots.client import Ubidots + + +@pytest.fixture +def api(): + with patch("ubidots.client.HttpClient") as MockHttp: + client = Ubidots("test-token") + client._http = MockHttp.return_value + yield client + + +BASE = "https://industrial.api.ubidots.com" + + +class TestDevices: + def test_get_devices_paginates(self, api): + api._http.get.side_effect = [ + {"results": [{"label": "d1"}], "next": f"{BASE}/api/v2.0/devices/?page=2"}, + {"results": [{"label": "d2"}], "next": None}, + ] + result = api.get_devices() + assert result == [{"label": "d1"}, {"label": "d2"}] + assert api._http.get.call_count == 2 + + def test_get_device(self, api): + api._http.get.return_value = {"label": "my-dev"} + result = api.get_device("my-dev") + api._http.get.assert_called_once_with(f"{BASE}/api/v2.0/devices/~my-dev/") + assert result == {"label": "my-dev"} + + def test_get_device_by_id(self, api): + api._http.get.return_value = {"id": "abc123"} + result = api.get_device_by_id("abc123") + api._http.get.assert_called_once_with(f"{BASE}/api/v2.0/devices/abc123/") + assert result == {"id": "abc123"} + + def test_create_device(self, api): + api._http.post.return_value = {"label": "new-dev"} + result = api.create_device(label="new-dev", name="New Device") + api._http.post.assert_called_once_with( + f"{BASE}/api/v2.0/devices/", + json={"label": "new-dev", "name": "New Device"}, + ) + assert result == {"label": "new-dev"} + + def test_create_device_with_extra_fields(self, api): + api._http.post.return_value = {} + api.create_device(label="dev", description="A device") + api._http.post.assert_called_once_with( + f"{BASE}/api/v2.0/devices/", + json={"label": "dev", "description": "A device"}, + ) + + def test_update_device(self, api): + api._http.patch.return_value = {"name": "Updated"} + result = api.update_device("my-dev", name="Updated") + api._http.patch.assert_called_once_with( + f"{BASE}/api/v2.0/devices/~my-dev/", + json={"name": "Updated"}, + ) + assert result == {"name": "Updated"} + + def test_delete_device(self, api): + api._http.delete.return_value = None + api.delete_device("my-dev") + api._http.delete.assert_called_once_with(f"{BASE}/api/v2.0/devices/~my-dev/") + + +class TestVariables: + def test_get_variables_for_device(self, api): + api._http.get.return_value = {"results": [{"label": "temp"}], "next": None} + result = api.get_variables("my-dev") + api._http.get.assert_called_once_with( + f"{BASE}/api/v2.0/devices/~my-dev/variables/", params={} + ) + assert result == [{"label": "temp"}] + + def test_get_variables_all(self, api): + api._http.get.return_value = {"results": [{"label": "temp"}], "next": None} + result = api.get_variables() + api._http.get.assert_called_once_with( + f"{BASE}/api/v2.0/variables/", params={} + ) + assert result == [{"label": "temp"}] + + def test_get_variable(self, api): + api._http.get.return_value = {"label": "temp"} + result = api.get_variable("my-dev", "temp") + api._http.get.assert_called_once_with( + f"{BASE}/api/v2.0/devices/~my-dev/variables/~temp/" + ) + assert result == {"label": "temp"} + + def test_create_variable(self, api): + api._http.post.return_value = {"label": "temp"} + result = api.create_variable("my-dev", label="temp", name="Temperature") + api._http.post.assert_called_once_with( + f"{BASE}/api/v2.0/devices/~my-dev/variables/", + json={"label": "temp", "name": "Temperature"}, + ) + assert result == {"label": "temp"} + + def test_update_variable(self, api): + api._http.patch.return_value = {"name": "Temp Updated"} + result = api.update_variable("my-dev", "temp", name="Temp Updated") + api._http.patch.assert_called_once_with( + f"{BASE}/api/v2.0/devices/~my-dev/variables/~temp/", + json={"name": "Temp Updated"}, + ) + assert result == {"name": "Temp Updated"} + + def test_delete_variable(self, api): + api._http.delete.return_value = None + api.delete_variable("my-dev", "temp") + api._http.delete.assert_called_once_with( + f"{BASE}/api/v2.0/devices/~my-dev/variables/~temp/" + ) + + +class TestDataIngestion: + def test_send_values(self, api): + api._http.post.return_value = {"temperature": [{"status_code": 201}]} + result = api.send_values("my-dev", {"temperature": 22.5, "humidity": 60}) + api._http.post.assert_called_once_with( + f"{BASE}/api/v1.6/devices/my-dev", + json={"temperature": 22.5, "humidity": 60}, + ) + assert "temperature" in result + + def test_send_value_simple(self, api): + api._http.post.return_value = {} + api.send_value("my-dev", "temp", 22.5) + api._http.post.assert_called_once_with( + f"{BASE}/api/v1.6/devices/my-dev/temp/values", + json={"value": 22.5}, + ) + + def test_send_value_dict(self, api): + api._http.post.return_value = {} + payload = {"value": 22.5, "timestamp": 1609459200000} + api.send_value("my-dev", "temp", payload) + api._http.post.assert_called_once_with( + f"{BASE}/api/v1.6/devices/my-dev/temp/values", + json=payload, + ) + + def test_get_values(self, api): + api._http.get.return_value = [{"value": 22.5}] + result = api.get_values("my-dev", "temp", page_size=10) + api._http.get.assert_called_once_with( + f"{BASE}/api/v1.6/devices/my-dev/temp/values", + params={"page_size": 10}, + ) + assert result == [{"value": 22.5}] + + def test_get_last_value(self, api): + api._http.get.return_value = 22.5 + result = api.get_last_value("my-dev", "temp") + api._http.get.assert_called_once_with( + f"{BASE}/api/v1.6/devices/my-dev/temp/lv" + ) + assert result == 22.5 + + +class TestPagination: + def test_paginate_single_page(self, api): + api._http.get.return_value = { + "results": [{"id": 1}, {"id": 2}], + "next": None, + } + result = api._paginate("http://example.com/api", {}) + assert result == [{"id": 1}, {"id": 2}] + + def test_paginate_multiple_pages(self, api): + api._http.get.side_effect = [ + {"results": [{"id": 1}], "next": "http://example.com/api?page=2"}, + {"results": [{"id": 2}], "next": "http://example.com/api?page=3"}, + {"results": [{"id": 3}], "next": None}, + ] + result = api._paginate("http://example.com/api", {"size": 1}) + assert result == [{"id": 1}, {"id": 2}, {"id": 3}] + # First call includes params, subsequent calls don't (next URL has them) + calls = api._http.get.call_args_list + assert calls[0] == call("http://example.com/api", params={"size": 1}) + assert calls[1] == call("http://example.com/api?page=2", params={}) + + +class TestCustomBaseUrl: + def test_custom_base_url(self): + with patch("ubidots.client.HttpClient"): + api = Ubidots("tok", base_url="https://custom.example.com") + assert api._endpoints.base_url == "https://custom.example.com" diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..d1e93a3 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,115 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from ubidots._http import HttpClient +from ubidots.exceptions import ( + UbidotsError400, + UbidotsError404, + UbidotsError500, + UbidotsForbiddenError, + UbidotsHTTPError, +) + + +@pytest.fixture +def http(): + return HttpClient("test-token") + + +class TestHttpClientInit: + def test_sets_auth_header(self, http): + assert http.session.headers["X-Auth-Token"] == "test-token" + + def test_sets_content_type(self, http): + assert http.session.headers["Content-Type"] == "application/json" + + def test_custom_timeout(self): + client = HttpClient("tok", timeout=60) + assert client.timeout == 60 + + +class TestRaiseForStatus: + def _response(self, status_code, text="error detail"): + resp = MagicMock() + resp.ok = status_code < 400 + resp.status_code = status_code + resp.text = text + return resp + + def test_ok_does_nothing(self): + HttpClient._raise_for_status(self._response(200)) + + def test_400_raises(self): + with pytest.raises(UbidotsError400) as exc_info: + HttpClient._raise_for_status(self._response(400)) + assert exc_info.value.status_code == 400 + + def test_401_raises_forbidden(self): + with pytest.raises(UbidotsForbiddenError): + HttpClient._raise_for_status(self._response(401)) + + def test_403_raises_forbidden(self): + with pytest.raises(UbidotsForbiddenError): + HttpClient._raise_for_status(self._response(403)) + + def test_404_raises(self): + with pytest.raises(UbidotsError404): + HttpClient._raise_for_status(self._response(404)) + + def test_500_raises(self): + with pytest.raises(UbidotsError500): + HttpClient._raise_for_status(self._response(500)) + + def test_502_raises_500(self): + with pytest.raises(UbidotsError500): + HttpClient._raise_for_status(self._response(502)) + + def test_unknown_4xx_raises_generic(self): + with pytest.raises(UbidotsHTTPError) as exc_info: + HttpClient._raise_for_status(self._response(429)) + assert exc_info.value.status_code == 429 + + +class TestHttpMethods: + @patch("ubidots._http.HttpClient._request") + def test_get(self, mock_req, http): + http.get("http://example.com", params={"a": 1}) + mock_req.assert_called_once_with("GET", "http://example.com", params={"a": 1}) + + @patch("ubidots._http.HttpClient._request") + def test_post(self, mock_req, http): + http.post("http://example.com", json={"k": "v"}) + mock_req.assert_called_once_with("POST", "http://example.com", json={"k": "v"}) + + @patch("ubidots._http.HttpClient._request") + def test_patch(self, mock_req, http): + http.patch("http://example.com", json={"k": "v"}) + mock_req.assert_called_once_with("PATCH", "http://example.com", json={"k": "v"}) + + @patch("ubidots._http.HttpClient._request") + def test_delete(self, mock_req, http): + http.delete("http://example.com") + mock_req.assert_called_once_with("DELETE", "http://example.com") + + +class TestRequest: + def test_returns_json(self, http): + resp = MagicMock() + resp.ok = True + resp.status_code = 200 + resp.content = b'{"key": "val"}' + resp.json.return_value = {"key": "val"} + http.session.request = MagicMock(return_value=resp) + + result = http.get("http://example.com") + assert result == {"key": "val"} + + def test_204_returns_none(self, http): + resp = MagicMock() + resp.ok = True + resp.status_code = 204 + resp.content = b"" + http.session.request = MagicMock(return_value=resp) + + assert http.delete("http://example.com") is None diff --git a/ubidots/__init__.py b/ubidots/__init__.py index dd1f886..44d818f 100644 --- a/ubidots/__init__.py +++ b/ubidots/__init__.py @@ -1,28 +1,12 @@ - -__version__ = '0.1.3-alpha' - - -class UbidotsLibraryError (Exception): - pass - -def check_requests_version(): - minimum_requests_version = '1.2.3' - try: - import requests - except: - raise UbidotsLibraryError("""requests Library is not installed, please install it - with pip install requests or better yet install ubidots using pip install ubidots""") - - - if requests.__version__ < '1.2.3': - raise UbidotsLibraryError("""Your current version of the library requests is %s, - to work with Ubidots you need at least the version 1.2.3 - you can install the latest version with 'pip install request' or better yet install - ubidots with 'pip install ubidots' """%requests.__version__ ) - - -check_requests_version() - -from .apiclient import ApiClient, Datasource, Variable, ServerBridge, Paginator -from .apiclient import UbidotsError400, UbidotsError404, UbidotsError500 -from .apiclient import UbidotsForbiddenError, UbidotsBulkOperationError, UbidotsInvalidInputError +__version__ = "2.0.0" + +from .client import Ubidots +from .exceptions import ( + UbidotsError, + UbidotsError400, + UbidotsError404, + UbidotsError500, + UbidotsForbiddenError, + UbidotsHTTPError, + UbidotsInvalidInputError, +) diff --git a/ubidots/_endpoints.py b/ubidots/_endpoints.py new file mode 100644 index 0000000..2306979 --- /dev/null +++ b/ubidots/_endpoints.py @@ -0,0 +1,49 @@ +DEFAULT_BASE_URL = "https://industrial.api.ubidots.com" + +V2_PREFIX = "/api/v2.0" +V1_PREFIX = "/api/v1.6" + + +class Endpoints: + """URL builders for Ubidots v1.6 and v2.0 APIs.""" + + def __init__(self, base_url=DEFAULT_BASE_URL): + self.base_url = base_url.rstrip("/") + + def _v2(self, path): + return f"{self.base_url}{V2_PREFIX}{path}" + + def _v1(self, path): + return f"{self.base_url}{V1_PREFIX}{path}" + + # ── Device CRUD (v2.0) ── + + def devices_url(self, label=None): + if label: + return self._v2(f"/devices/~{label}/") + return self._v2("/devices/") + + def device_by_id_url(self, device_id): + return self._v2(f"/devices/{device_id}/") + + # ── Variable CRUD (v2.0) ── + + def variables_url(self, label=None, device_label=None): + if device_label and label: + return self._v2(f"/devices/~{device_label}/variables/~{label}/") + if device_label: + return self._v2(f"/devices/~{device_label}/variables/") + if label: + return self._v2(f"/variables/~{label}/") + return self._v2("/variables/") + + # ── Data ingestion/extraction (v1.6) ── + + def device_data_url(self, device_label): + return self._v1(f"/devices/{device_label}") + + def variable_data_url(self, device_label, variable_label): + return self._v1(f"/devices/{device_label}/{variable_label}/values") + + def variable_lv_url(self, device_label, variable_label): + return self._v1(f"/devices/{device_label}/{variable_label}/lv") diff --git a/ubidots/_http.py b/ubidots/_http.py new file mode 100644 index 0000000..0aa44ae --- /dev/null +++ b/ubidots/_http.py @@ -0,0 +1,67 @@ +import requests + +from .exceptions import ( + UbidotsError400, + UbidotsError404, + UbidotsError500, + UbidotsForbiddenError, + UbidotsHTTPError, +) + +DEFAULT_TIMEOUT = 30 + + +class HttpClient: + """Thin wrapper around requests.Session with Ubidots auth and error handling.""" + + def __init__(self, token, timeout=DEFAULT_TIMEOUT): + self.timeout = timeout + self.session = requests.Session() + self.session.headers.update({ + "X-Auth-Token": token, + "Content-Type": "application/json", + }) + + def get(self, url, **kwargs): + return self._request("GET", url, **kwargs) + + def post(self, url, json=None, **kwargs): + return self._request("POST", url, json=json, **kwargs) + + def put(self, url, json=None, **kwargs): + return self._request("PUT", url, json=json, **kwargs) + + def patch(self, url, json=None, **kwargs): + return self._request("PATCH", url, json=json, **kwargs) + + def delete(self, url, **kwargs): + return self._request("DELETE", url, **kwargs) + + def _request(self, method, url, **kwargs): + kwargs.setdefault("timeout", self.timeout) + response = self.session.request(method, url, **kwargs) + self._raise_for_status(response) + if response.status_code == 204 or not response.content: + return None + return response.json() + + @staticmethod + def _raise_for_status(response): + if response.ok: + return + + try: + detail = response.text + except Exception: + detail = "" + + status = response.status_code + if status == 400: + raise UbidotsError400(detail) + if status in (401, 403): + raise UbidotsForbiddenError(detail) + if status == 404: + raise UbidotsError404(detail) + if status >= 500: + raise UbidotsError500(detail) + raise UbidotsHTTPError(status, detail) diff --git a/ubidots/apiclient.py b/ubidots/apiclient.py deleted file mode 100644 index 24b831f..0000000 --- a/ubidots/apiclient.py +++ /dev/null @@ -1,470 +0,0 @@ -import requests -import json -import re -import sys - -if sys.version_info > (3,): - long = int - -BASE_URL = 'http://things.ubidots.com/api/v1.6/' - - -def get_response_json_or_info_message(response): - if response.status_code == 204: - resp = {"detail": "this response don't need a body"} - - try: - resp = response.json() - except Exception: - resp = {"detail": "this response doesn't have a valid json response"} - return resp - - -class UbidotsError(Exception): - pass - - -class UbidotsHTTPError(UbidotsError): - def __init__(self, *args, **kwargs): - self.response = kwargs['response'] - self.detail = get_response_json_or_info_message(self.response) - self.status_code = self.response.status_code - del kwargs['response'] - super(UbidotsHTTPError, self).__init__(*args, **kwargs) - - -class UbidotsError400(UbidotsHTTPError): - """Exception thrown when server returns status code 400 Bad request""" - pass - - -class UbidotsError404(UbidotsHTTPError): - """Exception thrown when server returns status code 404 Not found""" - pass - - -class UbidotsError500(UbidotsHTTPError): - """Exception thrown when server returns status code 500""" - pass - - -class UbidotsForbiddenError(UbidotsHTTPError): - """Exception thrown when server returns status code 401 or 403""" - pass - - -class UbidotsBulkOperationError(UbidotsHTTPError): - ''' - TODO: the 'status_code' for this exception is 200!! - ''' - pass - - -class UbidotsInvalidInputError(UbidotsError): - """Exception thrown when client-side verification fails""" - pass - - -def create_exception_object(response): - """Creates an Exception object for an erronous status code.""" - - code = response.status_code - - if code == 500: - return UbidotsError500("An Internal Server Error Occurred.", response=response) - elif code == 400: - return UbidotsError400("Your response is invalid", response=response) - elif code == 404: - return UbidotsError404("Resource responseed not found:\n ", response=response) - elif code in [403, 401]: - return UbidotsForbiddenError( - "Your token is invalid or you don't have permissions to access this resource:\n ", - response=response - ) - else: - return UbidotsError("Not Handled Exception: ", response=response) - - -def raise_informative_exception(list_of_error_codes): - def real_decorator(fn): - def wrapped_f(self, *args, **kwargs): - response = fn(self, *args, **kwargs) - if response.status_code in list_of_error_codes: - try: - body = response.text - except: - body = "" - - # error = create_exception_object(response.status_code, body) - error = create_exception_object(response) - raise error - else: - return response - return wrapped_f - return real_decorator - - -def try_again(list_of_error_codes, number_of_tries=2): - def real_decorator(fn): - def wrapped_f(self, *args, **kwargs): - for i in range(number_of_tries): - response = fn(self, *args, **kwargs) - if response.status_code not in list_of_error_codes: - return response - else: - self.initialize() - - try: - body = response.text - except: - body = "" - - error = create_exception_object(response) - raise error - - return wrapped_f - return real_decorator - - -def validate_input(type, required_keys=[]): - ''' - Decorator for validating input on the client side. - - If validation fails, UbidotsInvalidInputError is raised and the function - is not called. - ''' - def real_decorator(fn): - def wrapped_f(self, *args, **kwargs): - if not isinstance(args[0], type): - raise UbidotsInvalidInputError("Invalid argument type. Required: " + str(type)) - - def check_keys(obj): - for key in required_keys: - if key not in obj: - raise UbidotsInvalidInputError('Key "%s" is missing' % key) - - if isinstance(args[0], list): - list(map(check_keys, args[0])) - elif isinstance(args[0], dict): - check_keys(args[0]) - - return fn(self, *args, **kwargs) - return wrapped_f - return real_decorator - - -class ServerBridge(object): - ''' - Responsabilites: Make petitions to the browser with the right headers and arguments - ''' - - def __init__(self, apikey=None, token=None, base_url=None): - self.base_url = base_url or BASE_URL - if apikey: - self._token = None - self._apikey = apikey - self._apikey_header = {'X-UBIDOTS-APIKEY': self._apikey} - self.initialize() - elif token: - self._apikey = None - self._token = token - self._set_token_header() - - def _get_token(self): - self._token = self._post_with_apikey('auth/token').json()['token'] - self._set_token_header() - - def _set_token_header(self): - self._token_header = {'X-AUTH-TOKEN': self._token} - - def initialize(self): - if self._apikey: - self._get_token() - - @raise_informative_exception([400, 404, 500, 401, 403]) - def _post_with_apikey(self, path): - headers = self._prepare_headers(self._apikey_header) - response = requests.post(self.base_url + path, headers=headers) - return response - - @try_again([403, 401]) - @raise_informative_exception([400, 404, 500]) - def get(self, path, **kwargs): - headers = self._prepare_headers(self._token_header) - response = requests.get(self.base_url + path, headers=headers, **kwargs) - return response - - def get_with_url(self, url, **kwargs): - headers = self._prepare_headers(self._token_header) - response = requests.get(url, headers=headers, **kwargs) - return response - - @try_again([403, 401]) - @raise_informative_exception([400, 404, 500]) - def post(self, path, data, **kwargs): - headers = self._prepare_headers(self._token_header) - data = self._prepare_data(data) - response = requests.post(self.base_url + path, data=data, headers=headers, **kwargs) - return response - - @try_again([403, 401]) - @raise_informative_exception([400, 404, 500]) - def delete(self, path, **kwargs): - headers = self._prepare_headers(self._token_header) - response = requests.delete(self.base_url + path, headers=headers, **kwargs) - return response - - def _prepare_headers(self, *args, **kwargs): - headers = self._transform_a_list_of_dictionaries_to_a_dictionary(args) - headers.update(self._get_custom_headers()) - headers.update(kwargs.items()) - return headers - - def _prepare_data(self, data): - return json.dumps(data) - - def _get_custom_headers(self): - headers = {'content-type': 'application/json'} - return headers - - def _transform_a_list_of_dictionaries_to_a_dictionary(self, list_of_dicts): - headers = {} - for dictionary in list_of_dicts: - for key, val in dictionary.items(): - headers[key] = val - return headers - - -class ApiObject(object): - - def __init__(self, raw_data, bridge, *args, **kwargs): - self.raw = raw_data - self.api = kwargs.get('api', None) - self.bridge = bridge - self._from_raw_to_attributes() - - def _from_raw_to_attributes(self): - for key, value in self.raw.items(): - setattr(self, key, value) - - -def transform_to_datasource_objects(raw_datasources, bridge): - datasources = [] - for ds in raw_datasources: - datasources.append(Datasource(ds, bridge)) - return datasources - - -def transform_to_variable_objects(raw_variables, bridge): - variables = [] - for variable in raw_variables: - variables.append(Variable(variable, bridge)) - return variables - - -class Datasource(ApiObject): - - def remove_datasource(self): - return self.bridge.delete('datasources/' + self.id) == 204 - - def get_variables(self, numofvars="ALL"): - endpoint = 'datasources/' + self.id + '/variables' - response = self.bridge.get(endpoint) - pag = self.get_new_paginator(self.bridge, response.json(), transform_to_variable_objects, endpoint) - return InfoList(pag, numofvars) - - def get_new_paginator(self, bridge, json_data, transform_function, endpoint): - return Paginator(bridge, json_data, transform_function, endpoint) - - @validate_input(dict, ["name", "unit"]) - def create_variable(self, data): - response = self.bridge.post('datasources/' + self.id + '/variables', data) - return Variable(response.json(), self.bridge, datasource=self) - - def __repr__(self): - return self.name - - -class Variable(ApiObject): - - def __init__(self, raw_data, bridge, *args, **kwargs): - super(Variable, self).__init__(raw_data, bridge, *args, **kwargs) - - def get_values(self, numofvals="ALL"): - endpoint = 'variables/' + self.id + '/values' - kwargs = {} - if isinstance(numofvals, int): - kwargs["params"] = {"page_size": str(numofvals)} - response = self.bridge.get(endpoint, **kwargs).json() - pag = Paginator(self.bridge, response, self.get_transform_function(), endpoint) - return InfoList(pag, numofvals) - - def get_transform_function(self): - def transform_function(values, bridge): - return values - return transform_function - - @validate_input(dict, ["value"]) - def save_value(self, data): - if not isinstance(data.get('timestamp', 0), (int, long)): - raise UbidotsInvalidInputError('Key "timestamp" must point to an int value.') - - return self.bridge.post('variables/' + self.id + '/values', data).json() - - @validate_input(list, ["value", "timestamp"]) - def save_values(self, data, force=False): - if not all(isinstance(e['timestamp'], (int, long)) for e in data): - raise UbidotsInvalidInputError('Key "timestamp" must point to an int value.') - - path = 'variables/' + self.id + '/values' - path += ('', '?force=true')[int(force)] - response = self.bridge.post(path, data) - data = response.json() - if not self._all_values_where_accepted(data): - raise UbidotsBulkOperationError("There was a problem with some of your posted values.", response=response) - return data - - def _all_values_where_accepted(self, data): - return all(map(lambda x: x['status_code'] == 201, data)) - - def remove_variable(self): - return self.bridge.delete('variables/' + self.id).status_code == 204 - - def remove_values(self, t_start, t_end): - return self.bridge.delete('variables/{0}/values/{1}/{2}'.format(self.id, t_start, t_end)) - - def remove_all_values(self): - from time import time - t_start = 0 - t_end = int(time()) * 1000 - return self.remove_values(t_start=t_start, t_end=t_end) - - def get_datasource(self, **kwargs): - if not self._datasource: - api = ApiClient(server_bridge=self.bridge) - self._datasource = api.get_datasource(url=self.datasource['url']) - return self._datasource - - def __repr__(self): - return self.name - - -class Paginator(object): - def __init__(self, bridge, response, transform_function, endpoint): - self.bridge = bridge - self.response = response - self.endpoint = endpoint - self.hasNext = self.response['next'] - self.transform_function = transform_function - self.items_per_page = self._get_number_of_items_per_page() - self.items = [] - self.actualPage = 1 - self.add_new_items(response) - - def _there_is_more_than_one_page(self): - return self.hasNext - - def _get_number_of_items_per_page(self): - return len(self.response['results']) - - def add_new_items(self, response): - self.hasNext = response['next'] - new_items = self.transform_function(response['results'], self.bridge) - self.items = self.items + new_items - self.actualPage = self.actualPage + 1 - - def get_page(self): - try: - response = self.bridge.get("{0}?page={1}".format(self.endpoint, self.actualPage)).json() - except JSONDecodeError: - # When the server returns something that is not JSON decodable - # this will crash. - raise UbidotsHTTPError("Invalid response from the server") - self.add_new_items(response) - return self.items - - def get_all_items(self): - self.get_pages() - return self.items - - def get_pages(self): - while self.hasNext is not None: - self.get_page() - - def _filter_valid_pages(self, list_of_pages): - return list(set(list_of_pages) & set(self.pages)) - - def _add_items_to_results(self, raw_results): - self.result[self.current_page] = raw_results - - def _flat_items(self, pages): - nestedlist = [value for key, value in self.items.items() if key in pages] - return [item for sublist in nestedlist for item in sublist] - - -class InfoList(list): - def __init__(self, paginator, numofitems='ALL'): - self.paginator = paginator - items = self.get_items(numofitems) - super(InfoList, self).__init__(items) - - def get_items(self, numofitems): - return self.paginator.get_all_items() - - -class ApiClient(object): - bridge_class = ServerBridge - - def __init__(self, apikey=None, token=None, base_url=None, bridge=None): - if bridge is None: - self.bridge = ServerBridge(apikey, token, base_url) - else: - self.bridge = bridge - - def get_datasources(self, numofdsources='ALL', **kwargs): - endpoint = 'datasources' - response = self.bridge.get(endpoint, **kwargs).json() - pag = Paginator(self.bridge, response, transform_to_datasource_objects, endpoint) - return InfoList(pag, numofdsources) - - def get_datasource(self, ds_id=None, url=None, **kwargs): - if not id and not url: - raise UbidotsInvalidInputError("id or url required") - - if ds_id: - raw_datasource = self.bridge.get('datasources/' + str(ds_id), **kwargs).json() - elif url: - raw_datasource = self.bridge.get_with_url(url, **kwargs).json() - - return Datasource(raw_datasource, self.bridge) - - @validate_input(dict, ["name"]) - def create_datasource(self, data): - raw_datasource = self.bridge.post('datasources/', data).json() - return Datasource(raw_datasource, self.bridge) - - def get_variables(self, numofvars='ALL', **kwargs): - endpoint = 'variables' - response = self.bridge.get('variables', **kwargs).json() - pag = Paginator(self.bridge, response, transform_to_variable_objects, endpoint) - return InfoList(pag, numofvars) - - def get_variable(self, var_id, **kwargs): - raw_variable = self.bridge.get('variables/' + str(var_id), **kwargs).json() - return Variable(raw_variable, self.bridge) - - @validate_input(list, ["variable", "value"]) - def save_collection(self, data, force=False): - path = "collections/values" - path += ('', '?force=true')[int(force)] - response = self.bridge.post(path, data) - data = response.json() - if not self._all_collection_items_where_accepted(data): - raise UbidotsBulkOperationError( - "There was a problem with some of your posted items values.", - response=response - ) - return data - - def _all_collection_items_where_accepted(self, data): - return all(map(lambda x: x['status_code'] == 201, data)) diff --git a/ubidots/client.py b/ubidots/client.py new file mode 100644 index 0000000..71d4cbe --- /dev/null +++ b/ubidots/client.py @@ -0,0 +1,98 @@ +from ._endpoints import DEFAULT_BASE_URL, Endpoints +from ._http import HttpClient + + +class Ubidots: + """Flat, label-based client for the Ubidots API. + + Uses v2.0 for device/variable CRUD and v1.6 for data ingestion/extraction. + """ + + def __init__(self, token, base_url=DEFAULT_BASE_URL, timeout=30): + self._http = HttpClient(token, timeout=timeout) + self._endpoints = Endpoints(base_url) + + # ── Device management (v2.0) ── + + def get_devices(self, **params): + url = self._endpoints.devices_url() + return self._paginate(url, params) + + def get_device(self, label): + url = self._endpoints.devices_url(label) + return self._http.get(url) + + def get_device_by_id(self, device_id): + url = self._endpoints.device_by_id_url(device_id) + return self._http.get(url) + + def create_device(self, *, label, name=None, **fields): + url = self._endpoints.devices_url() + body = {"label": label, **fields} + if name is not None: + body["name"] = name + return self._http.post(url, json=body) + + def update_device(self, label, **fields): + url = self._endpoints.devices_url(label) + return self._http.patch(url, json=fields) + + def delete_device(self, label): + url = self._endpoints.devices_url(label) + self._http.delete(url) + + # ── Variable management (v2.0) ── + + def get_variables(self, device_label=None, **params): + url = self._endpoints.variables_url(device_label=device_label) + return self._paginate(url, params) + + def get_variable(self, device_label, variable_label): + url = self._endpoints.variables_url(variable_label, device_label=device_label) + return self._http.get(url) + + def create_variable(self, device_label, *, label, name=None, **fields): + url = self._endpoints.variables_url(device_label=device_label) + body = {"label": label, **fields} + if name is not None: + body["name"] = name + return self._http.post(url, json=body) + + def update_variable(self, device_label, variable_label, **fields): + url = self._endpoints.variables_url(variable_label, device_label=device_label) + return self._http.patch(url, json=fields) + + def delete_variable(self, device_label, variable_label): + url = self._endpoints.variables_url(variable_label, device_label=device_label) + self._http.delete(url) + + # ── Data ingestion/extraction (v1.6) ── + + def send_values(self, device_label, data): + url = self._endpoints.device_data_url(device_label) + return self._http.post(url, json=data) + + def send_value(self, device_label, variable_label, value): + url = self._endpoints.variable_data_url(device_label, variable_label) + body = value if isinstance(value, dict) else {"value": value} + return self._http.post(url, json=body) + + def get_values(self, device_label, variable_label, **params): + url = self._endpoints.variable_data_url(device_label, variable_label) + return self._http.get(url, params=params) + + def get_last_value(self, device_label, variable_label): + url = self._endpoints.variable_lv_url(device_label, variable_label) + return self._http.get(url) + + # ── Internal ── + + def _paginate(self, url, params): + results = [] + params = dict(params) + while url: + data = self._http.get(url, params=params) + results.extend(data.get("results", [])) + url = data.get("next") + params = {} # next URL already contains query params + return results diff --git a/ubidots/exceptions.py b/ubidots/exceptions.py new file mode 100644 index 0000000..dd4e82e --- /dev/null +++ b/ubidots/exceptions.py @@ -0,0 +1,43 @@ +class UbidotsError(Exception): + """Base exception for all Ubidots errors.""" + + +class UbidotsHTTPError(UbidotsError): + """Raised when the Ubidots API returns an error HTTP status.""" + + def __init__(self, status_code, detail=""): + self.status_code = status_code + self.detail = detail + super().__init__(f"HTTP {status_code}: {detail}") + + +class UbidotsError400(UbidotsHTTPError): + """Bad request.""" + + def __init__(self, detail=""): + super().__init__(400, detail) + + +class UbidotsError404(UbidotsHTTPError): + """Resource not found.""" + + def __init__(self, detail=""): + super().__init__(404, detail) + + +class UbidotsError500(UbidotsHTTPError): + """Internal server error.""" + + def __init__(self, detail=""): + super().__init__(500, detail) + + +class UbidotsForbiddenError(UbidotsHTTPError): + """Authentication or authorization failure (401/403).""" + + def __init__(self, detail=""): + super().__init__(403, detail) + + +class UbidotsInvalidInputError(UbidotsError): + """Raised for client-side input validation failures.""" From e356dc2be593ffa534bd3bb088fcc1828eb08146 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:20:42 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`v2.?= =?UTF-8?q?0`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @AgustinPelaez. * https://github.com/ubidots/ubidots-python/pull/26#issuecomment-3948449398 The following files were modified: * `setup.py` * `tests/test_client.py` * `tests/test_http.py` * `ubidots/_endpoints.py` * `ubidots/_http.py` * `ubidots/client.py` * `ubidots/exceptions.py` --- setup.py | 9 +++ tests/test_client.py | 6 ++ tests/test_http.py | 16 +++++ ubidots/_endpoints.py | 85 ++++++++++++++++++++++ ubidots/_http.py | 83 ++++++++++++++++++++++ ubidots/client.py | 159 ++++++++++++++++++++++++++++++++++++++++++ ubidots/exceptions.py | 34 +++++++++ 7 files changed, 392 insertions(+) diff --git a/setup.py b/setup.py index 4953de6..af581ad 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,15 @@ def read_file(name): + """ + Read and return the entire contents of a text file. + + Parameters: + name (str): Path to the file to read. + + Returns: + contents (str): The file contents as a string. + """ with open(name, "r") as f: return f.read() diff --git a/tests/test_client.py b/tests/test_client.py index dbb7743..9c476cd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,6 +7,12 @@ @pytest.fixture def api(): + """ + Pytest fixture that provides a Ubidots client with its HttpClient replaced by a mock. + + Returns: + Ubidots: A Ubidots instance whose internal HTTP client (`_http`) is the mocked HttpClient for use in tests. + """ with patch("ubidots.client.HttpClient") as MockHttp: client = Ubidots("test-token") client._http = MockHttp.return_value diff --git a/tests/test_http.py b/tests/test_http.py index d1e93a3..376b37b 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -14,6 +14,12 @@ @pytest.fixture def http(): + """ + Provide a configured HttpClient instance for tests. + + Returns: + HttpClient: an HttpClient initialized with the token "test-token". + """ return HttpClient("test-token") @@ -31,6 +37,16 @@ def test_custom_timeout(self): class TestRaiseForStatus: def _response(self, status_code, text="error detail"): + """ + Create a MagicMock representing an HTTP response with the given status and text. + + Parameters: + status_code (int): HTTP status code to set on the mock response. + text (str): Response body text to set on the mock response. + + Returns: + MagicMock: A mock response with `ok` set to True for status codes < 400, and `status_code` and `text` attributes set accordingly. + """ resp = MagicMock() resp.ok = status_code < 400 resp.status_code = status_code diff --git a/ubidots/_endpoints.py b/ubidots/_endpoints.py index 2306979..6c92195 100644 --- a/ubidots/_endpoints.py +++ b/ubidots/_endpoints.py @@ -8,27 +8,83 @@ class Endpoints: """URL builders for Ubidots v1.6 and v2.0 APIs.""" def __init__(self, base_url=DEFAULT_BASE_URL): + """ + Initialize the Endpoints instance with a sanitized base URL. + + Parameters: + base_url (str): Base URL used for building API endpoints; any trailing '/' is removed before storage. + """ self.base_url = base_url.rstrip("/") def _v2(self, path): + """ + Builds a full Ubidots v2.0 API URL for the given path. + + Parameters: + path (str): Path relative to the v2 API prefix (e.g. '/devices' or '/devices/{id}'); may include a leading slash. + + Returns: + url (str): Full URL for the v2.0 endpoint. + """ return f"{self.base_url}{V2_PREFIX}{path}" def _v1(self, path): + """ + Build the full v1.6 API URL for the given path. + + Parameters: + path (str): Path to append to the v1.6 API prefix (e.g., '/devices' or '/devices/