From 3e291fc59cb5a1c452ecf69367948e740585ea6c Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 15 Feb 2024 15:37:01 -0800 Subject: [PATCH 01/16] Remove support for farmOS v1 --- farmOS/__init__.py | 32 ++--- farmOS/client.py | 341 ++++++++++++++++++++------------------------- farmOS/client_2.py | 226 ------------------------------ 3 files changed, 163 insertions(+), 436 deletions(-) delete mode 100644 farmOS/client_2.py diff --git a/farmOS/__init__.py b/farmOS/__init__.py index e4c77ff..ef530e0 100644 --- a/farmOS/__init__.py +++ b/farmOS/__init__.py @@ -3,7 +3,7 @@ from functools import partial from urllib.parse import urlparse, urlunparse -from . import client, client_2, subrequests +from . import client, subrequests from .session import OAuthSession logger = logging.getLogger(__name__) @@ -21,7 +21,6 @@ def __init__( scope="farm_manager", token=None, token_updater=lambda new_token: None, - version=2, ): """ Initialize instance of the farmOS client that connects to a single farmOS server. @@ -33,7 +32,6 @@ def __init__( :param scope: OAuth Scope. Defaults to "farm_manager". :param token: An existing OAuth token to use. :param token_updater: A function used to save OAuth tokens outside of the client. - :param version: The major version of the farmOS server. Defaults to 2. """ logger.debug("Creating farmOS client.") @@ -100,11 +98,6 @@ def __init__( # Unset the 'expires_at' key. token.pop("expires_at") - # Determine the Content-Type header depending on server version. - content_type = ( - "application/vnd.api+json" if version == 2 else "application/json" - ) - # Create an OAuth Session self.session = OAuthSession( hostname=hostname, @@ -113,7 +106,7 @@ def __init__( scope=scope, token=token, token_url=token_url, - content_type=content_type, + content_type="application/vnd.api+json", token_updater=self.token_updater, ) @@ -126,20 +119,13 @@ def __init__( "initializing a farmOS Client." ) - if version == 2: - self.log = client_2.LogAPI(self.session) - self.asset = client_2.AssetAPI(self.session) - self.term = client_2.TermAPI(self.session) - self.resource = client_2.ResourceBase(self.session) - self.info = partial(client_2.info, self.session) - self.subrequests = subrequests.SubrequestsBase(self.session) - self.filter = client_2.filter - else: - self.log = client.LogAPI(self.session) - self.asset = client.AssetAPI(self.session) - self.area = client.AreaAPI(self.session) - self.term = client.TermAPI(self.session) - self.info = partial(client.info, self.session) + self.log = client.LogAPI(self.session) + self.asset = client.AssetAPI(self.session) + self.term = client.TermAPI(self.session) + self.resource = client.ResourceBase(self.session) + self.info = partial(client.info, self.session) + self.subrequests = subrequests.SubrequestsBase(self.session) + self.filter = client.filter def authorize(self, username=None, password=None, scope=None): """Authorize with the farmOS server. diff --git a/farmOS/client.py b/farmOS/client.py index 916a055..2ccde6e 100644 --- a/farmOS/client.py +++ b/farmOS/client.py @@ -1,146 +1,84 @@ import logging -from urllib.parse import parse_qs, urlparse +from urllib.parse import urlparse logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -class BaseAPI: - """Base class for API methods +class ResourceBase: + """Base class for JSONAPI resource methods.""" - This class includes basic implementations of the - get(), send() and delete() methods used in the API client. - - Keyword Arguments: - session: APISession for the farmOS instance - entity_type: String, used to set the entity type in the path - of all requests used with the API - """ - - def __init__(self, session, entity_type=None): + def __init__(self, session): self.session = session - self.entity_type = entity_type - self.filters = {} - - def _get_single_record_data(self, id): - """Retrieve one record given the record ID""" - # Set path to return record type by specific ID - path = self.entity_type + "/" + str(id) + ".json" - - logger.debug( - "Getting single record data for id: %s of entity type: %s", - id, - self.entity_type, - ) - response = self.session.http_request(path=path) - - return response.json() + self.params = {} - def _get_record_data(self, filters): - """Retrieve one page of raw record data from the farmOS API.""" - # Set path to return record type + filters - path = self.entity_type + ".json" - # Combine instance filters and filters from the method call - filters = {**self.filters, **filters} - - response = self.session.http_request(path=path, params=filters) + def _get_records(self, entity_type, bundle=None, resource_id=None, params=None): + """Helper function that checks to retrieve one record, one page or multiple pages of farmOS records""" + if params is None: + params = {} - # Return object - data = {} + params = {**self.params, **params} - response = response.json() - if "list" in response: - data["list"] = response["list"] - data["page"] = { - "self": _parse_api_page(url=response["self"]), - "first": _parse_api_page(url=response["first"]), - "last": _parse_api_page(url=response["last"]), - } + path = self._get_resource_path(entity_type, bundle, resource_id) - return data + response = self.session.http_request(path=path, params=params) + return response.json() - def _get_all_record_data(self, filters, page=0, list=None): - """Recursive function to retrieve multiple pages of raw record data from the farmOS API.""" - if list is None: - list = [] + def get(self, entity_type, bundle=None, params=None): + return self._get_records(entity_type=entity_type, bundle=bundle, params=params) - filters["page"] = page + def get_id(self, entity_type, bundle=None, resource_id=None, params=None): + return self._get_records( + entity_type=entity_type, + bundle=bundle, + params=params, + resource_id=resource_id, + ) - logger.debug( - "Getting page: %s of record data of entity type: %s", - filters["page"], - self.entity_type, + def iterate(self, entity_type, bundle=None, params=None): + response = self._get_records( + entity_type=entity_type, bundle=bundle, params=params ) - data = self._get_record_data(filters=filters) - - # Append record data to list of all requested data - if "list" in data: - list = list + data["list"] - - # Check to see if there are more pages - if "page" in data: - last_page = data["page"]["last"] - # Last page, return the list - if last_page == page: - data = { - "page": { - "first": data["page"]["first"], - "last": data["page"]["last"], - }, - "list": list, - } - return data - # Recursive call, get the next page - else: - return self._get_all_record_data( - list=list, page=(page + 1), filters=filters - ) - - def _get_records(self, filters=None): - """Helper function that checks to retrieve one record, one page or multiple pages of farmOS records""" - if filters is None: - filters = {} - - # Determine if filters is an int (id) or dict (filters object) - if isinstance(filters, int) or isinstance(filters, str): - return self._get_single_record_data(filters) - elif isinstance(filters, dict): - # Check if the caller requests a specific page - if "page" in filters: - logger.debug( - "Getting page: %s of record data of entity type: %s", - filters["page"], - self.entity_type, - ) - return self._get_record_data(filters=filters) - else: - logger.debug( - "Getting all record data of entity type: %s", self.entity_type - ) - return self._get_all_record_data(filters=filters) - - def get(self, filters=None): - """Simple get method""" - - data = self._get_records(filters=filters) - - return data - - def send(self, payload): - options = {"json": payload} + more = True + while more: + # TODO: Should we merge in the "includes" info here? + yield from response["data"] + try: + next_url = response["links"]["next"]["href"] + parsed_url = urlparse(next_url) + next_path = parsed_url._replace(scheme="", netloc="").geturl() + response = self.session.http_request(path=next_path) + response = response.json() + except KeyError: + more = False + + def send(self, entity_type, bundle=None, payload=None): + # Default to empty payload dict. + if payload is None: + payload = {} + + # Set the resource type. + payload["type"] = self._get_resource_type(entity_type, bundle) + json_payload = { + "data": {**payload}, + } + options = {"json": json_payload} # If an ID is included, update the record id = payload.pop("id", None) if id: - logger.debug("Updating record id: of entity type: %s", id, self.entity_type) - path = self.entity_type + "/" + str(id) + json_payload["data"]["id"] = id + logger.debug("Updating record id: of entity type: %s", id, entity_type) + path = self._get_resource_path( + entity_type=entity_type, bundle=bundle, record_id=id + ) response = self.session.http_request( - method="PUT", path=path, options=options + method="PATCH", path=path, options=options ) # If no ID is included, create a new record else: - logger.debug("Creating record of entity type: %s", self.entity_type) - path = self.entity_type + logger.debug("Creating record of entity type: %s", entity_type) + path = self._get_resource_path(entity_type=entity_type, bundle=bundle) response = self.session.http_request( method="POST", path=path, options=options ) @@ -148,112 +86,141 @@ def send(self, payload): # Handle response from POST requests if response.status_code == 201: logger.debug("Record created.") - return response.json() # Handle response from PUT requests if response.status_code == 200: logger.debug("Record updated.") - # farmOS returns no response data for PUT requests - # response_data = response.json() - # Hard code the entity ID, path, and resource - entity_data = { - "id": id, - "uri": path, - "resource": self.entity_type, - } - return entity_data + return response.json() - def delete(self, id): - logger.debug("Deleted record id: %s of entity type: %s", id, self.entity_type) - path = self.entity_type + "/" + str(id) - response = self.session.http_request(method="DELETE", path=path) + def delete(self, entity_type, bundle=None, id=None): + logger.debug("Deleted record id: %s of entity type: %s", id, entity_type) + path = self._get_resource_path( + entity_type=entity_type, bundle=bundle, record_id=id + ) + return self.session.http_request(method="DELETE", path=path) - return response + @staticmethod + def _get_resource_path(entity_type, bundle=None, record_id=None): + """Helper function that builds paths to jsonapi resources.""" + if bundle is None: + bundle = entity_type -class TermAPI(BaseAPI): - """API for interacting with farm Terms""" + path = "api/" + entity_type + "/" + bundle - def __init__(self, session): - # Define 'taxonomy_term' as the farmOS API entity endpoint - super().__init__(session=session, entity_type="taxonomy_term") + if record_id: + path += "/" + str(record_id) - def get(self, filters=None): - """Get method that supports a bundle name as the 'filter' parameter""" + return path - # Check if filters parameter is a str - if isinstance(filters, str): - # Add filters to instance requests.session filter dict with keyword 'bundle' - self.filters["bundle"] = filters - # Reset filters to empty dict - filters = {} + @staticmethod + def _get_resource_type(entity_type, bundle=None): + """Helper function that builds a JSONAPI resource name.""" - data = self._get_records(filters=filters) + if bundle is None: + bundle = entity_type - return data + return entity_type + "--" + bundle -class LogAPI(BaseAPI): - """API for interacting with farm logs""" +class ResourceHelperBase: + def __init__(self, session, entity_type): + self.entity_type = entity_type + self.resource_api = ResourceBase(session=session) - def __init__(self, session): - # Define 'log' as the farmOS API entity endpoint - super().__init__(session=session, entity_type="log") + def get(self, bundle, params=None): + return self.resource_api.get( + entity_type=self.entity_type, bundle=bundle, params=params + ) + def get_id(self, bundle, resource_id, params=None): + return self.resource_api.get_id( + entity_type=self.entity_type, + bundle=bundle, + resource_id=resource_id, + params=params, + ) -class AssetAPI(BaseAPI): - """API for interacting with farm assets""" + def iterate(self, bundle, params=None): + return self.resource_api.iterate( + entity_type=self.entity_type, bundle=bundle, params=params + ) - def __init__(self, session): - # Define 'farm_asset' as the farmOS API entity endpoint - super().__init__(session=session, entity_type="farm_asset") + def send(self, bundle, payload=None): + return self.resource_api.send( + entity_type=self.entity_type, bundle=bundle, payload=payload + ) + + def delete(self, bundle, id): + return self.resource_api.delete( + entity_type=self.entity_type, bundle=bundle, id=id + ) -class AreaAPI(TermAPI): - """API for interacting with farm areas, a subset of farm terms""" +class AssetAPI(ResourceHelperBase): + """API for interacting with farm assets""" def __init__(self, session): - super().__init__(session=session) - self.filters["bundle"] = "farm_areas" + # Define 'asset' as the JSONAPI resource type. + super().__init__(session=session, entity_type="asset") - def get(self, filters=None): - """Retrieve raw record data from the farmOS API. - Override get() from BaseAPI to support TID (Taxonomy ID) - rather than record ID - """ +class LogAPI(ResourceHelperBase): + """API for interacting with farm logs""" - # Determine if filters is an int (tid) or dict (filters object) - if isinstance(filters, int) or isinstance(filters, str): - tid = str(filters) - # Add tid to filters object - filters = { - "tid": tid, - } + def __init__(self, session): + # Define 'log' as the JSONAPI resource type. + super().__init__(session=session, entity_type="log") - data = self._get_records(filters=filters) - return data +class TermAPI(ResourceHelperBase): + """API for interacting with farm Terms""" + + def __init__(self, session): + # Define 'taxonomy_term' as the farmOS API entity endpoint + super().__init__(session=session, entity_type="taxonomy_term") def info(session): """Retrieve info about the farmOS server.""" logger.debug("Retrieving farmOS server info.") - response = session.http_request("farm.json") + response = session.http_request("api") return response.json() -def _parse_api_page(url): - """Helper function that returns page numbers from the API response. +def filter(path, value, operation="="): + """Helper method to build JSONAPI filter query params.""" + + # TODO: Validate path, value, operation? + # TODO: Support filter groups. + # TODO: Support revision filters, are these edge cases? + # TODO: Support "pagination" query params? Separate method? + # TODO: Support "sort" query params? Separate method? + # TODO: Support "includes" query params? Separate method? + + filters = {} + + # If the operation is '=', only one query param is required. + if operation == "=": + param = f"filter[{path}]" + filters[param] = value + # Else we need a query param for the path, operation, and value. + else: + # TODO: Use a unique identifier instead of using "condition" + # otherwise the same path cannot be filtered multiple times. + base_param = f"filter[{path}_{operation.lower()}][condition]" + + path_param = base_param + "[path]" + filters[path_param] = path - The farmOS API returns URLs for the self, first and last pages of records - in requests that return many records. This function helps parse a raw page - number from the returned URL. - """ + op_param = base_param + "[operator]" + filters[op_param] = operation - parsed_url = urlparse(url) - page_num = parse_qs(parsed_url.query)["page"][0] + value_param = base_param + "[value]" + if operation in ["IN", "NOT IN", ">", "<", "<>", "BETWEEN"]: + value_param += "[]" + filters[value_param] = value - return int(page_num) + return filters diff --git a/farmOS/client_2.py b/farmOS/client_2.py deleted file mode 100644 index 2ccde6e..0000000 --- a/farmOS/client_2.py +++ /dev/null @@ -1,226 +0,0 @@ -import logging -from urllib.parse import urlparse - -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) - - -class ResourceBase: - """Base class for JSONAPI resource methods.""" - - def __init__(self, session): - self.session = session - self.params = {} - - def _get_records(self, entity_type, bundle=None, resource_id=None, params=None): - """Helper function that checks to retrieve one record, one page or multiple pages of farmOS records""" - if params is None: - params = {} - - params = {**self.params, **params} - - path = self._get_resource_path(entity_type, bundle, resource_id) - - response = self.session.http_request(path=path, params=params) - return response.json() - - def get(self, entity_type, bundle=None, params=None): - return self._get_records(entity_type=entity_type, bundle=bundle, params=params) - - def get_id(self, entity_type, bundle=None, resource_id=None, params=None): - return self._get_records( - entity_type=entity_type, - bundle=bundle, - params=params, - resource_id=resource_id, - ) - - def iterate(self, entity_type, bundle=None, params=None): - response = self._get_records( - entity_type=entity_type, bundle=bundle, params=params - ) - more = True - while more: - # TODO: Should we merge in the "includes" info here? - yield from response["data"] - try: - next_url = response["links"]["next"]["href"] - parsed_url = urlparse(next_url) - next_path = parsed_url._replace(scheme="", netloc="").geturl() - response = self.session.http_request(path=next_path) - response = response.json() - except KeyError: - more = False - - def send(self, entity_type, bundle=None, payload=None): - # Default to empty payload dict. - if payload is None: - payload = {} - - # Set the resource type. - payload["type"] = self._get_resource_type(entity_type, bundle) - json_payload = { - "data": {**payload}, - } - options = {"json": json_payload} - - # If an ID is included, update the record - id = payload.pop("id", None) - if id: - json_payload["data"]["id"] = id - logger.debug("Updating record id: of entity type: %s", id, entity_type) - path = self._get_resource_path( - entity_type=entity_type, bundle=bundle, record_id=id - ) - response = self.session.http_request( - method="PATCH", path=path, options=options - ) - # If no ID is included, create a new record - else: - logger.debug("Creating record of entity type: %s", entity_type) - path = self._get_resource_path(entity_type=entity_type, bundle=bundle) - response = self.session.http_request( - method="POST", path=path, options=options - ) - - # Handle response from POST requests - if response.status_code == 201: - logger.debug("Record created.") - - # Handle response from PUT requests - if response.status_code == 200: - logger.debug("Record updated.") - - return response.json() - - def delete(self, entity_type, bundle=None, id=None): - logger.debug("Deleted record id: %s of entity type: %s", id, entity_type) - path = self._get_resource_path( - entity_type=entity_type, bundle=bundle, record_id=id - ) - return self.session.http_request(method="DELETE", path=path) - - @staticmethod - def _get_resource_path(entity_type, bundle=None, record_id=None): - """Helper function that builds paths to jsonapi resources.""" - - if bundle is None: - bundle = entity_type - - path = "api/" + entity_type + "/" + bundle - - if record_id: - path += "/" + str(record_id) - - return path - - @staticmethod - def _get_resource_type(entity_type, bundle=None): - """Helper function that builds a JSONAPI resource name.""" - - if bundle is None: - bundle = entity_type - - return entity_type + "--" + bundle - - -class ResourceHelperBase: - def __init__(self, session, entity_type): - self.entity_type = entity_type - self.resource_api = ResourceBase(session=session) - - def get(self, bundle, params=None): - return self.resource_api.get( - entity_type=self.entity_type, bundle=bundle, params=params - ) - - def get_id(self, bundle, resource_id, params=None): - return self.resource_api.get_id( - entity_type=self.entity_type, - bundle=bundle, - resource_id=resource_id, - params=params, - ) - - def iterate(self, bundle, params=None): - return self.resource_api.iterate( - entity_type=self.entity_type, bundle=bundle, params=params - ) - - def send(self, bundle, payload=None): - return self.resource_api.send( - entity_type=self.entity_type, bundle=bundle, payload=payload - ) - - def delete(self, bundle, id): - return self.resource_api.delete( - entity_type=self.entity_type, bundle=bundle, id=id - ) - - -class AssetAPI(ResourceHelperBase): - """API for interacting with farm assets""" - - def __init__(self, session): - # Define 'asset' as the JSONAPI resource type. - super().__init__(session=session, entity_type="asset") - - -class LogAPI(ResourceHelperBase): - """API for interacting with farm logs""" - - def __init__(self, session): - # Define 'log' as the JSONAPI resource type. - super().__init__(session=session, entity_type="log") - - -class TermAPI(ResourceHelperBase): - """API for interacting with farm Terms""" - - def __init__(self, session): - # Define 'taxonomy_term' as the farmOS API entity endpoint - super().__init__(session=session, entity_type="taxonomy_term") - - -def info(session): - """Retrieve info about the farmOS server.""" - - logger.debug("Retrieving farmOS server info.") - response = session.http_request("api") - return response.json() - - -def filter(path, value, operation="="): - """Helper method to build JSONAPI filter query params.""" - - # TODO: Validate path, value, operation? - # TODO: Support filter groups. - # TODO: Support revision filters, are these edge cases? - # TODO: Support "pagination" query params? Separate method? - # TODO: Support "sort" query params? Separate method? - # TODO: Support "includes" query params? Separate method? - - filters = {} - - # If the operation is '=', only one query param is required. - if operation == "=": - param = f"filter[{path}]" - filters[param] = value - # Else we need a query param for the path, operation, and value. - else: - # TODO: Use a unique identifier instead of using "condition" - # otherwise the same path cannot be filtered multiple times. - base_param = f"filter[{path}_{operation.lower()}][condition]" - - path_param = base_param + "[path]" - filters[path_param] = path - - op_param = base_param + "[operator]" - filters[op_param] = operation - - value_param = base_param + "[value]" - if operation in ["IN", "NOT IN", ">", "<", "<>", "BETWEEN"]: - value_param += "[]" - filters[value_param] = value - - return filters From 122e7a3daac4c5225848005bc88a9362f0e2295f Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 15 Feb 2024 15:37:27 -0800 Subject: [PATCH 02/16] Rename client.py to resource.py --- farmOS/__init__.py | 14 +++++++------- farmOS/{client.py => resource.py} | 0 2 files changed, 7 insertions(+), 7 deletions(-) rename farmOS/{client.py => resource.py} (100%) diff --git a/farmOS/__init__.py b/farmOS/__init__.py index ef530e0..88610dc 100644 --- a/farmOS/__init__.py +++ b/farmOS/__init__.py @@ -3,7 +3,7 @@ from functools import partial from urllib.parse import urlparse, urlunparse -from . import client, subrequests +from . import resource, subrequests from .session import OAuthSession logger = logging.getLogger(__name__) @@ -119,13 +119,13 @@ def __init__( "initializing a farmOS Client." ) - self.log = client.LogAPI(self.session) - self.asset = client.AssetAPI(self.session) - self.term = client.TermAPI(self.session) - self.resource = client.ResourceBase(self.session) - self.info = partial(client.info, self.session) + self.log = resource.LogAPI(self.session) + self.asset = resource.AssetAPI(self.session) + self.term = resource.TermAPI(self.session) + self.resource = resource.ResourceBase(self.session) + self.info = partial(resource.info, self.session) self.subrequests = subrequests.SubrequestsBase(self.session) - self.filter = client.filter + self.filter = resource.filter def authorize(self, username=None, password=None, scope=None): """Authorize with the farmOS server. diff --git a/farmOS/client.py b/farmOS/resource.py similarity index 100% rename from farmOS/client.py rename to farmOS/resource.py From 92c60653b181ba9783bb71d1e3af23a0deb7248b Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 15 Feb 2024 15:58:04 -0800 Subject: [PATCH 03/16] Extend the httpx Client with resource methods --- farmOS/client.py | 16 ++++++++++++++++ farmOS/resource.py | 44 +++++++++++++++++++++++++------------------- 2 files changed, 41 insertions(+), 19 deletions(-) create mode 100644 farmOS/client.py diff --git a/farmOS/client.py b/farmOS/client.py new file mode 100644 index 0000000..7af326b --- /dev/null +++ b/farmOS/client.py @@ -0,0 +1,16 @@ +from functools import partial + +from httpx import Client + +from farmOS import resource + + +class FarmClient(Client): + def __init__(self, hostname, **kwargs): + super().__init__(base_url=hostname, **kwargs) + self.info = partial(resource.info, self) + self.filter = resource.filter + self.resource = resource.ResourceBase(self) + self.log = resource.LogAPI(self) + self.asset = resource.AssetAPI(self) + self.term = resource.TermAPI(self) diff --git a/farmOS/resource.py b/farmOS/resource.py index 2ccde6e..aecf675 100644 --- a/farmOS/resource.py +++ b/farmOS/resource.py @@ -8,8 +8,8 @@ class ResourceBase: """Base class for JSONAPI resource methods.""" - def __init__(self, session): - self.session = session + def __init__(self, client): + self.client = client self.params = {} def _get_records(self, entity_type, bundle=None, resource_id=None, params=None): @@ -21,7 +21,7 @@ def _get_records(self, entity_type, bundle=None, resource_id=None, params=None): path = self._get_resource_path(entity_type, bundle, resource_id) - response = self.session.http_request(path=path, params=params) + response = self.client.request(method="GET", url=path, params=params) return response.json() def get(self, entity_type, bundle=None, params=None): @@ -47,7 +47,7 @@ def iterate(self, entity_type, bundle=None, params=None): next_url = response["links"]["next"]["href"] parsed_url = urlparse(next_url) next_path = parsed_url._replace(scheme="", netloc="").geturl() - response = self.session.http_request(path=next_path) + response = self.client.request(method="GET", url=next_path) response = response.json() except KeyError: more = False @@ -72,15 +72,21 @@ def send(self, entity_type, bundle=None, payload=None): path = self._get_resource_path( entity_type=entity_type, bundle=bundle, record_id=id ) - response = self.session.http_request( - method="PATCH", path=path, options=options + response = self.client.request( + method="PATCH", + url=path, + json=json_payload, + headers={"Content-Type": "application/vnd.api+json"}, ) # If no ID is included, create a new record else: logger.debug("Creating record of entity type: %s", entity_type) path = self._get_resource_path(entity_type=entity_type, bundle=bundle) - response = self.session.http_request( - method="POST", path=path, options=options + response = self.client.request( + method="POST", + url=path, + json=json_payload, + headers={"Content-Type": "application/vnd.api+json"}, ) # Handle response from POST requests @@ -98,7 +104,7 @@ def delete(self, entity_type, bundle=None, id=None): path = self._get_resource_path( entity_type=entity_type, bundle=bundle, record_id=id ) - return self.session.http_request(method="DELETE", path=path) + return self.client.request(method="DELETE", url=path) @staticmethod def _get_resource_path(entity_type, bundle=None, record_id=None): @@ -125,9 +131,9 @@ def _get_resource_type(entity_type, bundle=None): class ResourceHelperBase: - def __init__(self, session, entity_type): + def __init__(self, client, entity_type): self.entity_type = entity_type - self.resource_api = ResourceBase(session=session) + self.resource_api = ResourceBase(client=client) def get(self, bundle, params=None): return self.resource_api.get( @@ -161,32 +167,32 @@ def delete(self, bundle, id): class AssetAPI(ResourceHelperBase): """API for interacting with farm assets""" - def __init__(self, session): + def __init__(self, client): # Define 'asset' as the JSONAPI resource type. - super().__init__(session=session, entity_type="asset") + super().__init__(client=client, entity_type="asset") class LogAPI(ResourceHelperBase): """API for interacting with farm logs""" - def __init__(self, session): + def __init__(self, client): # Define 'log' as the JSONAPI resource type. - super().__init__(session=session, entity_type="log") + super().__init__(client=client, entity_type="log") class TermAPI(ResourceHelperBase): """API for interacting with farm Terms""" - def __init__(self, session): + def __init__(self, client): # Define 'taxonomy_term' as the farmOS API entity endpoint - super().__init__(session=session, entity_type="taxonomy_term") + super().__init__(client=client, entity_type="taxonomy_term") -def info(session): +def info(client): """Retrieve info about the farmOS server.""" logger.debug("Retrieving farmOS server info.") - response = session.http_request("api") + response = client.request(method="GET", url="api") return response.json() From 144091b13bd999293bc4fdd4f24a139de8b64f8d Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 15 Feb 2024 16:00:37 -0800 Subject: [PATCH 04/16] Export FarmClient from __init__.py --- farmOS/__init__.py | 143 +------------------------------------- farmOS/session.py | 168 --------------------------------------------- 2 files changed, 2 insertions(+), 309 deletions(-) delete mode 100644 farmOS/session.py diff --git a/farmOS/__init__.py b/farmOS/__init__.py index 88610dc..b37f04d 100644 --- a/farmOS/__init__.py +++ b/farmOS/__init__.py @@ -1,142 +1,3 @@ -import logging -from datetime import datetime -from functools import partial -from urllib.parse import urlparse, urlunparse +from .client import FarmClient -from . import resource, subrequests -from .session import OAuthSession - -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) - - -class farmOS: - """A client that connects to the farmOS server.""" - - def __init__( - self, - hostname, - client_id="farm", - client_secret=None, - scope="farm_manager", - token=None, - token_updater=lambda new_token: None, - ): - """ - Initialize instance of the farmOS client that connects to a single farmOS server. - - :param hostname: Valid hostname without a path or query params. The HTTPS scheme - will be added if none is specified. - :param client_id: OAuth Client ID. Defaults to "farm" - :param client_secret: OAuth Client Secret. Defaults to None. - :param scope: OAuth Scope. Defaults to "farm_manager". - :param token: An existing OAuth token to use. - :param token_updater: A function used to save OAuth tokens outside of the client. - """ - - logger.debug("Creating farmOS client.") - - # Save the token_updater function. - self.token_updater = token_updater - - # Save the session. - self.session = None - - if hostname is not None: - valid_schemes = ["http", "https"] - default_scheme = "https" - parsed_url = urlparse(hostname) - - # Validate the hostname. - # Add a default scheme if not provided. - if not parsed_url.scheme: - parsed_url = parsed_url._replace(scheme=default_scheme) - logger.debug("No scheme provided. Using %s", default_scheme) - - # Check for a valid scheme. - if parsed_url.scheme not in valid_schemes: - raise Exception("Not a valid scheme.") - - # If not netloc was provided, it was probably parsed as the path. - if not parsed_url.netloc and parsed_url.path: - parsed_url = parsed_url._replace(netloc=parsed_url.path) - parsed_url = parsed_url._replace(path="") - - # Check for netloc. - if not parsed_url.netloc: - raise Exception("Invalid hostname. Must have netloc.") - - # Don't allow path, params, or query. - if parsed_url.path or parsed_url.params or parsed_url.query: - raise Exception("Hostname cannot include path and query parameters.") - - # Build the url again to include changes. - hostname = urlunparse(parsed_url) - logger.debug("Complete hostname configured as %s", hostname) - - else: - raise Exception("No hostname provided and could not be loaded from config.") - - logger.debug("Creating an OAuth Session.") - # OR implement a method to check both token paths. - # maybe version can default to none, and check the server? - token_url = hostname + "/oauth/token" - - # Check the token expiration time. - if token is not None and "expires_at" in token: - # Create datetime objects for comparison. - now = datetime.now() - expiration_time = datetime.fromtimestamp(float(token["expires_at"])) - - # Calculate seconds until expiration. - timedelta = expiration_time - now - expires_in = timedelta.total_seconds() - - # Update the token expires_in value - token["expires_in"] = expires_in - - # Unset the 'expires_at' key. - token.pop("expires_at") - - # Create an OAuth Session - self.session = OAuthSession( - hostname=hostname, - client_id=client_id, - client_secret=client_secret, - scope=scope, - token=token, - token_url=token_url, - content_type="application/vnd.api+json", - token_updater=self.token_updater, - ) - - self._client_id = client_id - self._client_secret = client_secret - - if self.session is None: - raise Exception( - "Could not create a session object. Supply authentication credentials when " - "initializing a farmOS Client." - ) - - self.log = resource.LogAPI(self.session) - self.asset = resource.AssetAPI(self.session) - self.term = resource.TermAPI(self.session) - self.resource = resource.ResourceBase(self.session) - self.info = partial(resource.info, self.session) - self.subrequests = subrequests.SubrequestsBase(self.session) - self.filter = resource.filter - - def authorize(self, username=None, password=None, scope=None): - """Authorize with the farmOS server. - - The client must be authorized with the farmOS server before making requests. - This method utilizes the OAuth Password Credentials flow to authorize users. - - :param username: farmOS Username. Prompted if not included. - :param password: farmOS Password. Prompted if not included. - :param scope: Scope to authorize as with the farmOS server. Defaults to "farm_manager". - :return: OAuth Token. - """ - - return self.session.authorize(username, password, scope) +__all__ = ["FarmClient"] diff --git a/farmOS/session.py b/farmOS/session.py deleted file mode 100644 index 1f3f2d8..0000000 --- a/farmOS/session.py +++ /dev/null @@ -1,168 +0,0 @@ -import logging - -from oauthlib.oauth2 import LegacyApplicationClient -from requests_oauthlib import OAuth2Session - -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) - - -class OAuthSession(OAuth2Session): - """OAuthSession uses OAuth2 to authenticate with farmOS""" - - def __init__( - self, - hostname, - client_id, - client_secret=None, - scope=None, - token=None, - token_url=None, - authorization_url=None, - content_type="application/vnd.api+json", - token_updater=None, - *args, - **kwargs, - ): - # Default to the "farm_manager" scope. - if scope is None: - scope = "farm_manager" - - # Create a dictionary of credentials required to pass along with Refresh Tokens - # Required to generate a new access token - auto_refresh_kwargs = {"client_id": client_id, "client_secret": client_secret} - - super().__init__( - token=token, - client=LegacyApplicationClient(client_id=client_id), - auto_refresh_url=token_url, - auto_refresh_kwargs=auto_refresh_kwargs, - token_updater=token_updater, - scope=scope, - ) - - # Save values for later use. - self._token_url = token_url - self._authorization_base_url = authorization_url - self._content_type = content_type - self._client_id = client_id - self._client_secret = client_secret - self.hostname = hostname - - def authorize(self, username, password, scope=None): - """Authorize with the farmOS OAuth server.""" - - token = self.token - - logger.debug("Retrieving new OAuth Token.") - - # Ask for username if not provided. - if username is None: - from getpass import getpass - - username = getpass("Enter username: ") - - # Ask for password if not provided. - if password is None: - from getpass import getpass - - password = getpass("Enter password: ") - - # Use default scope if none provided. - if scope is None: - scope = self.scope - - token = self.fetch_token( - token_url=self._token_url, - client_id=self._client_id, - client_secret=self._client_secret, - username=username, - password=password, - scope=scope, - ) - - logger.debug("Fetched OAuth Access Token %s", token) - - # Save the token. - logger.debug("Saving token with token_updater utility.") - if self.token_updater is not None: - self.token_updater(token) - - return token - - def http_request(self, path, method="GET", options=None, params=None, headers=None): - """Raw HTTP request helper function. - - Keyword arguments: - :param path: the URL path. - :param method: the HTTP method. - :param options: a dictionary of data and parameters to pass on to the request. - :param params: URL query parameters. - :return: requests response object. - """ - - # Strip protocol, hostname, leading/trailing slashes, and whitespace from the path. - path = path.strip("/") - path = path.strip() - - # Assemble the URL. - url = f"{self.hostname}/{path}" - return self._http_request( - url=url, method=method, options=options, params=params, headers=headers - ) - - def _http_request(self, url, method="GET", options=None, params=None, headers=None): - # Automatically follow redirects, unless this is a POST request. - # The Python requests library converts POST to GET during a redirect. - # Allow this to be overridden in options. - allow_redirects = True - if method in ["POST", "PUT"]: - allow_redirects = False - if options and "allow_redirects" in options: - allow_redirects = options["allow_redirects"] - - if headers is None: - headers = {} - - # If there is data to be sent, include it. - data = None - if options and "data" in options: - data = options["data"] - headers["Content-Type"] = self._content_type - - # If there is a json data to be sent, include it. - json = None - if options and "json" in options: - json = options["json"] - if "Content-Type" not in headers: - headers["Content-Type"] = self._content_type - - # Perform the request. - response = self.request( - method, - url, - headers=headers, - allow_redirects=allow_redirects, - data=data, - json=json, - params=params, - ) - - # If this is a POST request, and a redirect occurred, attempt to re-POST. - redirect_codes = [300, 301, 302, 303, 304, 305, 306, 307, 308] - if method in ["POST", "PUT"] and response.status_code in redirect_codes: - if response.headers["Location"]: - response = self.request( - method, - response.headers["Location"], - allow_redirects=True, - data=data, - json=json, - params=params, - ) - - # Raise exception if error. - response.raise_for_status() - - # Return the response. - return response From 41ab6e14399137c09503dc38d216723fac732b25 Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 15 Feb 2024 16:11:04 -0800 Subject: [PATCH 05/16] Update tests --- tests/conftest.py | 13 +++--- tests/functional/test_auth.py | 74 ++++++++++++++++++++----------- tests/functional/test_resource.py | 8 ++-- 3 files changed, 59 insertions(+), 36 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9706ab2..4b9cef4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,9 @@ import os +from httpx_auth import OAuth2ResourceOwnerPasswordCredentials import pytest -import farmOS +from farmOS import FarmClient # Allow testing via http. os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" @@ -30,13 +31,15 @@ @pytest.fixture(scope="module") def test_farm(): if valid_oauth_config: - farm = farmOS.farmOS( - hostname=FARMOS_HOSTNAME, + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, client_id=FARMOS_OAUTH_CLIENT_ID, client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", ) - farm.authorize(username=FARMOS_OAUTH_USERNAME, password=FARMOS_OAUTH_PASSWORD) - return farm + return FarmClient(hostname=FARMOS_HOSTNAME, auth=auth) @pytest.fixture(scope="module") diff --git a/tests/functional/test_auth.py b/tests/functional/test_auth.py index 25a718f..eaf08f3 100644 --- a/tests/functional/test_auth.py +++ b/tests/functional/test_auth.py @@ -1,36 +1,49 @@ import os import time +from httpx_auth import InvalidGrantRequest +from httpx_auth import OAuth2ResourceOwnerPasswordCredentials import pytest -from oauthlib.oauth2 import InvalidClientError, InvalidGrantError, InvalidScopeError -from requests import HTTPError -from farmOS import farmOS +from farmOS import FarmClient from tests.conftest import farmOS_testing_server # Variables for testing. FARMOS_HOSTNAME = os.getenv("FARMOS_HOSTNAME") FARMOS_OAUTH_USERNAME = os.getenv("FARMOS_OAUTH_USERNAME") FARMOS_OAUTH_PASSWORD = os.getenv("FARMOS_OAUTH_PASSWORD") +FARMOS_OAUTH_CLIENT_ID = os.getenv("FARMOS_OAUTH_CLIENT_ID", "farm") +FARMOS_OAUTH_CLIENT_SECRET = os.getenv("FARMOS_OAUTH_CLIENT_SECRET", None) @farmOS_testing_server def test_invalid_login(): - with pytest.raises(InvalidGrantError): - farm = farmOS(hostname=FARMOS_HOSTNAME, scope="farm_manager", version=2) - farm.authorize("username", "password") + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username="username", + password="password", + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", + ) + farm = FarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + farm.info() @farmOS_testing_server def test_invalid_client_id(): - with pytest.raises(InvalidClientError): - farm = farmOS( - hostname=FARMOS_HOSTNAME, - scope="farm_manager", + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, client_id="bad_client", - version=2, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", ) - farm.authorize(FARMOS_OAUTH_USERNAME, FARMOS_OAUTH_PASSWORD) + farm = FarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + farm.info() @farmOS_testing_server @@ -38,27 +51,36 @@ def test_invalid_client_id(): reason="simple_oauth seems to accept any secret if none is configured on the client." ) def test_invalid_client_secret(): - with pytest.raises(InvalidClientError): - farm = farmOS(FARMOS_HOSTNAME, client_id="farm", client_secret="bad_pass") - farm.authorize(FARMOS_OAUTH_USERNAME, FARMOS_OAUTH_PASSWORD) + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret="bad secret", + scope="farm_manager", + ) + farm = FarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + farm.info() @farmOS_testing_server def test_invalid_scope(): - with pytest.raises(InvalidScopeError): - farm = farmOS(hostname=FARMOS_HOSTNAME, scope="bad_scope", version=2) - farm.authorize(FARMOS_OAUTH_USERNAME, FARMOS_OAUTH_PASSWORD, scope="bad_scope") - - -@farmOS_testing_server -@pytest.mark.skip(reason="JSONAPI endpoints don't return 403.") -def test_unauthorized_request(test_farm): - with pytest.raises(HTTPError, match=r"403 *."): - farm = farmOS(hostname=FARMOS_HOSTNAME, scope="farm_manager", version=2) - farm.log.get("activity") + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="bad_scope", + ) + farm = FarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + farm.info() @farmOS_testing_server +@pytest.mark.skip(reason="Not implemented yet.") def test_valid_login(test_farm): token = test_farm.authorize( username=FARMOS_OAUTH_USERNAME, password=FARMOS_OAUTH_PASSWORD diff --git a/tests/functional/test_resource.py b/tests/functional/test_resource.py index 92be48f..98ece64 100644 --- a/tests/functional/test_resource.py +++ b/tests/functional/test_resource.py @@ -1,10 +1,7 @@ -import os +from httpx_auth import OAuth2 from tests.conftest import farmOS_testing_server -FARMOS_OAUTH_USERNAME = os.getenv("FARMOS_OAUTH_USERNAME") -FARMOS_OAUTH_PASSWORD = os.getenv("FARMOS_OAUTH_PASSWORD") - # todo: Expand these tests to include a CRUD. Currently limited by user permissions. @@ -22,7 +19,8 @@ def test_user_update_self(test_farm): patch_response = test_farm.resource.send("user", payload=user_changes) # Re-authorize the user after changing their profile. - test_farm.authorize(username=FARMOS_OAUTH_USERNAME, password=FARMOS_OAUTH_PASSWORD) + state, token, expires_in, refresh = test_farm.auth.request_new_token() + OAuth2.token_cache._add_access_token(state, token, expires_in) # Get the user by ID. get_response = test_farm.resource.get_id("user", resource_id=user_id) From 977eabc5492801b8a2a700a240aa543c556446a0 Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Wed, 13 Mar 2024 16:44:22 -0700 Subject: [PATCH 06/16] Fix valid login test --- .github/workflows/run-tests.yml | 1 - tests/functional/test_auth.py | 30 ++++++++++++++++-------------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 9efdd0d..32075dc 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -34,7 +34,6 @@ jobs: docker-compose exec -u www-data -T www drush site-install -y --db-url=pgsql://farm:farm@db/farm --account-pass=admin docker-compose exec -u www-data -T www drush user-create tester --password test docker-compose exec -u www-data -T www drush user-add-role farm_manager tester - docker-compose exec -u www-data -T www drush config:set simple_oauth.settings access_token_expiration 15 -y - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: diff --git a/tests/functional/test_auth.py b/tests/functional/test_auth.py index eaf08f3..d16fed4 100644 --- a/tests/functional/test_auth.py +++ b/tests/functional/test_auth.py @@ -80,23 +80,25 @@ def test_invalid_scope(): @farmOS_testing_server -@pytest.mark.skip(reason="Not implemented yet.") -def test_valid_login(test_farm): - token = test_farm.authorize( - username=FARMOS_OAUTH_USERNAME, password=FARMOS_OAUTH_PASSWORD +def test_valid_login(): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", ) + farm = FarmClient(hostname=FARMOS_HOSTNAME, auth=auth) - assert "access_token" in token - assert "refresh_token" in token - assert "expires_at" in token - assert "expires_in" in token + # Re-authorize the user after changing their profile. + state, token, expires_in, refresh = farm.auth.request_new_token() + assert ".ey" in token + assert 3600 == expires_in + assert refresh is not None - # Sleep until the token expires. - time.sleep(token["expires_in"] + 1) - - # Make a request that will trigger a refresh. - # Ensure the request is still authenticated. - info = test_farm.info() + # Check that the user info is provided at farm.info. + info = farm.info() assert "meta" in info assert "links" in info["meta"] assert "me" in info["meta"]["links"] From e5b0df9dfd6ee4f4d6ae861f3af487b83f8b63b0 Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 15 Feb 2024 16:15:32 -0800 Subject: [PATCH 07/16] Add support for subrequests --- farmOS/client.py | 3 ++- farmOS/subrequests.py | 19 +++++++------------ tests/functional/test_subrequests.py | 2 +- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/farmOS/client.py b/farmOS/client.py index 7af326b..33a4366 100644 --- a/farmOS/client.py +++ b/farmOS/client.py @@ -2,7 +2,7 @@ from httpx import Client -from farmOS import resource +from farmOS import resource, subrequests class FarmClient(Client): @@ -10,6 +10,7 @@ def __init__(self, hostname, **kwargs): super().__init__(base_url=hostname, **kwargs) self.info = partial(resource.info, self) self.filter = resource.filter + self.subrequests = subrequests.SubrequestsBase(self) self.resource = resource.ResourceBase(self) self.log = resource.LogAPI(self) self.asset = resource.AssetAPI(self) diff --git a/farmOS/subrequests.py b/farmOS/subrequests.py index 60f2073..99d15d0 100644 --- a/farmOS/subrequests.py +++ b/farmOS/subrequests.py @@ -4,8 +4,6 @@ from pydantic import BaseModel, Field, validator -from .session import OAuthSession - # Subrequests model derived from provided JSON Schema # https://git.drupalcode.org/project/subrequests/-/blob/3.x/schema.json @@ -86,8 +84,8 @@ class SubrequestsBase: subrequest_path = "subrequests" - def __init__(self, session: OAuthSession): - self.session = session + def __init__(self, client): + self.client = client def send( self, @@ -101,7 +99,7 @@ def send( for sub in blueprint.__root__: # Build the URI if an endpoint is provided. if sub.uri is None and sub.endpoint is not None: - sub.uri = f"{self.session.hostname}/{sub.endpoint}" + sub.uri = sub.endpoint # Set the endpoint to None so it is not included in the serialized subrequest. sub.endpoint = None @@ -112,8 +110,6 @@ def send( if sub.body is not None and "Content-Type" not in sub.headers: sub.headers["Content-Type"] = "application/vnd.api+json" - headers = {"Content-Type": "application/json"} - params = {} if format == Format.json.value: params = {"_format": "json"} @@ -121,14 +117,13 @@ def send( # Generate the json to send. It is important to use the .json() method # of the model for correct serialization. json = blueprint.json(exclude_none=True) - options = {"data": json} - response = self.session.http_request( + response = self.client.request( method="POST", - path=self.subrequest_path, - options=options, + url=self.subrequest_path, params=params, - headers=headers, + headers={"Content-Type": "application/json"}, + content=json, ) # Return a json response if requested. diff --git a/tests/functional/test_subrequests.py b/tests/functional/test_subrequests.py index d423e07..9f47ae2 100644 --- a/tests/functional/test_subrequests.py +++ b/tests/functional/test_subrequests.py @@ -116,7 +116,7 @@ def test_subrequests(test_farm): # Test that each response succeeded. assert response_key in post_response assert "headers" in post_response[response_key] - assert 201 == post_response[response_key]["headers"]["status"][0] + assert 201 == int(post_response[response_key]["headers"]["status"][0]) # Test that each resource was created. assert "body" in post_response[response_key] From 74a2b603de4d1fa83bfe17e732dda114996c9e14 Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 15 Feb 2024 16:15:39 -0800 Subject: [PATCH 08/16] Update deps --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bf48467..4bf2ef0 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ "pytest-runner", ] -install_requires = ["requests-oauthlib~=1.3.1", "pydantic~=1.7.3"] +install_requires = ["httpx~=0.26", "httpx_auth~=0.20", "pydantic~=1.7.3"] extras_require = { "test": ["pytest~=7.0", "black~=23.0", "setuptools~=68.0"], From 2ca825d283824a5f27c8387d91607c9007d34a07 Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Wed, 13 Mar 2024 16:28:37 -0700 Subject: [PATCH 09/16] Update python versions --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 32075dc..546f926 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -18,10 +18,10 @@ jobs: strategy: matrix: python-version: - - 3.8 - 3.9 - '3.10' - 3.11 + - 3.12 steps: - name: Checkout the repository uses: actions/checkout@v3 From 6565b0a4d7131dd95e1b34e29f433c725678b02c Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Wed, 13 Mar 2024 16:42:36 -0700 Subject: [PATCH 10/16] Farmos 3.x --- .github/workflows/run-tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 546f926..9649975 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -26,12 +26,13 @@ jobs: - name: Checkout the repository uses: actions/checkout@v3 - name: Create docker-compose.yml - run: curl https://raw.githubusercontent.com/farmOS/farmOS/2.x/docker/docker-compose.development.yml -o docker-compose.yml + run: curl https://raw.githubusercontent.com/farmOS/farmOS/3.x/docker/docker-compose.development.yml -o docker-compose.yml - name: Start containers run: docker-compose up -d && sleep 5 - name: Install farmOS run: | docker-compose exec -u www-data -T www drush site-install -y --db-url=pgsql://farm:farm@db/farm --account-pass=admin + docker-compose exec -u www-data -T www drush en farm_api_default_consumer -y docker-compose exec -u www-data -T www drush user-create tester --password test docker-compose exec -u www-data -T www drush user-add-role farm_manager tester - name: Set up Python ${{ matrix.python-version }} From 68aac8f327c1ab1c7fc09f1b1bd96ec1b780386a Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 14 Mar 2024 14:06:03 -0700 Subject: [PATCH 11/16] Linting --- farmOS/resource.py | 1 - tests/functional/test_auth.py | 1 - 2 files changed, 2 deletions(-) diff --git a/farmOS/resource.py b/farmOS/resource.py index aecf675..6a39c79 100644 --- a/farmOS/resource.py +++ b/farmOS/resource.py @@ -62,7 +62,6 @@ def send(self, entity_type, bundle=None, payload=None): json_payload = { "data": {**payload}, } - options = {"json": json_payload} # If an ID is included, update the record id = payload.pop("id", None) diff --git a/tests/functional/test_auth.py b/tests/functional/test_auth.py index d16fed4..c4b5daf 100644 --- a/tests/functional/test_auth.py +++ b/tests/functional/test_auth.py @@ -1,5 +1,4 @@ import os -import time from httpx_auth import InvalidGrantRequest from httpx_auth import OAuth2ResourceOwnerPasswordCredentials From 6d5990c8a86e3753f5377c0561b77817c2b0ed36 Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 14 Mar 2024 14:52:25 -0700 Subject: [PATCH 12/16] Update to pydantic v2 #59 --- farmOS/subrequests.py | 40 +++++++++++++++------------- setup.py | 2 +- tests/functional/test_subrequests.py | 2 +- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/farmOS/subrequests.py b/farmOS/subrequests.py index 99d15d0..be774ec 100644 --- a/farmOS/subrequests.py +++ b/farmOS/subrequests.py @@ -1,8 +1,8 @@ import json from enum import Enum -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, Iterator, List, Optional, Union -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, RootModel, Field, field_validator, model_validator # Subrequests model derived from provided JSON Schema # https://git.drupalcode.org/project/subrequests/-/blob/3.x/schema.json @@ -52,26 +52,28 @@ class Subrequest(BaseModel): title="Parent ID", ) - @validator("uri", pre=True, always=True) - def check_uri_or_endpoint(cls, uri, values): - endpoint = values.get("endpoint", None) - if uri is None and endpoint is None: + @model_validator(mode="after") + def check_uri_or_endpoint(self): + # endpoint = values.get("endpoint", None) + if self.uri is None and self.endpoint is None: raise ValueError("Either uri or endpoint is required.") - return uri + return self.uri - @validator("body", pre=True) + @field_validator("body") def serialize_body(cls, body): if not isinstance(body, str): return json.dumps(body) return body -class SubrequestsBlueprint(BaseModel): - __root__: List[Subrequest] = Field( - ..., - description="Describes the subrequests payload format.", - title="Subrequests format", - ) +class SubrequestsBlueprint(RootModel): + root: List + + def __iter__(self) -> Iterator[Subrequest]: + return iter(self.root) + + def __getitem__(self, item) -> Subrequest: + return self.root[item] class Format(str, Enum): @@ -93,10 +95,10 @@ def send( format: Optional[Union[Format, str]] = Format.json, ): if isinstance(blueprint, List): - blueprint = SubrequestsBlueprint.parse_obj(blueprint) + blueprint = SubrequestsBlueprint(blueprint) # Modify each sub-request as needed. - for sub in blueprint.__root__: + for sub in blueprint: # Build the URI if an endpoint is provided. if sub.uri is None and sub.endpoint is not None: sub.uri = sub.endpoint @@ -114,16 +116,16 @@ def send( if format == Format.json.value: params = {"_format": "json"} - # Generate the json to send. It is important to use the .json() method + # Generate the json to send. It is important to use the .model_dump_json() method # of the model for correct serialization. - json = blueprint.json(exclude_none=True) + blueprint_json = blueprint.model_dump_json(exclude_none=True) response = self.client.request( method="POST", url=self.subrequest_path, params=params, headers={"Content-Type": "application/json"}, - content=json, + content=blueprint_json, ) # Return a json response if requested. diff --git a/setup.py b/setup.py index 4bf2ef0..f39d626 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ "pytest-runner", ] -install_requires = ["httpx~=0.26", "httpx_auth~=0.20", "pydantic~=1.7.3"] +install_requires = ["httpx~=0.26", "httpx_auth~=0.20", "pydantic~=2.0"] extras_require = { "test": ["pytest~=7.0", "black~=23.0", "setuptools~=68.0"], diff --git a/tests/functional/test_subrequests.py b/tests/functional/test_subrequests.py index 9f47ae2..84d3202 100644 --- a/tests/functional/test_subrequests.py +++ b/tests/functional/test_subrequests.py @@ -77,7 +77,7 @@ def test_subrequests(test_farm): ) # Create a blueprint object - blueprint = SubrequestsBlueprint.parse_obj([new_plant_type, new_asset, new_log]) + blueprint = SubrequestsBlueprint([new_plant_type, new_asset, new_log]) # Send the blueprint. post_response = test_farm.subrequests.send(blueprint, format=Format.json) From dab8bef68ff27982f7f40e2789988b96625c770e Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 14 Mar 2024 15:00:14 -0700 Subject: [PATCH 13/16] Loosen expires_in restriction --- tests/functional/test_auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/functional/test_auth.py b/tests/functional/test_auth.py index c4b5daf..f173f19 100644 --- a/tests/functional/test_auth.py +++ b/tests/functional/test_auth.py @@ -93,7 +93,8 @@ def test_valid_login(): # Re-authorize the user after changing their profile. state, token, expires_in, refresh = farm.auth.request_new_token() assert ".ey" in token - assert 3600 == expires_in + # Expiration should be 3600 but can fluctuate during tests. + assert 3590 < expires_in < 3610 assert refresh is not None # Check that the user info is provided at farm.info. From 7292cbf0c7db47a5a564749c5fa027a00729227a Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 14 Mar 2024 15:26:10 -0700 Subject: [PATCH 14/16] Linting --- farmOS/subrequests.py | 2 +- tests/conftest.py | 2 +- tests/functional/test_auth.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/farmOS/subrequests.py b/farmOS/subrequests.py index be774ec..f2f0781 100644 --- a/farmOS/subrequests.py +++ b/farmOS/subrequests.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Any, Dict, Iterator, List, Optional, Union -from pydantic import BaseModel, RootModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, RootModel, field_validator, model_validator # Subrequests model derived from provided JSON Schema # https://git.drupalcode.org/project/subrequests/-/blob/3.x/schema.json diff --git a/tests/conftest.py b/tests/conftest.py index 4b9cef4..95e8ff7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import os -from httpx_auth import OAuth2ResourceOwnerPasswordCredentials import pytest +from httpx_auth import OAuth2ResourceOwnerPasswordCredentials from farmOS import FarmClient diff --git a/tests/functional/test_auth.py b/tests/functional/test_auth.py index f173f19..e072912 100644 --- a/tests/functional/test_auth.py +++ b/tests/functional/test_auth.py @@ -1,8 +1,7 @@ import os -from httpx_auth import InvalidGrantRequest -from httpx_auth import OAuth2ResourceOwnerPasswordCredentials import pytest +from httpx_auth import InvalidGrantRequest, OAuth2ResourceOwnerPasswordCredentials from farmOS import FarmClient from tests.conftest import farmOS_testing_server From 066822ffa5ed5d2169f4e1c4e8b2a57238074796 Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 14 Mar 2024 16:56:15 -0700 Subject: [PATCH 15/16] Refactor tests to use farm_auth fixture --- tests/conftest.py | 82 +++++++++++--------- tests/functional/test_asset.py | 81 +++++++++++--------- tests/functional/test_info.py | 29 +++---- tests/functional/test_log.py | 83 ++++++++++---------- tests/functional/test_resource.py | 64 +++++++++------- tests/functional/test_subrequests.py | 110 ++++++++++++++------------- tests/functional/test_term.py | 93 +++++++++++----------- 7 files changed, 290 insertions(+), 252 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 95e8ff7..c376bfe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,7 +29,7 @@ @pytest.fixture(scope="module") -def test_farm(): +def farm_auth(): if valid_oauth_config: auth = OAuth2ResourceOwnerPasswordCredentials( token_url=f"{FARMOS_HOSTNAME}/oauth/token", @@ -39,49 +39,55 @@ def test_farm(): client_secret=FARMOS_OAUTH_CLIENT_SECRET, scope="farm_manager", ) - return FarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + return FARMOS_HOSTNAME, auth @pytest.fixture(scope="module") -def test_logs(test_farm): - log_ids = [] - # Create logs. - for x in range(1, 60): - test_log = { - "type": "observation", - "payload": {"attributes": {"name": "Log #" + str(x)}}, - } - response = test_farm.log.send(test_log["type"], test_log["payload"]) - log_ids.append(response["data"]["id"]) - - return log_ids +def test_logs(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + log_ids = [] + # Create logs. + for x in range(1, 60): + test_log = { + "type": "observation", + "payload": {"attributes": {"name": "Log #" + str(x)}}, + } + response = farm.log.send(test_log["type"], test_log["payload"]) + log_ids.append(response["data"]["id"]) + + return log_ids @pytest.fixture(scope="module") -def test_assets(test_farm): - asset_ids = [] - # Create assets. - for x in range(1, 60): - test_asset = { - "type": "equipment", - "payload": {"attributes": {"name": "Asset #" + str(x)}}, - } - response = test_farm.asset.send(test_asset["type"], test_asset["payload"]) - asset_ids.append(response["data"]["id"]) - - return asset_ids +def test_assets(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + asset_ids = [] + # Create assets. + for x in range(1, 60): + test_asset = { + "type": "equipment", + "payload": {"attributes": {"name": "Asset #" + str(x)}}, + } + response = farm.asset.send(test_asset["type"], test_asset["payload"]) + asset_ids.append(response["data"]["id"]) + + return asset_ids @pytest.fixture(scope="module") -def test_terms(test_farm): - term_ids = [] - # Create terms. - for x in range(1, 60): - test_term = { - "type": "plant_type", - "payload": {"attributes": {"name": "Plant type #" + str(x)}}, - } - response = test_farm.term.send(test_term["type"], test_term["payload"]) - term_ids.append(response["data"]["id"]) - - return term_ids +def test_terms(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + term_ids = [] + # Create terms. + for x in range(1, 60): + test_term = { + "type": "plant_type", + "payload": {"attributes": {"name": "Plant type #" + str(x)}}, + } + response = farm.term.send(test_term["type"], test_term["payload"]) + term_ids.append(response["data"]["id"]) + + return term_ids diff --git a/tests/functional/test_asset.py b/tests/functional/test_asset.py index 30aca42..2e400f3 100644 --- a/tests/functional/test_asset.py +++ b/tests/functional/test_asset.py @@ -1,3 +1,4 @@ +from farmOS import FarmClient from tests.conftest import farmOS_testing_server # Create a test asset @@ -15,52 +16,56 @@ @farmOS_testing_server -def test_asset_crud(test_farm): - post_response = test_farm.asset.send(test_asset["type"], test_asset["payload"]) - assert "id" in post_response["data"] +def test_asset_crud(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + post_response = farm.asset.send(test_asset["type"], test_asset["payload"]) + assert "id" in post_response["data"] - # Once created, add 'id' to test_asset - test_asset["id"] = post_response["data"]["id"] + # Once created, add 'id' to test_asset + test_asset["id"] = post_response["data"]["id"] - # Get the asset by ID. - get_response = test_farm.asset.get_id(test_asset["type"], test_asset["id"]) + # Get the asset by ID. + get_response = farm.asset.get_id(test_asset["type"], test_asset["id"]) - # Assert that both responses have the correct values. - for response in [post_response, get_response]: - for key, value in test_asset["payload"]["attributes"].items(): - assert response["data"]["attributes"][key] == value + # Assert that both responses have the correct values. + for response in [post_response, get_response]: + for key, value in test_asset["payload"]["attributes"].items(): + assert response["data"]["attributes"][key] == value - test_asset_changes = { - "id": test_asset["id"], - "attributes": { - "name": "Old tractor", - "status": "archived", - }, - } + test_asset_changes = { + "id": test_asset["id"], + "attributes": { + "name": "Old tractor", + "status": "archived", + }, + } - # Update the asset. - patch_response = test_farm.asset.send(test_asset["type"], test_asset_changes) - # Get the asset by ID. - get_response = test_farm.asset.get_id(test_asset["type"], test_asset["id"]) + # Update the asset. + patch_response = farm.asset.send(test_asset["type"], test_asset_changes) + # Get the asset by ID. + get_response = farm.asset.get_id(test_asset["type"], test_asset["id"]) - # Assert that both responses have the correct values. - for response in [patch_response, get_response]: - for key, value in test_asset_changes["attributes"].items(): - assert response["data"]["attributes"][key] == value + # Assert that both responses have the correct values. + for response in [patch_response, get_response]: + for key, value in test_asset_changes["attributes"].items(): + assert response["data"]["attributes"][key] == value - # Delete the asset. - deleted_response = test_farm.asset.delete(test_asset["type"], test_asset["id"]) - assert deleted_response.status_code == 204 + # Delete the asset. + deleted_response = farm.asset.delete(test_asset["type"], test_asset["id"]) + assert deleted_response.status_code == 204 @farmOS_testing_server -def test_asset_get(test_farm, test_assets): - # Get one page of assets. - response = test_farm.asset.get(test_asset["type"]) - assert "data" in response - assert "links" in response - assert len(response["data"]) == 50 +def test_asset_get(farm_auth, test_assets): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + # Get one page of assets. + response = farm.asset.get(test_asset["type"]) + assert "data" in response + assert "links" in response + assert len(response["data"]) == 50 - # Get all assets. - all_assets = list(test_farm.asset.iterate(test_asset["type"])) - assert len(all_assets) > len(response["data"]) + # Get all assets. + all_assets = [asset for asset in farm.asset.iterate(test_asset["type"])] + assert len(all_assets) > len(response["data"]) diff --git a/tests/functional/test_info.py b/tests/functional/test_info.py index e6e02ef..8fb7496 100644 --- a/tests/functional/test_info.py +++ b/tests/functional/test_info.py @@ -1,3 +1,4 @@ +from farmOS import FarmClient from tests.conftest import farmOS_testing_server @@ -5,19 +6,21 @@ # Test farm info method # @farmOS_testing_server -def test_get_farm_info(test_farm): - info = test_farm.info() +def test_get_farm_info(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + info = farm.info() - assert "links" in info - assert "meta" in info + assert "links" in info + assert "meta" in info - # Test user links info. - assert "links" in info["meta"] - assert "me" in info["meta"]["links"] + # Test user links info. + assert "links" in info["meta"] + assert "me" in info["meta"]["links"] - # Test farm info. - assert "farm" in info["meta"] - farm_info = info["meta"]["farm"] - assert "name" in farm_info - assert "url" in farm_info - assert "version" in farm_info + # Test farm info. + assert "farm" in info["meta"] + farm_info = info["meta"]["farm"] + assert "name" in farm_info + assert "url" in farm_info + assert "version" in farm_info diff --git a/tests/functional/test_log.py b/tests/functional/test_log.py index a110b35..59e1e92 100644 --- a/tests/functional/test_log.py +++ b/tests/functional/test_log.py @@ -1,5 +1,6 @@ from datetime import datetime, timezone +from farmOS import FarmClient from tests.conftest import farmOS_testing_server curr_time = datetime.now(timezone.utc) @@ -18,52 +19,56 @@ @farmOS_testing_server -def test_log_crud(test_farm): - post_response = test_farm.log.send(test_log["type"], test_log["payload"]) - assert "id" in post_response["data"] +def test_log_crud(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + post_response = farm.log.send(test_log["type"], test_log["payload"]) + assert "id" in post_response["data"] - # Once created, add 'id' to test_log - test_log["id"] = post_response["data"]["id"] + # Once created, add 'id' to test_log + test_log["id"] = post_response["data"]["id"] - # Get the log by ID. - get_response = test_farm.log.get_id(test_log["type"], test_log["id"]) + # Get the log by ID. + get_response = farm.log.get_id(test_log["type"], test_log["id"]) - # Assert that both responses have the correct values. - for response in [post_response, get_response]: - for key, value in test_log["payload"]["attributes"].items(): - assert response["data"]["attributes"][key] == value + # Assert that both responses have the correct values. + for response in [post_response, get_response]: + for key, value in test_log["payload"]["attributes"].items(): + assert response["data"]["attributes"][key] == value - test_log_changes = { - "id": test_log["id"], - "attributes": { - "name": "Updated Log Name", - }, - } - # Update the log. - patch_response = test_farm.log.send(test_log["type"], test_log_changes) - # Get the log by ID. - get_response = test_farm.log.get_id(test_log["type"], test_log["id"]) + test_log_changes = { + "id": test_log["id"], + "attributes": { + "name": "Updated Log Name", + }, + } + # Update the log. + patch_response = farm.log.send(test_log["type"], test_log_changes) + # Get the log by ID. + get_response = farm.log.get_id(test_log["type"], test_log["id"]) - # Assert that both responses have the updated name. - for response in [patch_response, get_response]: - assert ( - response["data"]["attributes"]["name"] - == test_log_changes["attributes"]["name"] - ) + # Assert that both responses have the updated name. + for response in [patch_response, get_response]: + assert ( + response["data"]["attributes"]["name"] + == test_log_changes["attributes"]["name"] + ) - # Delete the log. - deleted_response = test_farm.log.delete(test_log["type"], test_log["id"]) - assert deleted_response.status_code == 204 + # Delete the log. + deleted_response = farm.log.delete(test_log["type"], test_log["id"]) + assert deleted_response.status_code == 204 @farmOS_testing_server -def test_log_get(test_farm, test_logs): - # Get one page of logs. - response = test_farm.log.get(test_log["type"]) - assert "data" in response - assert "links" in response - assert len(response["data"]) == 50 +def test_log_get(farm_auth, test_logs): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + # Get one page of logs. + response = farm.log.get(test_log["type"]) + assert "data" in response + assert "links" in response + assert len(response["data"]) == 50 - # Get all logs. - all_logs = list(test_farm.log.iterate(test_log["type"])) - assert len(all_logs) > len(response["data"]) + # Get all logs. + all_logs = [log for log in farm.log.iterate(test_log["type"])] + assert len(all_logs) > len(response["data"]) diff --git a/tests/functional/test_resource.py b/tests/functional/test_resource.py index 98ece64..63ad2f5 100644 --- a/tests/functional/test_resource.py +++ b/tests/functional/test_resource.py @@ -1,40 +1,46 @@ from httpx_auth import OAuth2 +from farmOS import FarmClient from tests.conftest import farmOS_testing_server # todo: Expand these tests to include a CRUD. Currently limited by user permissions. @farmOS_testing_server -def test_user_update_self(test_farm): - # Get current user ID. - info_response = test_farm.info() - user_id = info_response["meta"]["links"]["me"]["meta"]["id"] - - user_changes = { - "id": user_id, - "attributes": {"timezone": "UTC"}, - } - # Update the user. - patch_response = test_farm.resource.send("user", payload=user_changes) - - # Re-authorize the user after changing their profile. - state, token, expires_in, refresh = test_farm.auth.request_new_token() - OAuth2.token_cache._add_access_token(state, token, expires_in) - - # Get the user by ID. - get_response = test_farm.resource.get_id("user", resource_id=user_id) - - # Assert that both responses have the updated display_name. - for response in [patch_response, get_response]: - assert ( - response["data"]["attributes"]["timezone"] - == user_changes["attributes"]["timezone"] - ) +def test_user_update_self(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + # Get current user ID. + info_response = farm.info() + user_id = info_response["meta"]["links"]["me"]["meta"]["id"] + + user_changes = { + "id": user_id, + "attributes": {"timezone": "UTC"}, + } + # Update the user. + patch_response = farm.resource.send("user", payload=user_changes) + + # Re-authorize the user after changing their profile. + state, token, expires_in, refresh = farm.auth.request_new_token() + OAuth2.token_cache._add_access_token(state, token, expires_in) + + # Get the user by ID. + get_response = farm.resource.get_id("user", resource_id=user_id) + + # Assert that both responses have the updated display_name. + for response in [patch_response, get_response]: + assert ( + response["data"]["attributes"]["timezone"] + == user_changes["attributes"]["timezone"] + ) @farmOS_testing_server -def test_user_iterate(test_farm): - all_users = list(test_farm.resource.iterate("user", params={"page[limit]": 1})) - - assert len(all_users) > 1 +def test_user_iterate(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + all_users = [ + user for user in farm.resource.iterate("user", params={"page[limit]": 1}) + ] + assert len(all_users) > 1 diff --git a/tests/functional/test_subrequests.py b/tests/functional/test_subrequests.py index 84d3202..14d78f3 100644 --- a/tests/functional/test_subrequests.py +++ b/tests/functional/test_subrequests.py @@ -1,6 +1,7 @@ import json from datetime import datetime, timezone +from farmOS import FarmClient from farmOS.subrequests import Action, Format, Subrequest, SubrequestsBlueprint from tests.conftest import farmOS_testing_server @@ -9,7 +10,7 @@ @farmOS_testing_server -def test_subrequests(test_farm): +def test_subrequests(farm_auth): plant_type = { "data": { "type": "taxonomy_term--plant_type", @@ -80,61 +81,68 @@ def test_subrequests(test_farm): blueprint = SubrequestsBlueprint([new_plant_type, new_asset, new_log]) # Send the blueprint. - post_response = test_farm.subrequests.send(blueprint, format=Format.json) + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + post_response = farm.subrequests.send(blueprint, format=Format.json) - # Expected results. - response_keys = { - "create-plant-type": { - "attributes": {"name": "New plant type"}, - "relationships": {}, - }, - "create-asset#body{0}": { - "attributes": { - "name": "My new plant", - }, - "relationships": { - "plant_type": [ - { - "type": "taxonomy_term--plant_type", - "id": "{{create-plant-type.body@$.data.id}}", - } - ] + # Expected results. + response_keys = { + "create-plant-type": { + "attributes": {"name": "New plant type"}, + "relationships": {}, }, - }, - "create-log#body{0}": { - "attributes": { - "name": "Seeding my new plant", + "create-asset#body{0}": { + "attributes": { + "name": "My new plant", + }, + "relationships": { + "plant_type": [ + { + "type": "taxonomy_term--plant_type", + "id": "{{create-plant-type.body@$.data.id}}", + } + ] + }, }, - "relationships": { - "asset": [ - {"type": "asset--plant", "id": "{{create-asset.body@$.data.id}}"} - ] + "create-log#body{0}": { + "attributes": { + "name": "Seeding my new plant", + }, + "relationships": { + "asset": [ + { + "type": "asset--plant", + "id": "{{create-asset.body@$.data.id}}", + } + ] + }, }, - }, - } - for response_key, expected_data in response_keys.items(): - # Test that each response succeeded. - assert response_key in post_response - assert "headers" in post_response[response_key] - assert 201 == int(post_response[response_key]["headers"]["status"][0]) + } + for response_key, expected_data in response_keys.items(): + # Test that each response succeeded. + assert response_key in post_response + assert "headers" in post_response[response_key] + assert 201 == int(post_response[response_key]["headers"]["status"][0]) - # Test that each resource was created. - assert "body" in post_response[response_key] - body = json.loads(post_response[response_key]["body"]) - resource_id = body["data"]["id"] - entity_type, bundle = body["data"]["type"].split("--") - created_resource = test_farm.resource.get_id(entity_type, bundle, resource_id) + # Test that each resource was created. + assert "body" in post_response[response_key] + body = json.loads(post_response[response_key]["body"]) + resource_id = body["data"]["id"] + entity_type, bundle = body["data"]["type"].split("--") + created_resource = farm.resource.get_id(entity_type, bundle, resource_id) - assert created_resource is not None - assert created_resource["data"]["id"] == resource_id + assert created_resource is not None + assert created_resource["data"]["id"] == resource_id - # Test for correct attributes. - for key, value in expected_data["attributes"].items(): - assert created_resource["data"]["attributes"][key] == value + # Test for correct attributes. + for key, value in expected_data["attributes"].items(): + assert created_resource["data"]["attributes"][key] == value - # Test for correct relationships. - for field_name, relationships in expected_data["relationships"].items(): - relationship_field = created_resource["data"]["relationships"][field_name] - assert len(relationship_field["data"]) == len(relationships) - for resource in relationships: - assert relationship_field["data"][0]["type"] == resource["type"] + # Test for correct relationships. + for field_name, relationships in expected_data["relationships"].items(): + relationship_field = created_resource["data"]["relationships"][ + field_name + ] + assert len(relationship_field["data"]) == len(relationships) + for resource in relationships: + assert relationship_field["data"][0]["type"] == resource["type"] diff --git a/tests/functional/test_term.py b/tests/functional/test_term.py index a06c0f4..ced6e7e 100644 --- a/tests/functional/test_term.py +++ b/tests/functional/test_term.py @@ -1,3 +1,4 @@ +from farmOS import FarmClient from tests.conftest import farmOS_testing_server test_term = { @@ -7,50 +8,54 @@ @farmOS_testing_server -def test_term_crud(test_farm): - post_response = test_farm.term.send(test_term["type"], test_term["payload"]) - assert "id" in post_response["data"] - - # Once created, add 'id' to test_term - test_term["id"] = post_response["data"]["id"] - - # Get the term by ID. - get_response = test_farm.term.get_id(test_term["type"], test_term["id"]) - - # Assert that both responses have the correct values. - for response in [post_response, get_response]: - for key, value in test_term["payload"]["attributes"].items(): - assert response["data"]["attributes"][key] == value - - test_term_changes = { - "id": test_term["id"], - "attributes": {"name": "Updated corn"}, - } - # Update the term. - patch_response = test_farm.term.send(test_term["type"], test_term_changes) - # Get the term by ID. - get_response = test_farm.term.get_id(test_term["type"], test_term["id"]) - - # Assert that both responses have the updated name. - for response in [patch_response, get_response]: - assert ( - response["data"]["attributes"]["name"] - == test_term_changes["attributes"]["name"] - ) - - # Delete the term. - deleted_response = test_farm.term.delete(test_term["type"], test_term["id"]) - assert deleted_response.status_code == 204 +def test_term_crud(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + post_response = farm.term.send(test_term["type"], test_term["payload"]) + assert "id" in post_response["data"] + + # Once created, add 'id' to test_term + test_term["id"] = post_response["data"]["id"] + + # Get the term by ID. + get_response = farm.term.get_id(test_term["type"], test_term["id"]) + + # Assert that both responses have the correct values. + for response in [post_response, get_response]: + for key, value in test_term["payload"]["attributes"].items(): + assert response["data"]["attributes"][key] == value + + test_term_changes = { + "id": test_term["id"], + "attributes": {"name": "Updated corn"}, + } + # Update the term. + patch_response = farm.term.send(test_term["type"], test_term_changes) + # Get the term by ID. + get_response = farm.term.get_id(test_term["type"], test_term["id"]) + + # Assert that both responses have the updated name. + for response in [patch_response, get_response]: + assert ( + response["data"]["attributes"]["name"] + == test_term_changes["attributes"]["name"] + ) + + # Delete the term. + deleted_response = farm.term.delete(test_term["type"], test_term["id"]) + assert deleted_response.status_code == 204 @farmOS_testing_server -def test_term_get(test_farm, test_terms): - # Get one page of plant_type terms. - response = test_farm.term.get(test_term["type"]) - assert "data" in response - assert "links" in response - assert len(response["data"]) == 50 - - # Get all plant_type terms. - all_terms = list(test_farm.term.iterate(test_term["type"])) - assert len(all_terms) > len(response["data"]) +def test_term_get(farm_auth, test_terms): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + # Get one page of plant_type terms. + response = farm.term.get(test_term["type"]) + assert "data" in response + assert "links" in response + assert len(response["data"]) == 50 + + # Get all plant_type terms. + all_terms = [term for term in farm.term.iterate(test_term["type"])] + assert len(all_terms) > len(response["data"]) From b5b4fb70c4b0d73018f8fd9969b7a63ad7e1484a Mon Sep 17 00:00:00 2001 From: Paul Weidner Date: Thu, 14 Mar 2024 17:25:52 -0700 Subject: [PATCH 16/16] Use unasync approach to generate sync from async code --- .github/workflows/run-tests.yml | 2 + farmOS/__init__.py | 5 +- farmOS/_async/__init__.py | 0 farmOS/_async/client.py | 18 ++ farmOS/_async/resource.py | 201 ++++++++++++++++++ farmOS/_async/subrequests.py | 57 +++++ farmOS/_sync/__init__.py | 0 farmOS/{ => _sync}/client.py | 5 +- farmOS/{ => _sync}/resource.py | 52 +---- farmOS/_sync/subrequests.py | 57 +++++ farmOS/filter.py | 34 +++ .../{subrequests.py => subrequests_model.py} | 54 ----- farmOS/utils/unasync.py | 74 +++++++ tests/_async/__init__.py | 0 tests/_async/functional/__init__.py | 0 tests/_async/functional/test_asset.py | 75 +++++++ tests/_async/functional/test_auth.py | 108 ++++++++++ tests/_async/functional/test_info.py | 26 +++ tests/_async/functional/test_log.py | 78 +++++++ tests/_async/functional/test_resource.py | 50 +++++ tests/_async/functional/test_subrequests.py | 151 +++++++++++++ tests/_async/functional/test_term.py | 65 ++++++ tests/_sync/__init__.py | 0 tests/_sync/functional/__init__.py | 0 tests/{ => _sync}/functional/test_asset.py | 4 + tests/{ => _sync}/functional/test_auth.py | 7 +- tests/{ => _sync}/functional/test_info.py | 6 +- tests/{ => _sync}/functional/test_log.py | 4 + tests/{ => _sync}/functional/test_resource.py | 6 +- .../functional/test_subrequests.py | 5 +- tests/{ => _sync}/functional/test_term.py | 4 + tests/conftest.py | 5 + 32 files changed, 1048 insertions(+), 105 deletions(-) create mode 100644 farmOS/_async/__init__.py create mode 100644 farmOS/_async/client.py create mode 100644 farmOS/_async/resource.py create mode 100644 farmOS/_async/subrequests.py create mode 100644 farmOS/_sync/__init__.py rename farmOS/{ => _sync}/client.py (81%) rename farmOS/{ => _sync}/resource.py (80%) create mode 100644 farmOS/_sync/subrequests.py create mode 100644 farmOS/filter.py rename farmOS/{subrequests.py => subrequests_model.py} (56%) create mode 100644 farmOS/utils/unasync.py create mode 100644 tests/_async/__init__.py create mode 100644 tests/_async/functional/__init__.py create mode 100644 tests/_async/functional/test_asset.py create mode 100644 tests/_async/functional/test_auth.py create mode 100644 tests/_async/functional/test_info.py create mode 100644 tests/_async/functional/test_log.py create mode 100644 tests/_async/functional/test_resource.py create mode 100644 tests/_async/functional/test_subrequests.py create mode 100644 tests/_async/functional/test_term.py create mode 100644 tests/_sync/__init__.py create mode 100644 tests/_sync/functional/__init__.py rename tests/{ => _sync}/functional/test_asset.py (99%) rename tests/{ => _sync}/functional/test_auth.py (99%) rename tests/{ => _sync}/functional/test_info.py (95%) rename tests/{ => _sync}/functional/test_log.py (99%) rename tests/{ => _sync}/functional/test_resource.py (92%) rename tests/{ => _sync}/functional/test_subrequests.py (97%) rename tests/{ => _sync}/functional/test_term.py (99%) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 9649975..7b80ced 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -43,6 +43,8 @@ jobs: run: | python -m pip install --upgrade pip pip install pytest -e .[test] + - name: Check unasync + run: python farmOS/utils/unasync.py --check - name: Run farmOS.py tests. run: pytest tests env: diff --git a/farmOS/__init__.py b/farmOS/__init__.py index b37f04d..1af6f00 100644 --- a/farmOS/__init__.py +++ b/farmOS/__init__.py @@ -1,3 +1,4 @@ -from .client import FarmClient +from ._async.client import AsyncFarmClient +from ._sync.client import FarmClient -__all__ = ["FarmClient"] +__all__ = ["AsyncFarmClient", "FarmClient"] diff --git a/farmOS/_async/__init__.py b/farmOS/_async/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/farmOS/_async/client.py b/farmOS/_async/client.py new file mode 100644 index 0000000..2e5ec76 --- /dev/null +++ b/farmOS/_async/client.py @@ -0,0 +1,18 @@ +from functools import partial + +from httpx import AsyncClient + +from farmOS._async import resource, subrequests +from farmOS.filter import filter + + +class AsyncFarmClient(AsyncClient): + def __init__(self, hostname, **kwargs): + super().__init__(base_url=hostname, **kwargs) + self.info = partial(resource.info, self) + self.filter = filter + self.subrequests = subrequests.SubrequestsBase(self) + self.resource = resource.ResourceBase(self) + self.log = resource.LogAPI(self) + self.asset = resource.AssetAPI(self) + self.term = resource.TermAPI(self) diff --git a/farmOS/_async/resource.py b/farmOS/_async/resource.py new file mode 100644 index 0000000..18fcfe4 --- /dev/null +++ b/farmOS/_async/resource.py @@ -0,0 +1,201 @@ +import logging +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +class ResourceBase: + """Base class for JSONAPI resource methods.""" + + def __init__(self, client): + self.client = client + self.params = {} + + async def _get_records( + self, entity_type, bundle=None, resource_id=None, params=None + ): + """Helper function that checks to retrieve one record, one page or multiple pages of farmOS records""" + if params is None: + params = {} + + params = {**self.params, **params} + + path = self._get_resource_path(entity_type, bundle, resource_id) + + response = await self.client.request(method="GET", url=path, params=params) + return response.json() + + async def get(self, entity_type, bundle=None, params=None): + return await self._get_records( + entity_type=entity_type, bundle=bundle, params=params + ) + + async def get_id(self, entity_type, bundle=None, resource_id=None, params=None): + return await self._get_records( + entity_type=entity_type, + bundle=bundle, + params=params, + resource_id=resource_id, + ) + + async def iterate(self, entity_type, bundle=None, params=None): + response = await self._get_records( + entity_type=entity_type, bundle=bundle, params=params + ) + more = True + while more: + # TODO: Should we merge in the "includes" info here? + for resource in response["data"]: + yield resource + try: + next_url = response["links"]["next"]["href"] + parsed_url = urlparse(next_url) + next_path = parsed_url._replace(scheme="", netloc="").geturl() + response = await self.client.request(method="GET", url=next_path) + response = response.json() + except KeyError: + more = False + + async def send(self, entity_type, bundle=None, payload=None): + # Default to empty payload dict. + if payload is None: + payload = {} + + # Set the resource type. + payload["type"] = self._get_resource_type(entity_type, bundle) + json_payload = { + "data": {**payload}, + } + + # If an ID is included, update the record + id = payload.pop("id", None) + if id: + json_payload["data"]["id"] = id + logger.debug("Updating record id: of entity type: %s", id, entity_type) + path = self._get_resource_path( + entity_type=entity_type, bundle=bundle, record_id=id + ) + response = await self.client.request( + method="PATCH", + url=path, + json=json_payload, + headers={"Content-Type": "application/vnd.api+json"}, + ) + # If no ID is included, create a new record + else: + logger.debug("Creating record of entity type: %s", entity_type) + path = self._get_resource_path(entity_type=entity_type, bundle=bundle) + response = await self.client.request( + method="POST", + url=path, + json=json_payload, + headers={"Content-Type": "application/vnd.api+json"}, + ) + + # Handle response from POST requests + if response.status_code == 201: + logger.debug("Record created.") + + # Handle response from PUT requests + if response.status_code == 200: + logger.debug("Record updated.") + + return response.json() + + async def delete(self, entity_type, bundle=None, id=None): + logger.debug("Deleted record id: %s of entity type: %s", id, entity_type) + path = self._get_resource_path( + entity_type=entity_type, bundle=bundle, record_id=id + ) + return await self.client.request(method="DELETE", url=path) + + @staticmethod + def _get_resource_path(entity_type, bundle=None, record_id=None): + """Helper function that builds paths to jsonapi resources.""" + + if bundle is None: + bundle = entity_type + + path = "api/" + entity_type + "/" + bundle + + if record_id: + path += "/" + str(record_id) + + return path + + @staticmethod + def _get_resource_type(entity_type, bundle=None): + """Helper function that builds a JSONAPI resource name.""" + + if bundle is None: + bundle = entity_type + + return entity_type + "--" + bundle + + +class ResourceHelperBase: + def __init__(self, client, entity_type): + self.entity_type = entity_type + self.resource_api = ResourceBase(client=client) + + async def get(self, bundle, params=None): + return await self.resource_api.get( + entity_type=self.entity_type, bundle=bundle, params=params + ) + + async def get_id(self, bundle, resource_id, params=None): + return await self.resource_api.get_id( + entity_type=self.entity_type, + bundle=bundle, + resource_id=resource_id, + params=params, + ) + + async def iterate(self, bundle, params=None): + async for item in self.resource_api.iterate( + entity_type=self.entity_type, bundle=bundle, params=params + ): + yield item + + async def send(self, bundle, payload=None): + return await self.resource_api.send( + entity_type=self.entity_type, bundle=bundle, payload=payload + ) + + async def delete(self, bundle, id): + return await self.resource_api.delete( + entity_type=self.entity_type, bundle=bundle, id=id + ) + + +class AssetAPI(ResourceHelperBase): + """API for interacting with farm assets""" + + def __init__(self, client): + # Define 'asset' as the JSONAPI resource type. + super().__init__(client=client, entity_type="asset") + + +class LogAPI(ResourceHelperBase): + """API for interacting with farm logs""" + + def __init__(self, client): + # Define 'log' as the JSONAPI resource type. + super().__init__(client=client, entity_type="log") + + +class TermAPI(ResourceHelperBase): + """API for interacting with farm Terms""" + + def __init__(self, client): + # Define 'taxonomy_term' as the farmOS API entity endpoint + super().__init__(client=client, entity_type="taxonomy_term") + + +async def info(client): + """Retrieve info about the farmOS server.""" + + logger.debug("Retrieving farmOS server info.") + response = await client.request(method="GET", url="api") + return response.json() diff --git a/farmOS/_async/subrequests.py b/farmOS/_async/subrequests.py new file mode 100644 index 0000000..fff1a57 --- /dev/null +++ b/farmOS/_async/subrequests.py @@ -0,0 +1,57 @@ +from typing import List, Optional, Union + +from farmOS.subrequests_model import Format, SubrequestsBlueprint + + +class SubrequestsBase: + """Class for handling subrequests""" + + subrequest_path = "subrequests" + + def __init__(self, client): + self.client = client + + async def send( + self, + blueprint: Union[SubrequestsBlueprint, List], + format: Optional[Union[Format, str]] = Format.json, + ): + if isinstance(blueprint, List): + blueprint = SubrequestsBlueprint(blueprint) + + # Modify each sub-request as needed. + for sub in blueprint: + # Build the URI if an endpoint is provided. + if sub.uri is None and sub.endpoint is not None: + sub.uri = sub.endpoint + + # Set the endpoint to None so it is not included in the serialized subrequest. + sub.endpoint = None + + # Auto populate headers for each sub-request. + if "Accept" not in sub.headers: + sub.headers["Accept"] = "application/vnd.api+json" + if sub.body is not None and "Content-Type" not in sub.headers: + sub.headers["Content-Type"] = "application/vnd.api+json" + + params = {} + if format == Format.json.value: + params = {"_format": "json"} + + # Generate the json to send. It is important to use the .model_dump_json() method + # of the model for correct serialization. + blueprint_json = blueprint.model_dump_json(exclude_none=True) + + response = await self.client.request( + method="POST", + url=self.subrequest_path, + params=params, + headers={"Content-Type": "application/json"}, + content=blueprint_json, + ) + + # Return a json response if requested. + if format == Format.json.value: + return response.json() + + return response diff --git a/farmOS/_sync/__init__.py b/farmOS/_sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/farmOS/client.py b/farmOS/_sync/client.py similarity index 81% rename from farmOS/client.py rename to farmOS/_sync/client.py index 33a4366..c5b3054 100644 --- a/farmOS/client.py +++ b/farmOS/_sync/client.py @@ -2,14 +2,15 @@ from httpx import Client -from farmOS import resource, subrequests +from farmOS._sync import resource, subrequests +from farmOS.filter import filter class FarmClient(Client): def __init__(self, hostname, **kwargs): super().__init__(base_url=hostname, **kwargs) self.info = partial(resource.info, self) - self.filter = resource.filter + self.filter = filter self.subrequests = subrequests.SubrequestsBase(self) self.resource = resource.ResourceBase(self) self.log = resource.LogAPI(self) diff --git a/farmOS/resource.py b/farmOS/_sync/resource.py similarity index 80% rename from farmOS/resource.py rename to farmOS/_sync/resource.py index 6a39c79..e436376 100644 --- a/farmOS/resource.py +++ b/farmOS/_sync/resource.py @@ -12,7 +12,9 @@ def __init__(self, client): self.client = client self.params = {} - def _get_records(self, entity_type, bundle=None, resource_id=None, params=None): + def _get_records( + self, entity_type, bundle=None, resource_id=None, params=None + ): """Helper function that checks to retrieve one record, one page or multiple pages of farmOS records""" if params is None: params = {} @@ -25,7 +27,9 @@ def _get_records(self, entity_type, bundle=None, resource_id=None, params=None): return response.json() def get(self, entity_type, bundle=None, params=None): - return self._get_records(entity_type=entity_type, bundle=bundle, params=params) + return self._get_records( + entity_type=entity_type, bundle=bundle, params=params + ) def get_id(self, entity_type, bundle=None, resource_id=None, params=None): return self._get_records( @@ -42,7 +46,8 @@ def iterate(self, entity_type, bundle=None, params=None): more = True while more: # TODO: Should we merge in the "includes" info here? - yield from response["data"] + for resource in response["data"]: + yield resource try: next_url = response["links"]["next"]["href"] parsed_url = urlparse(next_url) @@ -148,9 +153,10 @@ def get_id(self, bundle, resource_id, params=None): ) def iterate(self, bundle, params=None): - return self.resource_api.iterate( + for item in self.resource_api.iterate( entity_type=self.entity_type, bundle=bundle, params=params - ) + ): + yield item def send(self, bundle, payload=None): return self.resource_api.send( @@ -193,39 +199,3 @@ def info(client): logger.debug("Retrieving farmOS server info.") response = client.request(method="GET", url="api") return response.json() - - -def filter(path, value, operation="="): - """Helper method to build JSONAPI filter query params.""" - - # TODO: Validate path, value, operation? - # TODO: Support filter groups. - # TODO: Support revision filters, are these edge cases? - # TODO: Support "pagination" query params? Separate method? - # TODO: Support "sort" query params? Separate method? - # TODO: Support "includes" query params? Separate method? - - filters = {} - - # If the operation is '=', only one query param is required. - if operation == "=": - param = f"filter[{path}]" - filters[param] = value - # Else we need a query param for the path, operation, and value. - else: - # TODO: Use a unique identifier instead of using "condition" - # otherwise the same path cannot be filtered multiple times. - base_param = f"filter[{path}_{operation.lower()}][condition]" - - path_param = base_param + "[path]" - filters[path_param] = path - - op_param = base_param + "[operator]" - filters[op_param] = operation - - value_param = base_param + "[value]" - if operation in ["IN", "NOT IN", ">", "<", "<>", "BETWEEN"]: - value_param += "[]" - filters[value_param] = value - - return filters diff --git a/farmOS/_sync/subrequests.py b/farmOS/_sync/subrequests.py new file mode 100644 index 0000000..bd6d6c1 --- /dev/null +++ b/farmOS/_sync/subrequests.py @@ -0,0 +1,57 @@ +from typing import List, Optional, Union + +from farmOS.subrequests_model import Format, SubrequestsBlueprint + + +class SubrequestsBase: + """Class for handling subrequests""" + + subrequest_path = "subrequests" + + def __init__(self, client): + self.client = client + + def send( + self, + blueprint: Union[SubrequestsBlueprint, List], + format: Optional[Union[Format, str]] = Format.json, + ): + if isinstance(blueprint, List): + blueprint = SubrequestsBlueprint(blueprint) + + # Modify each sub-request as needed. + for sub in blueprint: + # Build the URI if an endpoint is provided. + if sub.uri is None and sub.endpoint is not None: + sub.uri = sub.endpoint + + # Set the endpoint to None so it is not included in the serialized subrequest. + sub.endpoint = None + + # Auto populate headers for each sub-request. + if "Accept" not in sub.headers: + sub.headers["Accept"] = "application/vnd.api+json" + if sub.body is not None and "Content-Type" not in sub.headers: + sub.headers["Content-Type"] = "application/vnd.api+json" + + params = {} + if format == Format.json.value: + params = {"_format": "json"} + + # Generate the json to send. It is important to use the .model_dump_json() method + # of the model for correct serialization. + blueprint_json = blueprint.model_dump_json(exclude_none=True) + + response = self.client.request( + method="POST", + url=self.subrequest_path, + params=params, + headers={"Content-Type": "application/json"}, + content=blueprint_json, + ) + + # Return a json response if requested. + if format == Format.json.value: + return response.json() + + return response diff --git a/farmOS/filter.py b/farmOS/filter.py new file mode 100644 index 0000000..5d04611 --- /dev/null +++ b/farmOS/filter.py @@ -0,0 +1,34 @@ +def filter(path, value, operation="="): + """Helper method to build JSONAPI filter query params.""" + + # TODO: Validate path, value, operation? + # TODO: Support filter groups. + # TODO: Support revision filters, are these edge cases? + # TODO: Support "pagination" query params? Separate method? + # TODO: Support "sort" query params? Separate method? + # TODO: Support "includes" query params? Separate method? + + filters = {} + + # If the operation is '=', only one query param is required. + if operation == "=": + param = f"filter[{path}]" + filters[param] = value + # Else we need a query param for the path, operation, and value. + else: + # TODO: Use a unique identifier instead of using "condition" + # otherwise the same path cannot be filtered multiple times. + base_param = f"filter[{path}_{operation.lower()}][condition]" + + path_param = base_param + "[path]" + filters[path_param] = path + + op_param = base_param + "[operator]" + filters[op_param] = operation + + value_param = base_param + "[value]" + if operation in ["IN", "NOT IN", ">", "<", "<>", "BETWEEN"]: + value_param += "[]" + filters[value_param] = value + + return filters diff --git a/farmOS/subrequests.py b/farmOS/subrequests_model.py similarity index 56% rename from farmOS/subrequests.py rename to farmOS/subrequests_model.py index f2f0781..4679bbd 100644 --- a/farmOS/subrequests.py +++ b/farmOS/subrequests_model.py @@ -79,57 +79,3 @@ def __getitem__(self, item) -> Subrequest: class Format(str, Enum): html = "html" json = "json" - - -class SubrequestsBase: - """Class for handling subrequests""" - - subrequest_path = "subrequests" - - def __init__(self, client): - self.client = client - - def send( - self, - blueprint: Union[SubrequestsBlueprint, List], - format: Optional[Union[Format, str]] = Format.json, - ): - if isinstance(blueprint, List): - blueprint = SubrequestsBlueprint(blueprint) - - # Modify each sub-request as needed. - for sub in blueprint: - # Build the URI if an endpoint is provided. - if sub.uri is None and sub.endpoint is not None: - sub.uri = sub.endpoint - - # Set the endpoint to None so it is not included in the serialized subrequest. - sub.endpoint = None - - # Auto populate headers for each sub-request. - if "Accept" not in sub.headers: - sub.headers["Accept"] = "application/vnd.api+json" - if sub.body is not None and "Content-Type" not in sub.headers: - sub.headers["Content-Type"] = "application/vnd.api+json" - - params = {} - if format == Format.json.value: - params = {"_format": "json"} - - # Generate the json to send. It is important to use the .model_dump_json() method - # of the model for correct serialization. - blueprint_json = blueprint.model_dump_json(exclude_none=True) - - response = self.client.request( - method="POST", - url=self.subrequest_path, - params=params, - headers={"Content-Type": "application/json"}, - content=blueprint_json, - ) - - # Return a json response if requested. - if format == Format.json.value: - return response.json() - - return response diff --git a/farmOS/utils/unasync.py b/farmOS/utils/unasync.py new file mode 100644 index 0000000..4918c34 --- /dev/null +++ b/farmOS/utils/unasync.py @@ -0,0 +1,74 @@ +#!venv/bin/python +import os +import re +import sys + +# Copied from encode/httpcore unasync.py +# https://github.com/encode/httpcore/blob/master/scripts/unasync.py +# Subs modified for farmOS.py + +SUBS = [ + ("from farmOS._async", "from farmOS._sync"), + ("AsyncClient", "Client"), + ("Async([A-Z][A-Za-z0-9_]*)", r"\2"), + ("async def", "def"), + ("async with", "with"), + ("async for", "for"), + ("await ", ""), + ("@pytest.mark.anyio", ""), +] +COMPILED_SUBS = [ + (re.compile(r"(^|\b)" + regex + r"($|\b)"), repl) for regex, repl in SUBS +] + + +def unasync_line(line): + for regex, repl in COMPILED_SUBS: + line = re.sub(regex, repl, line) + return line + + +def unasync_file(in_path, out_path): + with open(in_path, "r") as in_file: + with open(out_path, "w", newline="") as out_file: + for line in in_file.readlines(): + line = unasync_line(line) + out_file.write(line) + + +def unasync_file_check(in_path, out_path): + with open(in_path, "r") as in_file: + with open(out_path, "r") as out_file: + for in_line, out_line in zip(in_file.readlines(), out_file.readlines()): + expected = unasync_line(in_line) + if out_line != expected: + print(f"unasync mismatch between {in_path!r} and {out_path!r}") + print(f"Async code: {in_line!r}") + print(f"Expected sync code: {expected!r}") + print(f"Actual sync code: {out_line!r}") + sys.exit(1) + + +def unasync_dir(in_dir, out_dir, check_only=False): + for dirpath, dirnames, filenames in os.walk(in_dir): + for filename in filenames: + if not filename.endswith(".py"): + continue + rel_dir = os.path.relpath(dirpath, in_dir) + in_path = os.path.normpath(os.path.join(in_dir, rel_dir, filename)) + out_path = os.path.normpath(os.path.join(out_dir, rel_dir, filename)) + print(in_path, "->", out_path) + if check_only: + unasync_file_check(in_path, out_path) + else: + unasync_file(in_path, out_path) + + +def main(): + check_only = "--check" in sys.argv + unasync_dir("farmOS/_async", "farmOS/_sync", check_only=check_only) + unasync_dir("tests/_async", "tests/_sync", check_only=check_only) + + +if __name__ == "__main__": + main() diff --git a/tests/_async/__init__.py b/tests/_async/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_async/functional/__init__.py b/tests/_async/functional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_async/functional/test_asset.py b/tests/_async/functional/test_asset.py new file mode 100644 index 0000000..365a4f8 --- /dev/null +++ b/tests/_async/functional/test_asset.py @@ -0,0 +1,75 @@ +import pytest + +from farmOS import AsyncFarmClient +from tests.conftest import farmOS_testing_server + +# Create a test asset +test_asset = { + "type": "equipment", + "payload": { + "attributes": { + "name": "Tractor", + "manufacturer": "Allis-Chalmers", + "model": "G", + "serial_number": "1234567890", + } + }, +} + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_asset_crud(farm_auth): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + post_response = await farm.asset.send(test_asset["type"], test_asset["payload"]) + assert "id" in post_response["data"] + + # Once created, add 'id' to test_asset + test_asset["id"] = post_response["data"]["id"] + + # Get the asset by ID. + get_response = await farm.asset.get_id(test_asset["type"], test_asset["id"]) + + # Assert that both responses have the correct values. + for response in [post_response, get_response]: + for key, value in test_asset["payload"]["attributes"].items(): + assert response["data"]["attributes"][key] == value + + test_asset_changes = { + "id": test_asset["id"], + "attributes": { + "name": "Old tractor", + "status": "archived", + }, + } + + # Update the asset. + patch_response = await farm.asset.send(test_asset["type"], test_asset_changes) + # Get the asset by ID. + get_response = await farm.asset.get_id(test_asset["type"], test_asset["id"]) + + # Assert that both responses have the correct values. + for response in [patch_response, get_response]: + for key, value in test_asset_changes["attributes"].items(): + assert response["data"]["attributes"][key] == value + + # Delete the asset. + deleted_response = await farm.asset.delete(test_asset["type"], test_asset["id"]) + assert deleted_response.status_code == 204 + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_asset_get(farm_auth, test_assets): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + # Get one page of assets. + response = await farm.asset.get(test_asset["type"]) + assert "data" in response + assert "links" in response + assert len(response["data"]) == 50 + + # Get all assets. + all_assets = [asset async for asset in farm.asset.iterate(test_asset["type"])] + assert len(all_assets) > len(response["data"]) diff --git a/tests/_async/functional/test_auth.py b/tests/_async/functional/test_auth.py new file mode 100644 index 0000000..830c62c --- /dev/null +++ b/tests/_async/functional/test_auth.py @@ -0,0 +1,108 @@ +import os + +import pytest +from httpx_auth import InvalidGrantRequest, OAuth2ResourceOwnerPasswordCredentials + +from farmOS import AsyncFarmClient +from tests.conftest import farmOS_testing_server + +# Variables for testing. +FARMOS_HOSTNAME = os.getenv("FARMOS_HOSTNAME") +FARMOS_OAUTH_USERNAME = os.getenv("FARMOS_OAUTH_USERNAME") +FARMOS_OAUTH_PASSWORD = os.getenv("FARMOS_OAUTH_PASSWORD") +FARMOS_OAUTH_CLIENT_ID = os.getenv("FARMOS_OAUTH_CLIENT_ID", "farm") +FARMOS_OAUTH_CLIENT_SECRET = os.getenv("FARMOS_OAUTH_CLIENT_SECRET", None) + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_invalid_login(): + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username="username", + password="password", + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", + ) + farm = AsyncFarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + await farm.info() + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_invalid_client_id(): + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id="bad_client", + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", + ) + farm = AsyncFarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + await farm.info() + + +@pytest.mark.anyio +@pytest.mark.skip( + reason="simple_oauth seems to accept any secret if none is configured on the client." +) +@farmOS_testing_server +async def test_invalid_client_secret(): + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret="bad secret", + scope="farm_manager", + ) + farm = AsyncFarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + await farm.info() + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_invalid_scope(): + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="bad_scope", + ) + farm = AsyncFarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + await farm.info() + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_valid_login(): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", + ) + farm = AsyncFarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + + # Re-authorize the user after changing their profile. + state, token, expires_in, refresh = farm.auth.request_new_token() + assert ".ey" in token + # Expiration should be 3600 but can fluctuate during tests. + assert 3590 < expires_in < 3610 + assert refresh is not None + + # Check that the user info is provided at farm.info. + info = await farm.info() + assert "meta" in info + assert "links" in info["meta"] + assert "me" in info["meta"]["links"] diff --git a/tests/_async/functional/test_info.py b/tests/_async/functional/test_info.py new file mode 100644 index 0000000..0820187 --- /dev/null +++ b/tests/_async/functional/test_info.py @@ -0,0 +1,26 @@ +import pytest + +from farmOS import AsyncFarmClient +from tests.conftest import farmOS_testing_server + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_get_farm_info(farm_auth): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + info = await farm.info() + + assert "links" in info + assert "meta" in info + + # Test user links info. + assert "links" in info["meta"] + assert "me" in info["meta"]["links"] + + # Test farm info. + assert "farm" in info["meta"] + farm_info = info["meta"]["farm"] + assert "name" in farm_info + assert "url" in farm_info + assert "version" in farm_info diff --git a/tests/_async/functional/test_log.py b/tests/_async/functional/test_log.py new file mode 100644 index 0000000..0703c05 --- /dev/null +++ b/tests/_async/functional/test_log.py @@ -0,0 +1,78 @@ +from datetime import datetime, timezone + +import pytest + +from farmOS import AsyncFarmClient +from tests.conftest import farmOS_testing_server + +curr_time = datetime.now(timezone.utc) +timestamp = curr_time.isoformat(timespec="seconds") + +# Create a test log +test_log = { + "type": "observation", + "payload": { + "attributes": { + "name": "Testing from farmOS.py", + "timestamp": timestamp, + }, + }, +} + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_log_crud(farm_auth): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + post_response = await farm.log.send(test_log["type"], test_log["payload"]) + assert "id" in post_response["data"] + + # Once created, add 'id' to test_log + test_log["id"] = post_response["data"]["id"] + + # Get the log by ID. + get_response = await farm.log.get_id(test_log["type"], test_log["id"]) + + # Assert that both responses have the correct values. + for response in [post_response, get_response]: + for key, value in test_log["payload"]["attributes"].items(): + assert response["data"]["attributes"][key] == value + + test_log_changes = { + "id": test_log["id"], + "attributes": { + "name": "Updated Log Name", + }, + } + # Update the log. + patch_response = await farm.log.send(test_log["type"], test_log_changes) + # Get the log by ID. + get_response = await farm.log.get_id(test_log["type"], test_log["id"]) + + # Assert that both responses have the updated name. + for response in [patch_response, get_response]: + assert ( + response["data"]["attributes"]["name"] + == test_log_changes["attributes"]["name"] + ) + + # Delete the log. + deleted_response = await farm.log.delete(test_log["type"], test_log["id"]) + assert deleted_response.status_code == 204 + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_log_get(farm_auth, test_logs): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + # Get one page of logs. + response = await farm.log.get(test_log["type"]) + assert "data" in response + assert "links" in response + assert len(response["data"]) == 50 + + # Get all logs. + all_logs = [log async for log in farm.log.iterate(test_log["type"])] + assert len(all_logs) > len(response["data"]) diff --git a/tests/_async/functional/test_resource.py b/tests/_async/functional/test_resource.py new file mode 100644 index 0000000..4e37576 --- /dev/null +++ b/tests/_async/functional/test_resource.py @@ -0,0 +1,50 @@ +import pytest +from httpx_auth import OAuth2 + +from farmOS import AsyncFarmClient +from tests.conftest import farmOS_testing_server + +# todo: Expand these tests to include a CRUD. Currently limited by user permissions. + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_user_update_self(farm_auth): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + # Get current user ID. + info_response = await farm.info() + user_id = info_response["meta"]["links"]["me"]["meta"]["id"] + + user_changes = { + "id": user_id, + "attributes": {"timezone": "UTC"}, + } + # Update the user. + patch_response = await farm.resource.send("user", payload=user_changes) + + # Re-authorize the user after changing their profile. + state, token, expires_in, refresh = farm.auth.request_new_token() + OAuth2.token_cache._add_access_token(state, token, expires_in) + + # Get the user by ID. + get_response = await farm.resource.get_id("user", resource_id=user_id) + + # Assert that both responses have the updated display_name. + for response in [patch_response, get_response]: + assert ( + response["data"]["attributes"]["timezone"] + == user_changes["attributes"]["timezone"] + ) + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_user_iterate(farm_auth): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + all_users = [ + user + async for user in farm.resource.iterate("user", params={"page[limit]": 1}) + ] + assert len(all_users) > 1 diff --git a/tests/_async/functional/test_subrequests.py b/tests/_async/functional/test_subrequests.py new file mode 100644 index 0000000..b2a0e5a --- /dev/null +++ b/tests/_async/functional/test_subrequests.py @@ -0,0 +1,151 @@ +import json +from datetime import datetime, timezone + +import pytest + +from farmOS import AsyncFarmClient +from farmOS.subrequests_model import Action, Format, Subrequest, SubrequestsBlueprint +from tests.conftest import farmOS_testing_server + +curr_time = datetime.now(timezone.utc) +timestamp = curr_time.isoformat(timespec="seconds") + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_subrequests(farm_auth): + plant_type = { + "data": { + "type": "taxonomy_term--plant_type", + "attributes": {"name": "New plant type"}, + } + } + + new_plant_type = Subrequest( + action=Action.create, + requestId="create-plant-type", + endpoint="api/taxonomy_term/plant_type", + body=plant_type, + ) + + plant = { + "data": { + "type": "asset--plant", + "attributes": { + "name": "My new plant", + }, + "relationships": { + "plant_type": { + "data": [ + { + "type": "taxonomy_term--plant_type", + "id": "{{create-plant-type.body@$.data.id}}", + } + ] + } + }, + } + } + new_asset = Subrequest( + action=Action.create, + requestId="create-asset", + waitFor=["create-plant-type"], + endpoint="api/asset/plant", + body=plant, + ) + + log = { + "data": { + "type": "log--seeding", + "attributes": { + "name": "Seeding my new plant", + }, + "relationships": { + "asset": { + "data": [ + { + "type": "asset--plant", + "id": "{{create-asset.body@$.data.id}}", + } + ] + } + }, + } + } + new_log = Subrequest( + action=Action.create, + requestId="create-log", + waitFor=["create-asset"], + endpoint="api/log/seeding", + body=log, + ) + + # Create a blueprint object + blueprint = SubrequestsBlueprint([new_plant_type, new_asset, new_log]) + + # Send the blueprint. + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + post_response = await farm.subrequests.send(blueprint, format=Format.json) + + # Expected results. + response_keys = { + "create-plant-type": { + "attributes": {"name": "New plant type"}, + "relationships": {}, + }, + "create-asset#body{0}": { + "attributes": { + "name": "My new plant", + }, + "relationships": { + "plant_type": [ + { + "type": "taxonomy_term--plant_type", + "id": "{{create-plant-type.body@$.data.id}}", + } + ] + }, + }, + "create-log#body{0}": { + "attributes": { + "name": "Seeding my new plant", + }, + "relationships": { + "asset": [ + { + "type": "asset--plant", + "id": "{{create-asset.body@$.data.id}}", + } + ] + }, + }, + } + for response_key, expected_data in response_keys.items(): + # Test that each response succeeded. + assert response_key in post_response + assert "headers" in post_response[response_key] + assert 201 == int(post_response[response_key]["headers"]["status"][0]) + + # Test that each resource was created. + assert "body" in post_response[response_key] + body = json.loads(post_response[response_key]["body"]) + resource_id = body["data"]["id"] + entity_type, bundle = body["data"]["type"].split("--") + created_resource = await farm.resource.get_id(entity_type, bundle, resource_id) + + assert created_resource is not None + assert created_resource["data"]["id"] == resource_id + + # Test for correct attributes. + for key, value in expected_data["attributes"].items(): + assert created_resource["data"]["attributes"][key] == value + + # Test for correct relationships. + for field_name, relationships in expected_data["relationships"].items(): + relationship_field = created_resource["data"]["relationships"][ + field_name + ] + assert len(relationship_field["data"]) == len(relationships) + for resource in relationships: + assert relationship_field["data"][0]["type"] == resource["type"] diff --git a/tests/_async/functional/test_term.py b/tests/_async/functional/test_term.py new file mode 100644 index 0000000..e7ce89a --- /dev/null +++ b/tests/_async/functional/test_term.py @@ -0,0 +1,65 @@ +import pytest + +from farmOS import AsyncFarmClient +from tests.conftest import farmOS_testing_server + +test_term = { + "type": "plant_type", + "payload": {"attributes": {"name": "Corn"}}, +} + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_term_crud(farm_auth): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + post_response = await farm.term.send(test_term["type"], test_term["payload"]) + assert "id" in post_response["data"] + + # Once created, add 'id' to test_term + test_term["id"] = post_response["data"]["id"] + + # Get the term by ID. + get_response = await farm.term.get_id(test_term["type"], test_term["id"]) + + # Assert that both responses have the correct values. + for response in [post_response, get_response]: + for key, value in test_term["payload"]["attributes"].items(): + assert response["data"]["attributes"][key] == value + + test_term_changes = { + "id": test_term["id"], + "attributes": {"name": "Updated corn"}, + } + # Update the term. + patch_response = await farm.term.send(test_term["type"], test_term_changes) + # Get the term by ID. + get_response = await farm.term.get_id(test_term["type"], test_term["id"]) + + # Assert that both responses have the updated name. + for response in [patch_response, get_response]: + assert ( + response["data"]["attributes"]["name"] + == test_term_changes["attributes"]["name"] + ) + + # Delete the term. + deleted_response = await farm.term.delete(test_term["type"], test_term["id"]) + assert deleted_response.status_code == 204 + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_term_get(farm_auth, test_terms): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + # Get one page of plant_type terms. + response = await farm.term.get(test_term["type"]) + assert "data" in response + assert "links" in response + assert len(response["data"]) == 50 + + # Get all plant_type terms. + all_terms = [term async for term in farm.term.iterate(test_term["type"])] + assert len(all_terms) > len(response["data"]) diff --git a/tests/_sync/__init__.py b/tests/_sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_sync/functional/__init__.py b/tests/_sync/functional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/functional/test_asset.py b/tests/_sync/functional/test_asset.py similarity index 99% rename from tests/functional/test_asset.py rename to tests/_sync/functional/test_asset.py index 2e400f3..ea322f3 100644 --- a/tests/functional/test_asset.py +++ b/tests/_sync/functional/test_asset.py @@ -1,3 +1,5 @@ +import pytest + from farmOS import FarmClient from tests.conftest import farmOS_testing_server @@ -15,6 +17,7 @@ } + @farmOS_testing_server def test_asset_crud(farm_auth): hostname, auth = farm_auth @@ -56,6 +59,7 @@ def test_asset_crud(farm_auth): assert deleted_response.status_code == 204 + @farmOS_testing_server def test_asset_get(farm_auth, test_assets): hostname, auth = farm_auth diff --git a/tests/functional/test_auth.py b/tests/_sync/functional/test_auth.py similarity index 99% rename from tests/functional/test_auth.py rename to tests/_sync/functional/test_auth.py index e072912..bc923c3 100644 --- a/tests/functional/test_auth.py +++ b/tests/_sync/functional/test_auth.py @@ -14,6 +14,7 @@ FARMOS_OAUTH_CLIENT_SECRET = os.getenv("FARMOS_OAUTH_CLIENT_SECRET", None) + @farmOS_testing_server def test_invalid_login(): with pytest.raises(InvalidGrantRequest): @@ -29,6 +30,7 @@ def test_invalid_login(): farm.info() + @farmOS_testing_server def test_invalid_client_id(): with pytest.raises(InvalidGrantRequest): @@ -44,10 +46,11 @@ def test_invalid_client_id(): farm.info() -@farmOS_testing_server + @pytest.mark.skip( reason="simple_oauth seems to accept any secret if none is configured on the client." ) +@farmOS_testing_server def test_invalid_client_secret(): with pytest.raises(InvalidGrantRequest): auth = OAuth2ResourceOwnerPasswordCredentials( @@ -62,6 +65,7 @@ def test_invalid_client_secret(): farm.info() + @farmOS_testing_server def test_invalid_scope(): with pytest.raises(InvalidGrantRequest): @@ -77,6 +81,7 @@ def test_invalid_scope(): farm.info() + @farmOS_testing_server def test_valid_login(): auth = OAuth2ResourceOwnerPasswordCredentials( diff --git a/tests/functional/test_info.py b/tests/_sync/functional/test_info.py similarity index 95% rename from tests/functional/test_info.py rename to tests/_sync/functional/test_info.py index 8fb7496..00af5ed 100644 --- a/tests/functional/test_info.py +++ b/tests/_sync/functional/test_info.py @@ -1,10 +1,10 @@ +import pytest + from farmOS import FarmClient from tests.conftest import farmOS_testing_server -# -# Test farm info method -# + @farmOS_testing_server def test_get_farm_info(farm_auth): hostname, auth = farm_auth diff --git a/tests/functional/test_log.py b/tests/_sync/functional/test_log.py similarity index 99% rename from tests/functional/test_log.py rename to tests/_sync/functional/test_log.py index 59e1e92..fb8fe25 100644 --- a/tests/functional/test_log.py +++ b/tests/_sync/functional/test_log.py @@ -1,5 +1,7 @@ from datetime import datetime, timezone +import pytest + from farmOS import FarmClient from tests.conftest import farmOS_testing_server @@ -18,6 +20,7 @@ } + @farmOS_testing_server def test_log_crud(farm_auth): hostname, auth = farm_auth @@ -59,6 +62,7 @@ def test_log_crud(farm_auth): assert deleted_response.status_code == 204 + @farmOS_testing_server def test_log_get(farm_auth, test_logs): hostname, auth = farm_auth diff --git a/tests/functional/test_resource.py b/tests/_sync/functional/test_resource.py similarity index 92% rename from tests/functional/test_resource.py rename to tests/_sync/functional/test_resource.py index 63ad2f5..ec601b4 100644 --- a/tests/functional/test_resource.py +++ b/tests/_sync/functional/test_resource.py @@ -1,3 +1,4 @@ +import pytest from httpx_auth import OAuth2 from farmOS import FarmClient @@ -6,6 +7,7 @@ # todo: Expand these tests to include a CRUD. Currently limited by user permissions. + @farmOS_testing_server def test_user_update_self(farm_auth): hostname, auth = farm_auth @@ -36,11 +38,13 @@ def test_user_update_self(farm_auth): ) + @farmOS_testing_server def test_user_iterate(farm_auth): hostname, auth = farm_auth with FarmClient(hostname, auth=auth) as farm: all_users = [ - user for user in farm.resource.iterate("user", params={"page[limit]": 1}) + user + for user in farm.resource.iterate("user", params={"page[limit]": 1}) ] assert len(all_users) > 1 diff --git a/tests/functional/test_subrequests.py b/tests/_sync/functional/test_subrequests.py similarity index 97% rename from tests/functional/test_subrequests.py rename to tests/_sync/functional/test_subrequests.py index 14d78f3..edfc4f9 100644 --- a/tests/functional/test_subrequests.py +++ b/tests/_sync/functional/test_subrequests.py @@ -1,14 +1,17 @@ import json from datetime import datetime, timezone +import pytest + from farmOS import FarmClient -from farmOS.subrequests import Action, Format, Subrequest, SubrequestsBlueprint +from farmOS.subrequests_model import Action, Format, Subrequest, SubrequestsBlueprint from tests.conftest import farmOS_testing_server curr_time = datetime.now(timezone.utc) timestamp = curr_time.isoformat(timespec="seconds") + @farmOS_testing_server def test_subrequests(farm_auth): plant_type = { diff --git a/tests/functional/test_term.py b/tests/_sync/functional/test_term.py similarity index 99% rename from tests/functional/test_term.py rename to tests/_sync/functional/test_term.py index ced6e7e..7d4dee3 100644 --- a/tests/functional/test_term.py +++ b/tests/_sync/functional/test_term.py @@ -1,3 +1,5 @@ +import pytest + from farmOS import FarmClient from tests.conftest import farmOS_testing_server @@ -7,6 +9,7 @@ } + @farmOS_testing_server def test_term_crud(farm_auth): hostname, auth = farm_auth @@ -46,6 +49,7 @@ def test_term_crud(farm_auth): assert deleted_response.status_code == 204 + @farmOS_testing_server def test_term_get(farm_auth, test_terms): hostname, auth = farm_auth diff --git a/tests/conftest.py b/tests/conftest.py index c376bfe..87f6d14 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,6 +28,11 @@ ) +@pytest.fixture +def anyio_backend(): + return "asyncio" + + @pytest.fixture(scope="module") def farm_auth(): if valid_oauth_config: