Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
6 changes: 6 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions tests/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand All @@ -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
Expand Down
85 changes: 85 additions & 0 deletions ubidots/_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<label>').

Returns:
str: Complete URL for the v1.6 endpoint.
"""
return f"{self.base_url}{V1_PREFIX}{path}"

# ── Device CRUD (v2.0) ──

def devices_url(self, label=None):
"""
Builds the v2.0 devices endpoint URL, optionally scoped to a specific device label.

Parameters:
label (str, optional): Device label to scope the endpoint to that device.

Returns:
str: Full URL for the devices endpoint; if `label` is provided, URL for the specified device.
"""
if label:
return self._v2(f"/devices/~{label}/")
return self._v2("/devices/")

def device_by_id_url(self, device_id):
"""
Builds the v2.0 URL for a device identified by its ID.

Parameters:
device_id (str): Device identifier to include in the path.

Returns:
str: Full v2.0 endpoint URL for the specified device.
"""
return self._v2(f"/devices/{device_id}/")

# ── Variable CRUD (v2.0) ──

def variables_url(self, label=None, device_label=None):
"""
Builds a v2.0 API URL for variable resources, optionally scoped to a device or a specific variable label.

Parameters:
label (str | None): Variable label to target a specific variable; when provided with `device_label`, targets the variable within that device.
device_label (str | None): Device label to scope the variables to a specific device.

Returns:
str: Full v2.0 endpoint URL:
- If both `device_label` and `label` are provided: URL for a specific variable within the specified device.
- If only `device_label` is provided: URL for all variables of the specified device.
- If only `label` is provided: URL for the specific variable across devices.
- If neither is provided: URL for the generic variables collection.
"""
if device_label and label:
return self._v2(f"/devices/~{device_label}/variables/~{label}/")
if device_label:
Expand All @@ -40,10 +96,39 @@ def variables_url(self, label=None, device_label=None):
# ── Data ingestion/extraction (v1.6) ──

def device_data_url(self, device_label):
"""
Build the v1.6 API URL for a device's data using its label.

Parameters:
device_label (str): The device's label used in the URL path.

Returns:
str: Full v1.6 endpoint URL for the device's data (e.g. ".../api/v1.6/devices/{device_label}").
"""
return self._v1(f"/devices/{device_label}")

def variable_data_url(self, device_label, variable_label):
"""
Builds the v1.6 endpoint URL for the values of a specific variable on a specific device.

Parameters:
device_label (str): Label of the device containing the variable.
variable_label (str): Label of the variable whose values are requested.

Returns:
str: Full v1.6 API URL for the variable's values (e.g. .../devices/{device_label}/{variable_label}/values).
"""
return self._v1(f"/devices/{device_label}/{variable_label}/values")

def variable_lv_url(self, device_label, variable_label):
"""
Builds the v1.6 API URL for the latest value ("lv") of a specific variable on a device.

Parameters:
device_label (str): Label of the device containing the variable.
variable_label (str): Label of the variable whose latest value is requested.

Returns:
str: Full v1.6 endpoint URL for the variable's latest value.
"""
return self._v1(f"/devices/{device_label}/{variable_label}/lv")
83 changes: 83 additions & 0 deletions ubidots/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ class HttpClient:
"""Thin wrapper around requests.Session with Ubidots auth and error handling."""

def __init__(self, token, timeout=DEFAULT_TIMEOUT):
"""
Initialize the HttpClient instance and configure its requests session.

Parameters:
token (str): Ubidots API token sent as the `X-Auth-Token` header for all requests.
timeout (int): Default request timeout in seconds used when no timeout is provided to calls.
"""
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
Expand All @@ -23,21 +30,84 @@ def __init__(self, token, timeout=DEFAULT_TIMEOUT):
})

def get(self, url, **kwargs):
"""
Send a GET request to the specified URL.

Additional keyword arguments are forwarded to the underlying requests call (for example, params, headers, or timeout).

Parameters:
url (str): The request URL.

Returns:
The parsed JSON response, or `None` if the response has no content or the status is 204.
"""
return self._request("GET", url, **kwargs)

def post(self, url, json=None, **kwargs):
"""
Send a POST request to the specified URL using the client's session.

Parameters:
url (str): The request URL.
json (optional): JSON-serializable body to include in the request.
**kwargs: Additional options forwarded to the underlying request (e.g., headers, params, timeout).

Returns:
The parsed JSON response, or `None` for HTTP 204 responses or when the response has no content.
"""
return self._request("POST", url, json=json, **kwargs)

def put(self, url, json=None, **kwargs):
"""
Send a PUT request to the specified URL with an optional JSON payload.

Parameters:
url (str): The target request URL.
json (Any, optional): JSON-serializable body to include in the request.

Returns:
result (Any or None): `None` if the response has no content or the status is 204, otherwise the parsed JSON response.
"""
return self._request("PUT", url, json=json, **kwargs)

def patch(self, url, json=None, **kwargs):
"""
Send a PATCH request to the specified URL with an optional JSON payload.

Parameters:
url (str): The target request URL.
json (Any, optional): JSON-serializable payload to include in the request body.
**kwargs: Additional keyword arguments forwarded to the underlying request (e.g., params, headers).

Returns:
The parsed JSON response, or None for HTTP 204 responses or when the response has no content.
"""
return self._request("PATCH", url, json=json, **kwargs)

def delete(self, url, **kwargs):
"""
Send an HTTP DELETE request to the given URL.

Parameters:
url (str): Request URL.

Returns:
The parsed JSON response, or `None` if the response has no content (e.g., 204 No Content).
"""
return self._request("DELETE", url, **kwargs)

def _request(self, method, url, **kwargs):
"""
Perform an HTTP request using the client's session and return the parsed JSON body or None for empty responses.

Parameters:
method (str): HTTP method to use (e.g., "GET", "POST").
url (str): Request URL.
**kwargs: Additional arguments passed to requests.Session.request. If `timeout` is not provided, the client's default timeout is applied.

Returns:
dict | list | None: The parsed JSON response body, or `None` when the response has no content (e.g., status 204).
"""
kwargs.setdefault("timeout", self.timeout)
response = self.session.request(method, url, **kwargs)
self._raise_for_status(response)
Expand All @@ -47,6 +117,19 @@ def _request(self, method, url, **kwargs):

@staticmethod
def _raise_for_status(response):
"""
Raise a Ubidots-specific exception for non-success HTTP responses.

Parameters:
response (requests.Response): HTTP response whose status code and body determine the raised exception.

Raises:
UbidotsError400: If the response status code is 400; message uses the response body.
UbidotsForbiddenError: If the response status code is 401 or 403; message uses the response body.
UbidotsError404: If the response status code is 404; message uses the response body.
UbidotsError500: If the response status code is 500 or greater; message uses the response body.
UbidotsHTTPError: For any other non-success status codes; includes the status code and response body.
"""
if response.ok:
return

Expand Down
Loading