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..af581ad 100644 --- a/setup.py +++ b/setup.py @@ -1,41 +1,54 @@ from setuptools import setup, find_packages -import multiprocessing # http://bugs.python.org/issue15881#msg170215 + 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() + 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..9c476cd --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,202 @@ +from unittest.mock import MagicMock, patch, call + +import pytest + +from ubidots.client import Ubidots + + +@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 + 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..376b37b --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,131 @@ +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(): + """ + Provide a configured HttpClient instance for tests. + + Returns: + HttpClient: an HttpClient initialized with the token "test-token". + """ + 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"): + """ + 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 + 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..6c92195 --- /dev/null +++ b/ubidots/_endpoints.py @@ -0,0 +1,134 @@ +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): + """ + 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/