diff --git a/.github/workflows/format.yaml b/.github/workflows/format.yaml new file mode 100644 index 0000000..d645bbf --- /dev/null +++ b/.github/workflows/format.yaml @@ -0,0 +1,8 @@ +name: Ruff +on: [ push, pull_request ] +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v1 \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..aa0bf78 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +default_install_hook_types: + - pre-commit + - prepare-commit-msg +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.7.4 + hooks: + # Run the linter. + - id: ruff + args: [ --fix ] + # Run the formatter. + - id: ruff-format diff --git a/VERSION b/VERSION index 1750564..5a5831a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.6 +0.0.7 diff --git a/setup.py b/setup.py index 5386790..1687b26 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,11 @@ -from setuptools import setup, find_packages +from setuptools import setup + def get_version(): with open("VERSION") as f: return f.read().strip() + setup( name="shopify-client", version=get_version(), @@ -25,7 +27,7 @@ def get_version(): "flake8", "black", "sphinx", - "pre-commit" + "pre-commit", ] }, python_requires=">=3.9", diff --git a/shopify_client/__init__.py b/shopify_client/__init__.py index c960a32..dae48c0 100644 --- a/shopify_client/__init__.py +++ b/shopify_client/__init__.py @@ -1,5 +1,4 @@ import logging -import time from urllib.parse import urljoin import requests @@ -15,7 +14,6 @@ class ShopifyClient(requests.Session): - def __init__( self, api_url, diff --git a/shopify_client/endpoint.py b/shopify_client/endpoint.py index 9ed2d62..3ff21bb 100644 --- a/shopify_client/endpoint.py +++ b/shopify_client/endpoint.py @@ -3,36 +3,37 @@ logger = logging.getLogger(__name__) + class Endpoint(object): def __init__(self, client, endpoint, sub_endpoint=None, metafields=False): self.client = client self.endpoint = endpoint self.sub_endpoint = sub_endpoint - + if metafields: - self.metafields = Endpoint(client=client, endpoint=self.endpoint, sub_endpoint="metafields") + self.metafields = Endpoint( + client=client, endpoint=self.endpoint, sub_endpoint="metafields" + ) def __prepare_params(self, **params): flatted_params = [] for key, value in params.items(): if isinstance(value, dict): for k, v in value.items(): - flatted_params.append( - (f"{key}[{k}]", v) - ) + flatted_params.append((f"{key}[{k}]", v)) elif isinstance(value, list): for v in value: - flatted_params.append( - (f"{key}[]", v) - ) + flatted_params.append((f"{key}[]", v)) else: flatted_params.append((key, value)) - + return flatted_params - def __build_url(self, resource_id=None, sub_resource_id=None, action=None, **params): + def __build_url( + self, resource_id=None, sub_resource_id=None, action=None, **params + ): url = self.endpoint - + if resource_id: url = f"{url}/{resource_id}" @@ -46,11 +47,11 @@ def __build_url(self, resource_id=None, sub_resource_id=None, action=None, **par if action: url = f"{url}/{action}" - + flatted_params = self.__prepare_params(**params) - + return f"{url}.json{'?' + urlencode(flatted_params) if flatted_params else ''}" - + def __paginate(self, url): next_url = url while next_url: @@ -62,7 +63,7 @@ def __paginate(self, url): def get(self, resource_id, **params): url = self.__build_url(resource_id=resource_id, **params) return self.client.parse_response(self.client.get(url)) - + def create(self, json: dict, **params): url = self.__build_url(**params) return self.client.parse_response(self.client.post(url, json=json)) @@ -75,56 +76,61 @@ def delete(self, resource_id, **params): url = self.__build_url(resource_id=resource_id, **params) resp = self.client.delete(url) return resp.ok - + def all(self, paginate=False, **params): url = self.__build_url(**params) if paginate: return self.__paginate(url) else: return self.client.parse_response(self.client.get(url)) - + def action(self, action, resource_id, method="GET", **params): url = self.__build_url(resource_id=resource_id, action=action, **params) return self.client.parse_response(self.client.request(method, url, **params)) - + def count(self, resource_id=None, **params): return self.action("count", resource_id=resource_id, **params) class OrdersEndpoint(Endpoint): - def __init__(self, client, endpoint): super().__init__(client, endpoint, metafields=True) - - self.transactions = Endpoint(client=client, endpoint=endpoint, sub_endpoint="transactions") + + self.transactions = Endpoint( + client=client, endpoint=endpoint, sub_endpoint="transactions" + ) self.risks = Endpoint(client=client, endpoint=endpoint, sub_endpoint="risks") - self.refunds = Endpoint(client=client, endpoint=endpoint, sub_endpoint="refunds") + self.refunds = Endpoint( + client=client, endpoint=endpoint, sub_endpoint="refunds" + ) def cancel(self, resource_id, **params): return self.action("cancel", resource_id=resource_id, method="POST", **params) - + def close(self, resource_id, **params): return self.action("close", resource_id=resource_id, method="POST", **params) - + def open(self, resource_id, **params): return self.action("open", resource_id=resource_id, method="POST", **params) - -class DraftOrdersEndpoint(Endpoint): +class DraftOrdersEndpoint(Endpoint): def complete(self, resource_id, **params): return self.action("complete", resource_id=resource_id, method="PUT", **params) - + def send_invoice(self, resource_id, **params): - return self.action("send_invoice", resource_id=resource_id, method="PUT", **params) - + return self.action( + "send_invoice", resource_id=resource_id, method="PUT", **params + ) -class FulfillmentOrdersEndpoint(Endpoint): +class FulfillmentOrdersEndpoint(Endpoint): def __init__(self, client, endpoint): super().__init__(client, endpoint, metafields=True) - self.fulfillment_request = Endpoint(client=client, endpoint=endpoint, sub_endpoint="fulfillment_request") + self.fulfillment_request = Endpoint( + client=client, endpoint=endpoint, sub_endpoint="fulfillment_request" + ) def cancel(self, resource_id, **params): - return self.action("cancel", resource_id=resource_id, method="POST", **params) \ No newline at end of file + return self.action("cancel", resource_id=resource_id, method="POST", **params) diff --git a/shopify_client/graphql.py b/shopify_client/graphql.py index 17004f0..4359425 100644 --- a/shopify_client/graphql.py +++ b/shopify_client/graphql.py @@ -8,7 +8,6 @@ class GraphQL: - def __init__(self, client, graphql_queries_dir=None): self.client = client self.endpoint = "graphql.json" @@ -27,18 +26,35 @@ def query_from_name(self, name): with open(query_path, "r") as f: return f.read() - def __query(self, query=None, query_name=None, variables=None, operation_name=None, paginate=False, page_size=100): + def __query( + self, + query=None, + query_name=None, + variables=None, + operation_name=None, + paginate=False, + page_size=100, + ): assert query or query_name, "Either 'query' or 'query_name' must be provided" if query is None and query_name: query = self.query_from_name(query_name) if paginate: - return self.__paginate(query=query, variables=variables, operation_name=operation_name, page_size=page_size) + return self.__paginate( + query=query, + variables=variables, + operation_name=operation_name, + page_size=page_size, + ) try: response = self.client.post( self.__build_url(), - json={"query": query, "variables": variables, "operationName": operation_name}, + json={ + "query": query, + "variables": variables, + "operationName": operation_name, + }, ) return self.client.parse_response(response) except requests.exceptions.HTTPError as e: @@ -49,9 +65,15 @@ def __query(self, query=None, query_name=None, variables=None, operation_name=No raise e def __paginate(self, query, variables=None, operation_name=None, page_size=100): - assert "pageInfo" in query, "Query must contain a 'pageInfo' object to be paginated" - assert "hasNextPage" in query[query.find("pageInfo"):], "Query must contain a 'hasNextPage' field in 'pageInfo' object" - assert "endCursor" in query[query.find("pageInfo"):], "Query must contain a 'endCursor' field in 'pageInfo' object" + assert ( + "pageInfo" in query + ), "Query must contain a 'pageInfo' object to be paginated" + assert ( + "hasNextPage" in query[query.find("pageInfo") :] + ), "Query must contain a 'hasNextPage' field in 'pageInfo' object" + assert ( + "endCursor" in query[query.find("pageInfo") :] + ), "Query must contain a 'endCursor' field in 'pageInfo' object" variables = variables or {} variables["page_size"] = page_size @@ -61,7 +83,9 @@ def __paginate(self, query, variables=None, operation_name=None, page_size=100): while has_next_page: variables["cursor"] = cursor - response = self.__query(query=query, variables=variables, operation_name=operation_name) + response = self.__query( + query=query, variables=variables, operation_name=operation_name + ) page_info = self.__find_page_info(response) has_next_page = page_info.get("hasNextPage", False) cursor = page_info.get("endCursor", None) @@ -78,4 +102,4 @@ def __find_page_info(self, response): if isinstance(v, dict): result = self.__find_page_info(v) if result: - return result \ No newline at end of file + return result diff --git a/tests/conftest.py b/tests/conftest.py index 4f14997..7b6a790 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,9 +9,12 @@ @pytest.fixture def mock_client(mocker): client = mocker.Mock() - client.parse_response.side_effect = lambda x: x # Just return the response data as-is + client.parse_response.side_effect = ( + lambda x: x + ) # Just return the response data as-is return client + @pytest.fixture def endpoint(mock_client): return Endpoint(client=mock_client, endpoint="test_endpoint") @@ -19,12 +22,15 @@ def endpoint(mock_client): @pytest.fixture def shopify_client(mocker): - return ShopifyClient(api_url="https://test-shop.myshopify.com", api_token="test-token") + return ShopifyClient( + api_url="https://test-shop.myshopify.com", api_token="test-token" + ) + # Create a new mock that will deepcopy the arguments passed to it - # https://docs.python.org/3.7/library/unittest.mock-examples.html#coping-with-mutable-arguments +# https://docs.python.org/3.7/library/unittest.mock-examples.html#coping-with-mutable-arguments class CopyingMock(Mock): def __call__(self, *args, **kwargs): args = deepcopy(args) kwargs = deepcopy(kwargs) - return super().__call__(*args, **kwargs) \ No newline at end of file + return super().__call__(*args, **kwargs) diff --git a/tests/test_draft_orders_endpoint.py b/tests/test_draft_orders_endpoint.py index 4716605..0426950 100644 --- a/tests/test_draft_orders_endpoint.py +++ b/tests/test_draft_orders_endpoint.py @@ -1,18 +1,23 @@ import pytest from shopify_client.endpoint import DraftOrdersEndpoint + @pytest.fixture def draft_orders_endpoint(mock_client): return DraftOrdersEndpoint(client=mock_client, endpoint="draft_orders") + def test_complete_draft_order(draft_orders_endpoint, mock_client): mock_client.request.return_value = {"result": "completed"} response = draft_orders_endpoint.complete(1) mock_client.request.assert_called_once_with("PUT", "draft_orders/1/complete.json") assert response == {"result": "completed"} + def test_send_invoice(draft_orders_endpoint, mock_client): mock_client.request.return_value = {"result": "invoice_sent"} response = draft_orders_endpoint.send_invoice(1) - mock_client.request.assert_called_once_with("PUT", "draft_orders/1/send_invoice.json") + mock_client.request.assert_called_once_with( + "PUT", "draft_orders/1/send_invoice.json" + ) assert response == {"result": "invoice_sent"} diff --git a/tests/test_endpoint.py b/tests/test_endpoint.py index fbde5e2..acf978b 100644 --- a/tests/test_endpoint.py +++ b/tests/test_endpoint.py @@ -1,62 +1,70 @@ import requests -import pytest -from shopify_client import Endpoint, OrdersEndpoint, DraftOrdersEndpoint + def test_prepare_params(endpoint): params = { "simple": "value", "nested": {"key1": "value1", "key2": "value2"}, - "list": ["item1", "item2"] + "list": ["item1", "item2"], } expected = [ ("simple", "value"), ("nested[key1]", "value1"), ("nested[key2]", "value2"), ("list[]", "item1"), - ("list[]", "item2") + ("list[]", "item2"), ] result = endpoint._Endpoint__prepare_params(**params) assert result == expected + def test_build_url_basic(endpoint): url = endpoint._Endpoint__build_url(resource_id=1) assert url == "test_endpoint/1.json" + def test_build_url_with_params(endpoint): url = endpoint._Endpoint__build_url(resource_id=1, action="test", param="value") assert url == "test_endpoint/1/test.json?param=value" + def test_build_url_for_sub_endpoint_create(endpoint): endpoint.sub_endpoint = "metafields" url = endpoint._Endpoint__build_url(resource_id=1) assert url == "test_endpoint/1/metafields.json" + def test_build_url_for_sub_endpoint_get(endpoint): endpoint.sub_endpoint = "metafields" url = endpoint._Endpoint__build_url(resource_id=1, sub_resource_id=2) assert url == "test_endpoint/1/metafields/2.json" + def test_build_url_for_sub_endpoint_all(endpoint): endpoint.sub_endpoint = "metafields" url = endpoint._Endpoint__build_url(resource_id=1) assert url == "test_endpoint/1/metafields.json" + def test_build_url_for_sub_endpoint_update(endpoint): endpoint.sub_endpoint = "metafields" url = endpoint._Endpoint__build_url(resource_id=1, sub_resource_id=2) assert url == "test_endpoint/1/metafields/2.json" + def test_build_url_for_sub_endpoint_delete(endpoint): endpoint.sub_endpoint = "metafields" url = endpoint._Endpoint__build_url(resource_id=1, sub_resource_id=2) assert url == "test_endpoint/1/metafields/2.json" + def test_get(endpoint, mock_client): mock_client.get.return_value = {"result": "success"} response = endpoint.get(1, param="value") mock_client.get.assert_called_once_with("test_endpoint/1.json?param=value") assert response == {"result": "success"} + def test_create(endpoint, mock_client): mock_client.post.return_value = {"result": "created"} data = {"field": "value"} @@ -64,26 +72,35 @@ def test_create(endpoint, mock_client): mock_client.post.assert_called_once_with("test_endpoint.json", json=data) assert response == {"result": "created"} + def test_update(endpoint, mock_client): mock_client.put.return_value = {"result": "updated"} data = {"field": "new_value"} response = endpoint.update(1, json=data, param="value") - mock_client.put.assert_called_once_with("test_endpoint/1.json?param=value", json=data) + mock_client.put.assert_called_once_with( + "test_endpoint/1.json?param=value", json=data + ) assert response == {"result": "updated"} + def test_delete(endpoint, mock_client): mock_client.delete.return_value.ok = True response = endpoint.delete(1, param="value") mock_client.delete.assert_called_once_with("test_endpoint/1.json?param=value") assert response is True + def test_paginated_calls(endpoint, mock_client, mocker): mock_client.parse_response.side_effect = lambda x: x.json() first_page_response = mocker.Mock(spec=requests.Response) first_page_response.json.return_value = {"items": [1, 2, 3]} - first_page_response.links = {"next": {"url": "https://test-shop.myshopify.com/admin/api/2024-10/test_endpoint.json?page=2"}} - + first_page_response.links = { + "next": { + "url": "https://test-shop.myshopify.com/admin/api/2024-10/test_endpoint.json?page=2" + } + } + second_page_response = mocker.Mock(spec=requests.Response) second_page_response.json.return_value = {"items": [4, 5, 6]} second_page_response.links = {} # No next link on the second page (end of pagination) diff --git a/tests/test_fulfillment_orders_endpoint.py b/tests/test_fulfillment_orders_endpoint.py index 7d7b173..cc25e9c 100644 --- a/tests/test_fulfillment_orders_endpoint.py +++ b/tests/test_fulfillment_orders_endpoint.py @@ -1,12 +1,16 @@ import pytest from shopify_client.endpoint import FulfillmentOrdersEndpoint + @pytest.fixture def fulfillment_orders_endpoint(mock_client): return FulfillmentOrdersEndpoint(client=mock_client, endpoint="fulfillment_orders") + def test_cancel_fulfillment_order(fulfillment_orders_endpoint, mock_client): mock_client.request.return_value = {"result": "cancelled"} response = fulfillment_orders_endpoint.cancel(1) - mock_client.request.assert_called_once_with("POST", "fulfillment_orders/1/cancel.json") + mock_client.request.assert_called_once_with( + "POST", "fulfillment_orders/1/cancel.json" + ) assert response == {"result": "cancelled"} diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 93f0b44..7d7efb9 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -1,21 +1,26 @@ -from copy import deepcopy import json -from unittest.mock import MagicMock, call, mock_open, patch +from unittest.mock import call, mock_open, patch import requests import pytest from shopify_client.graphql import GraphQL from tests.conftest import CopyingMock + @pytest.fixture def graphql(mock_client): return GraphQL(client=mock_client) + def test_graphql_query(graphql, mock_client): mock_client.post.return_value = {"data": {"key": "value"}} response = graphql(query="query { key }") - mock_client.post.assert_called_once_with("graphql.json", json={"query": "query { key }", "variables": None, "operationName": None}) + mock_client.post.assert_called_once_with( + "graphql.json", + json={"query": "query { key }", "variables": None, "operationName": None}, + ) assert response == {"data": {"key": "value"}} + def test_graphql_query_with_query_name(graphql, mock_client): mock_query_content = "query { items { id } }" graphql.graphql_queries_dir = "queries" @@ -24,37 +29,53 @@ def test_graphql_query_with_query_name(graphql, mock_client): response = graphql(query_name="test_query") mock_client.post.assert_called_once_with( "graphql.json", - json={"query": mock_query_content, "variables": None, "operationName": None}, + json={ + "query": mock_query_content, + "variables": None, + "operationName": None, + }, ) assert response == {"data": {"items": []}} + def test_graphql_query_with_variables(graphql, mock_client): mock_client.post.return_value = {"data": {"key": "value"}} variables = {"var1": "value1"} response = graphql(query="query { key }", variables=variables) - mock_client.post.assert_called_once_with("graphql.json", json={"query": "query { key }", "variables": variables, "operationName": None}) + mock_client.post.assert_called_once_with( + "graphql.json", + json={"query": "query { key }", "variables": variables, "operationName": None}, + ) assert response == {"data": {"key": "value"}} + def test_query_paginated(graphql, mock_client): mock_client.post.side_effect = [ {"data": {"pageInfo": {"hasNextPage": True, "endCursor": "cursor1"}}}, - {"data": {"pageInfo": {"hasNextPage": False}}} + {"data": {"pageInfo": {"hasNextPage": False}}}, ] - results = list(graphql(query="query { pageInfo { hasNextPage, endCursor } }", paginate=True)) + results = list( + graphql(query="query { pageInfo { hasNextPage, endCursor } }", paginate=True) + ) assert len(results) == 2 - assert results[0] == {"data": {"pageInfo": {"hasNextPage": True, "endCursor": "cursor1"}}} + assert results[0] == { + "data": {"pageInfo": {"hasNextPage": True, "endCursor": "cursor1"}} + } assert results[1] == {"data": {"pageInfo": {"hasNextPage": False}}} + def test_query_reraises_http_error(graphql, mock_client): mock_client.post.side_effect = requests.exceptions.HTTPError("HTTP Error") with pytest.raises(requests.exceptions.HTTPError): graphql(query="query { key }") + def test_query_reraises_json_error(graphql, mock_client): mock_client.post.side_effect = json.JSONDecodeError("JSON Decode Error", "", 0) with pytest.raises(json.JSONDecodeError): graphql(query="query { key }") + def test_graphql_call(graphql, mock_client): mock_query_response = {"data": {"exampleField": "exampleValue"}} mock_client.post.return_value = mock_query_response @@ -63,50 +84,78 @@ def test_graphql_call(graphql, mock_client): response = graphql("query { exampleField }") # Verify that the query method was called correctly - mock_client.post.assert_called_once_with("graphql.json", json={'query': 'query { exampleField }', 'variables': None, 'operationName': None}) + mock_client.post.assert_called_once_with( + "graphql.json", + json={ + "query": "query { exampleField }", + "variables": None, + "operationName": None, + }, + ) # Assert the response is as expected assert response == mock_query_response + def test_graphql_query_paginated(graphql, mock_client, mocker): # Mock the paginated query method mock_paginated_response_1 = { "data": { "items": [1, 2, 3], - "pageInfo": { - "hasNextPage": True, - "endCursor": "cursor-1" - } + "pageInfo": {"hasNextPage": True, "endCursor": "cursor-1"}, } } mock_paginated_response_2 = { "data": { "items": [1, 2, 3], - "pageInfo": { - "hasNextPage": False, - "endCursor": "cursor-2" - } + "pageInfo": {"hasNextPage": False, "endCursor": "cursor-2"}, } } - mock_client.post = CopyingMock(side_effect = [mock_paginated_response_1, mock_paginated_response_2]) - response = list(graphql(query="query { items { id } pageInfo { hasNextPage, endCursor } }", paginate=True)) + mock_client.post = CopyingMock( + side_effect=[mock_paginated_response_1, mock_paginated_response_2] + ) + response = list( + graphql( + query="query { items { id } pageInfo { hasNextPage, endCursor } }", + paginate=True, + ) + ) assert response == [mock_paginated_response_1, mock_paginated_response_2] assert mock_client.post.call_count == 2 - mock_client.post.assert_has_calls([ - call("graphql.json", json={"query": "query { items { id } pageInfo { hasNextPage, endCursor } }", "variables": {"cursor": None, "page_size": 100}, "operationName": None}), - call("graphql.json", json={"query": "query { items { id } pageInfo { hasNextPage, endCursor } }", "variables": {"cursor": "cursor-1", "page_size": 100}, "operationName": None}), - ]) + mock_client.post.assert_has_calls( + [ + call( + "graphql.json", + json={ + "query": "query { items { id } pageInfo { hasNextPage, endCursor } }", + "variables": {"cursor": None, "page_size": 100}, + "operationName": None, + }, + ), + call( + "graphql.json", + json={ + "query": "query { items { id } pageInfo { hasNextPage, endCursor } }", + "variables": {"cursor": "cursor-1", "page_size": 100}, + "operationName": None, + }, + ), + ] + ) + def test_paginated_query_requires_page_info(graphql, mock_client): with pytest.raises(AssertionError): list(graphql(query="query { items { id } }", paginate=True)) + def test_paginated_query_requires_has_next_page(graphql, mock_client): with pytest.raises(AssertionError): list(graphql(query="query { pageInfo { endCursor } }", paginate=True)) + def test_paginated_query_requires_end_cursor(graphql, mock_client): with pytest.raises(AssertionError): list(graphql(query="query { pageInfo { hasNextPage } }", paginate=True)) diff --git a/tests/test_orders_endpoint.py b/tests/test_orders_endpoint.py index 3a39658..6062c88 100644 --- a/tests/test_orders_endpoint.py +++ b/tests/test_orders_endpoint.py @@ -1,16 +1,19 @@ import pytest from shopify_client.endpoint import OrdersEndpoint, Endpoint + @pytest.fixture def orders_endpoint(mock_client): return OrdersEndpoint(client=mock_client, endpoint="orders") + def test_cancel_order(orders_endpoint, mock_client): mock_client.request.return_value = {"result": "cancelled"} response = orders_endpoint.cancel(1) mock_client.request.assert_called_once_with("POST", "orders/1/cancel.json") assert response == {"result": "cancelled"} + def test_transactions_sub_endpoint(orders_endpoint): assert isinstance(orders_endpoint.transactions, Endpoint) assert orders_endpoint.transactions.endpoint == "orders"