diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2a44a442..c0cd5ab9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,7 @@ jobs: name: Python ${{ matrix.version }} strategy: matrix: - version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - name: Checkout @@ -18,7 +18,7 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip - pip install pytest mock pytest-cov + pip install pytest mock pytest-cov setuptools python setup.py install pytest --cov=./ --cov-report=xml - name: Run Tests @@ -27,4 +27,4 @@ jobs: uses: codecov/codecov-action@v1 with: name: codecov-umbrella - fail_ci_if_error: true + fail_ci_if_error: false diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 048bc5a2..f92848d7 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -3,7 +3,7 @@ name: pre-commit on: pull_request: push: - branches: [master] + branches: [main] jobs: pre-commit: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index cd1f94c0..2d0a3ec7 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -22,7 +22,7 @@ jobs: This probably means that it is not reproducible or it has been fixed in a newer version. If it’s an enhancement and hasn’t been taken on since it was submitted, then it seems other issues have taken priority. - If you still encounter this issue with the latest stable version, please reopen using the issue template. You can also contribute directly by submitting a pull request– see the [CONTRIBUTING.md](https://github.com/Shopify/shopify_python_api/blob/master/CONTRIBUTING.md) file for guidelines + If you still encounter this issue with the latest stable version, please reopen using the issue template. You can also contribute directly by submitting a pull request– see the [CONTRIBUTING.md](https://github.com/Shopify/shopify_python_api/blob/main/CONTRIBUTING.md) file for guidelines Thank you! days-before-pr-stale: -1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 43267923..f3500257 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,15 +2,15 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + rev: v5.0.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 24.10.0 hooks: - id: black - repo: https://github.com/PyCQA/pylint - rev: v2.15.8 + rev: v3.3.3 hooks: - id: pylint diff --git a/CHANGELOG b/CHANGELOG index c7309ca8..e9910c2e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,19 @@ == Unreleased +- Remove requirement to provide scopes to Permission URL, as it should be omitted if defined with the TOML file. + +== Version 12.7.0 + +- Remove requirement to use a predefined API version. Now you can use any valid API version string. ([#737](https://github.com/Shopify/shopify_python_api/pull/737)) + +== Version 12.6.0 + +- Update API version with 2024-07 release ([#723](https://github.com/Shopify/shopify_python_api/pull/723)) + +== Version 12.5.0 + - Remove `cgi` import to avoid triggering a `DeprecationWarning` on Python 3.11. +- Update API version with 2024-04 release.([710](https://github.com/Shopify/shopify_python_api/pull/710)) == Version 12.4.0 diff --git a/README.md b/README.md index 7dc84ed5..cadda24e 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ [![Build Status](https://github.com/Shopify/shopify_python_api/workflows/CI/badge.svg)](https://github.com/Shopify/shopify_python_api/actions) [![PyPI version](https://badge.fury.io/py/ShopifyAPI.svg)](https://badge.fury.io/py/ShopifyAPI) -[![codecov](https://codecov.io/gh/Shopify/shopify_python_api/branch/master/graph/badge.svg?token=pNTx0TARUx)](https://codecov.io/gh/Shopify/shopify_python_api) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/Shopify/shopify_python_api/blob/master/LICENSE) +![Supported Python Versions](https://img.shields.io/badge/python-3.7%20|%203.8%20|%203.9%20|%203.10%20|%203.11%20|%203.12-brightgreen) +[![codecov](https://codecov.io/gh/Shopify/shopify_python_api/branch/main/graph/badge.svg?token=pNTx0TARUx)](https://codecov.io/gh/Shopify/shopify_python_api) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/Shopify/shopify_python_api/blob/main/LICENSE) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) The [Shopify Admin API](https://shopify.dev/docs/admin-api) Python Library @@ -23,20 +24,28 @@ pip install --upgrade ShopifyAPI ### Table of Contents -- [Getting Started](#getting-started) - - [Public and Custom Apps](#public-and-custom-apps) - - [Private Apps](#private-apps) -- [Billing](#billing) -- [Session Tokens](docs/session-tokens.md) -- [Handling Access Scope Operations](docs/api-access.md) -- [Advanced Usage](#advanced-usage) -- [Prefix Options](#prefix-options) -- [Console](#console) -- [GraphQL](#graphql) +- [Usage](#usage) + - [Requirements](#requirements) + - [Installation](#installation) + - [Table of Contents](#table-of-contents) + - [Getting Started](#getting-started) + - [Public and Custom Apps](#public-and-custom-apps) + - [Private Apps](#private-apps) + - [With full session](#with-full-session) + - [With temporary session](#with-temporary-session) + - [Billing](#billing) + - [Advanced Usage](#advanced-usage) + - [Prefix options](#prefix-options) + - [Console](#console) + - [GraphQL](#graphql) - [Using Development Version](#using-development-version) + - [Building and installing dev version](#building-and-installing-dev-version) + - [Running Tests](#running-tests) - [Relative Cursor Pagination](#relative-cursor-pagination) +- [Set up pre-commit locally \[OPTIONAL\]](#set-up-pre-commit-locally-optional) - [Limitations](#limitations) - [Additional Resources](#additional-resources) + - [Sample apps built using this library](#sample-apps-built-using-this-library) ### Getting Started @@ -54,13 +63,15 @@ pip install --upgrade ShopifyAPI ```python shop_url = "SHOP_NAME.myshopify.com" - api_version = '2024-01' + api_version = '2024-07' state = binascii.b2a_hex(os.urandom(15)).decode("utf-8") redirect_uri = "http://myapp.com/auth/shopify/callback" + # `scope` should be omitted if provided by app's TOML scopes = ['read_products', 'read_orders'] newSession = shopify.Session(shop_url, api_version) - auth_url = newSession.create_permission_url(scopes, redirect_uri, state) + # `scope` should be omitted if provided by app's TOML + auth_url = newSession.create_permission_url(redirect_uri, scopes, state) # redirect to auth_url ``` @@ -78,10 +89,11 @@ pip install --upgrade ShopifyAPI session = shopify.Session(shop_url, api_version, access_token) shopify.ShopifyResource.activate_session(session) - shop = shopify.Shop.current() # Get the current shop - product = shopify.Product.find(179761209) # Get a specific product + # Note: REST API examples will be deprecated in 2025 + shop = shopify.Shop.current() # Get the current shop + product = shopify.Product.find(179761209) # Get a specific product - # execute a graphQL call + # GraphQL API example shopify.GraphQL().execute("{ shop { name id } }") ``` @@ -141,6 +153,13 @@ _Note: Your application must be public to test the billing process. To test on a ``` ### Advanced Usage + +> **⚠️ Note**: As of October 1, 2024, the REST Admin API is legacy: +> - Public apps must migrate to GraphQL by February 2025 +> - Custom apps must migrate to GraphQL by April 2025 +> +> For migration guidance, see [Shopify's migration guide](https://shopify.dev/docs/apps/build/graphql/migrate/new-product-model) + It is recommended to have at least a basic grasp on the principles of the [pyactiveresource](https://github.com/Shopify/pyactiveresource) library, which is a port of rails/ActiveResource to Python and upon which this package relies heavily. Instances of `pyactiveresource` resources map to RESTful resources in the Shopify API. @@ -148,6 +167,7 @@ Instances of `pyactiveresource` resources map to RESTful resources in the Shopif `pyactiveresource` exposes life cycle methods for creating, finding, updating, and deleting resources which are equivalent to the `POST`, `GET`, `PUT`, and `DELETE` HTTP verbs. ```python +# Note: REST API examples will be deprecated in 2025 product = shopify.Product() product.title = "Shopify Logo T-Shirt" product.id # => 292082188312 @@ -173,6 +193,7 @@ new_orders = shopify.Order.find(status="open", limit="50") Some resources such as `Fulfillment` are prefixed by a parent resource in the Shopify API (e.g. `orders/450789469/fulfillments/255858046`). In order to interact with these resources, you must specify the identifier of the parent resource in your request. ```python +# Note: This REST API example will be deprecated in the future shopify.Fulfillment.find(255858046, order_id=450789469) ``` @@ -187,6 +208,9 @@ This package also includes the `shopify_api.py` script to make it easy to open a This library also supports Shopify's new [GraphQL API](https://help.shopify.com/en/api/graphql-admin-api). The authentication process is identical. Once your session is activated, simply construct a new graphql client and use `execute` to execute the query. +> **Note**: Shopify recommends using GraphQL API for new development as REST API will be deprecated. +> See [Migration Guide](https://shopify.dev/docs/apps/build/graphql/migrate/new-product-model) for more details. + ```python result = shopify.GraphQL().execute('{ shop { name id } }') ``` @@ -263,7 +287,7 @@ page2 = shopify.Product.find(from_=next_url) ``` ## Set up pre-commit locally [OPTIONAL] -[Pre-commit](https://pre-commit.com/) is set up as a GitHub action that runs on pull requests and pushes to the `master` branch. If you want to run pre-commit locally, install it and set up the git hook scripts +[Pre-commit](https://pre-commit.com/) is set up as a GitHub action that runs on pull requests and pushes to the `main` branch. If you want to run pre-commit locally, install it and set up the git hook scripts ```shell pip install -r requirements.txt pre-commit install diff --git a/RELEASING b/RELEASING index f84fa314..a15667a0 100644 --- a/RELEASING +++ b/RELEASING @@ -15,6 +15,6 @@ Releasing shopify_python_api git tag -m "Release X.Y.Z" vX.Y.Z 7. Push the changes to github - git push --tags origin master + git push --tags origin main 8. Shipit! diff --git a/SECURITY.md b/SECURITY.md index 2d13b5e1..2a0e9c48 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ ### New features -New features will only be added to the master branch and will not be made available in point releases. +New features will only be added to the main branch and will not be made available in point releases. ### Bug fixes diff --git a/scripts/shopify_api.py b/scripts/shopify_api.py index 5dfab93a..bab35f15 100755 --- a/scripts/shopify_api.py +++ b/scripts/shopify_api.py @@ -128,7 +128,7 @@ def add(cls, connection): if os.path.exists(filename): raise ConfigFileError("There is already a config file at " + filename) else: - config = dict(protocol="https") + config = {"protocol": "https"} domain = input("Domain? (leave blank for %s.myshopify.com) " % (connection)) if not domain.strip(): domain = "%s.myshopify.com" % (connection) diff --git a/setup.py b/setup.py index dae5a810..eb23ab08 100755 --- a/setup.py +++ b/setup.py @@ -24,7 +24,8 @@ install_requires=[ "pyactiveresource>=2.2.2", "PyJWT >= 2.0.0", - "PyYAML", + "PyYAML>=6.0.1; python_version>='3.12'", + "PyYAML; python_version<'3.12'", "six", ], test_suite="test", @@ -44,6 +45,7 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/shopify/api_access.py b/shopify/api_access.py index d5ffbe35..19b80671 100644 --- a/shopify/api_access.py +++ b/shopify/api_access.py @@ -14,7 +14,6 @@ class ApiAccessError(Exception): class ApiAccess: - SCOPE_DELIMITER = "," SCOPE_RE = re.compile(r"\A(?Punauthenticated_)?(write|read)_(?P.*)\Z") IMPLIED_SCOPE_RE = re.compile(r"\A(?Punauthenticated_)?write_(?P.*)\Z") diff --git a/shopify/api_version.py b/shopify/api_version.py index ba137d57..32276668 100644 --- a/shopify/api_version.py +++ b/shopify/api_version.py @@ -17,6 +17,9 @@ def coerce_to_version(cls, version): try: return cls.versions[version] except KeyError: + # Dynamically create a new Release object if version string is not found + if Release.FORMAT.match(version): + return Release(version) raise VersionNotFoundError @classmethod @@ -37,6 +40,9 @@ def define_known_versions(cls): cls.define_version(Release("2023-07")) cls.define_version(Release("2023-10")) cls.define_version(Release("2024-01")) + cls.define_version(Release("2024-04")) + cls.define_version(Release("2024-07")) + cls.define_version(Release("2024-10")) @classmethod def clear_defined_versions(cls): diff --git a/shopify/mixins.py b/shopify/mixins.py index 54496dbf..5a13ca3a 100644 --- a/shopify/mixins.py +++ b/shopify/mixins.py @@ -24,7 +24,7 @@ def add_metafield(self, metafield): if self.is_new(): raise ValueError("You can only add metafields to a resource that has been saved") - metafield._prefix_options = dict(resource=self.__class__.plural, resource_id=self.id) + metafield._prefix_options = {"resource": self.__class__.plural, "resource_id": self.id} metafield.save() return metafield diff --git a/shopify/resources/__init__.py b/shopify/resources/__init__.py index 16220739..0d420b38 100644 --- a/shopify/resources/__init__.py +++ b/shopify/resources/__init__.py @@ -38,7 +38,7 @@ from .page import Page from .country import Country from .refund import Refund -from .fulfillment import Fulfillment, FulfillmentOrders +from .fulfillment import Fulfillment, FulfillmentOrders, FulfillmentV2 from .fulfillment_event import FulfillmentEvent from .fulfillment_service import FulfillmentService from .carrier_service import CarrierService diff --git a/shopify/session.py b/shopify/session.py index 39ce5f7b..561faacf 100644 --- a/shopify/session.py +++ b/shopify/session.py @@ -53,8 +53,11 @@ def __init__(self, shop_url, version=None, token=None, access_scopes=None): self.access_scopes = access_scopes return - def create_permission_url(self, scope, redirect_uri, state=None): - query_params = dict(client_id=self.api_key, scope=",".join(scope), redirect_uri=redirect_uri) + def create_permission_url(self, redirect_uri, scope=None, state=None): + query_params = {"client_id": self.api_key, "redirect_uri": redirect_uri} + # `scope` should be omitted if provided by app's TOML + if scope: + query_params["scope"] = ",".join(scope) if state: query_params["state"] = state return "https://%s/admin/oauth/authorize?%s" % (self.url, urllib.parse.urlencode(query_params)) @@ -69,7 +72,7 @@ def request_token(self, params): code = params["code"] url = "https://%s/admin/oauth/access_token?" % self.url - query_params = dict(client_id=self.api_key, client_secret=self.secret, code=code) + query_params = {"client_id": self.api_key, "client_secret": self.secret, "code": code} request = urllib.request.Request(url, urllib.parse.urlencode(query_params).encode("utf-8")) response = urllib.request.urlopen(request) diff --git a/shopify/version.py b/shopify/version.py index 7c16e69e..dfb0b4e4 100644 --- a/shopify/version.py +++ b/shopify/version.py @@ -1 +1 @@ -VERSION = "12.4.0" +VERSION = "12.7.1" diff --git a/test/api_version_test.py b/test/api_version_test.py index 3089daee..9dce8cb2 100644 --- a/test/api_version_test.py +++ b/test/api_version_test.py @@ -29,6 +29,20 @@ def test_coerce_to_version_raises_with_string_that_does_not_match_known_version( with self.assertRaises(shopify.VersionNotFoundError): shopify.ApiVersion.coerce_to_version("crazy-name") + def test_coerce_to_version_creates_new_release_on_the_fly(self): + new_version = "2025-01" + coerced_version = shopify.ApiVersion.coerce_to_version(new_version) + + self.assertIsInstance(coerced_version, shopify.Release) + self.assertEqual(coerced_version.name, new_version) + self.assertEqual( + coerced_version.api_path("https://test.myshopify.com"), + f"https://test.myshopify.com/admin/api/{new_version}", + ) + + # Verify that the new version is not added to the known versions + self.assertNotIn(new_version, shopify.ApiVersion.versions) + class ReleaseTest(TestCase): def test_raises_if_format_invalid(self): diff --git a/test/session_test.py b/test/session_test.py index 806d551b..8d73e293 100644 --- a/test/session_test.py +++ b/test/session_test.py @@ -86,51 +86,69 @@ def test_temp_works_without_currently_active_session(self): self.assertEqual("https://testshop.myshopify.com/admin/api/unstable", assigned_site) self.assertEqual("https://none/admin/api/unstable", shopify.ShopifyResource.site) - def test_create_permission_url_returns_correct_url_with_single_scope_and_redirect_uri(self): + def test_create_permission_url_returns_correct_url_with_redirect_uri(self): + shopify.Session.setup(api_key="My_test_key", secret="My test secret") + session = shopify.Session("http://localhost.myshopify.com", "unstable") + permission_url = session.create_permission_url("my_redirect_uri.com") + self.assertEqual( + "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com", + self.normalize_url(permission_url), + ) + + def test_create_permission_url_returns_correct_url_with_redirect_uri_and_single_scope(self): shopify.Session.setup(api_key="My_test_key", secret="My test secret") session = shopify.Session("http://localhost.myshopify.com", "unstable") scope = ["write_products"] - permission_url = session.create_permission_url(scope, "my_redirect_uri.com") + permission_url = session.create_permission_url("my_redirect_uri.com", scope=scope) self.assertEqual( "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&scope=write_products", self.normalize_url(permission_url), ) - def test_create_permission_url_returns_correct_url_with_dual_scope_and_redirect_uri(self): + def test_create_permission_url_returns_correct_url_with_redirect_uri_and_dual_scope(self): shopify.Session.setup(api_key="My_test_key", secret="My test secret") session = shopify.Session("http://localhost.myshopify.com", "unstable") scope = ["write_products", "write_customers"] - permission_url = session.create_permission_url(scope, "my_redirect_uri.com") + permission_url = session.create_permission_url("my_redirect_uri.com", scope=scope) self.assertEqual( "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&scope=write_products%2Cwrite_customers", self.normalize_url(permission_url), ) - def test_create_permission_url_returns_correct_url_with_no_scope_and_redirect_uri(self): + def test_create_permission_url_returns_correct_url_with_redirect_uri_and_empty_scope(self): shopify.Session.setup(api_key="My_test_key", secret="My test secret") session = shopify.Session("http://localhost.myshopify.com", "unstable") scope = [] - permission_url = session.create_permission_url(scope, "my_redirect_uri.com") + permission_url = session.create_permission_url("my_redirect_uri.com", scope=scope) + self.assertEqual( + "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com", + self.normalize_url(permission_url), + ) + + def test_create_permission_url_returns_correct_url_with_redirect_uri_and_state(self): + shopify.Session.setup(api_key="My_test_key", secret="My test secret") + session = shopify.Session("http://localhost.myshopify.com", "unstable") + permission_url = session.create_permission_url("my_redirect_uri.com", state="mystate") self.assertEqual( - "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&scope=", + "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&state=mystate", self.normalize_url(permission_url), ) - def test_create_permission_url_returns_correct_url_with_no_scope_and_redirect_uri_and_state(self): + def test_create_permission_url_returns_correct_url_with_redirect_uri_empty_scope_and_state(self): shopify.Session.setup(api_key="My_test_key", secret="My test secret") session = shopify.Session("http://localhost.myshopify.com", "unstable") scope = [] - permission_url = session.create_permission_url(scope, "my_redirect_uri.com", state="mystate") + permission_url = session.create_permission_url("my_redirect_uri.com", scope=scope, state="mystate") self.assertEqual( - "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&scope=&state=mystate", + "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&state=mystate", self.normalize_url(permission_url), ) - def test_create_permission_url_returns_correct_url_with_single_scope_and_redirect_uri_and_state(self): + def test_create_permission_url_returns_correct_url_with_redirect_uri_and_single_scope_and_state(self): shopify.Session.setup(api_key="My_test_key", secret="My test secret") session = shopify.Session("http://localhost.myshopify.com", "unstable") scope = ["write_customers"] - permission_url = session.create_permission_url(scope, "my_redirect_uri.com", state="mystate") + permission_url = session.create_permission_url("my_redirect_uri.com", scope=scope, state="mystate") self.assertEqual( "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&scope=write_customers&state=mystate", self.normalize_url(permission_url), @@ -288,3 +306,16 @@ def normalize_url(self, url): scheme, netloc, path, query, fragment = urllib.parse.urlsplit(url) query = "&".join(sorted(query.split("&"))) return urllib.parse.urlunsplit((scheme, netloc, path, query, fragment)) + + def test_session_with_coerced_version(self): + future_version = "2030-01" + session = shopify.Session("test.myshopify.com", future_version, "token") + self.assertEqual(session.api_version.name, future_version) + self.assertEqual( + session.api_version.api_path("https://test.myshopify.com"), + f"https://test.myshopify.com/admin/api/{future_version}", + ) + + def test_session_with_invalid_version(self): + with self.assertRaises(shopify.VersionNotFoundError): + shopify.Session("test.myshopify.com", "invalid-version", "token")