diff --git a/.github/probots.yml b/.github/probots.yml deleted file mode 100644 index 1491d275..00000000 --- a/.github/probots.yml +++ /dev/null @@ -1,2 +0,0 @@ -enabled: - - cla diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ea9c6395..c0cd5ab9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,24 +1,24 @@ name: CI -on: [push] +on: [push, pull_request] jobs: build: runs-on: ubuntu-latest name: Python ${{ matrix.version }} strategy: matrix: - version: [2.7, 3.6, 3.8, 3.9] + version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - name: Checkout uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.version }} - 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/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 00000000..2c3a4042 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,22 @@ +name: Contributor License Agreement (CLA) + +on: + pull_request_target: + types: [opened, synchronize] + issue_comment: + types: [created] + +jobs: + cla: + runs-on: ubuntu-latest + if: | + (github.event.issue.pull_request + && !github.event.issue.pull_request.merged_at + && contains(github.event.comment.body, 'signed') + ) + || (github.event.pull_request && !github.event.pull_request.merged) + steps: + - uses: Shopify/shopify-cla-action@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + cla-token: ${{ secrets.CLA_TOKEN }} diff --git a/.github/workflows/close-waiting-for-response-issues.yml b/.github/workflows/close-waiting-for-response-issues.yml new file mode 100644 index 00000000..ffd7a382 --- /dev/null +++ b/.github/workflows/close-waiting-for-response-issues.yml @@ -0,0 +1,20 @@ +name: Close Waiting for Response Issues +on: + schedule: + - cron: "30 1 * * *" + workflow_dispatch: +jobs: + check-need-info: + runs-on: ubuntu-latest + steps: + - name: close-issues + uses: actions-cool/issues-helper@v3 + with: + actions: 'close-issues' + token: ${{ secrets.GITHUB_TOKEN }} + labels: 'Waiting for Response' + inactive-day: 7 + body: | + We are closing this issue because we did not hear back regarding additional details we needed to resolve this issue. If the issue persists and you are able to provide the missing clarification we need, feel free to respond and reopen this issue. + + We appreciate your understanding as we try to manage our number of open issues. diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 54fc61d5..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: @@ -12,6 +12,6 @@ jobs: - name: Checkout uses: actions/checkout@v2 - name: Setup - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 - name: Pre-commit uses: pre-commit/action@v2.0.0 diff --git a/.github/workflows/remove-labels-on-activity.yml b/.github/workflows/remove-labels-on-activity.yml new file mode 100644 index 00000000..948801e7 --- /dev/null +++ b/.github/workflows/remove-labels-on-activity.yml @@ -0,0 +1,15 @@ +name: Remove Stale or Waiting Labels +on: + issue_comment: + types: [created] + workflow_dispatch: +jobs: + remove-labels-on-activity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions-ecosystem/action-remove-labels@v1 + if: contains(github.event.issue.labels.*.name, 'Waiting for Response') + with: + labels: | + Waiting for Response diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..2d0a3ec7 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,32 @@ +name: Close inactive issues +on: + schedule: + - cron: "30 1 * * *" + +jobs: + close-issues: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v5 + with: + days-before-issue-stale: 60 + days-before-issue-close: 14 + stale-issue-label: "Stale" + stale-issue-message: > + This issue is stale because it has been open for 60 days with no activity. It will be closed if no further action occurs in 14 days. + close-issue-message: | + We are closing this issue because it has been inactive for a few months. + 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/main/CONTRIBUTING.md) file for guidelines + + Thank you! + days-before-pr-stale: -1 + days-before-pr-close: -1 + repo-token: ${{ secrets.GITHUB_TOKEN }} + exempt-issue-labels: "feature request" + close-issue-reason: "not_planned" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6aec2f5b..f3500257 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,16 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 hooks: - - id: end-of-file-fixer - - id: trailing-whitespace -- repo: https://github.com/psf/black - rev: 20.8b1 + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/psf/black + rev: 24.10.0 hooks: - - id: black -- repo: https://github.com/PyCQA/pylint - rev: pylint-2.7.2 + - id: black + - repo: https://github.com/PyCQA/pylint + rev: v3.3.3 hooks: - - id: pylint + - id: pylint diff --git a/CHANGELOG b/CHANGELOG index 65b6b8c0..e9910c2e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,18 +1,77 @@ == 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 + +- Update API version with 2023-07, 2023-10, 2024-01 releases ([#694](https://github.com/Shopify/shopify_python_api/pull/694)) + +== Version 12.3.0 + +- Update API version with 2023-04 release ([#649](https://github.com/Shopify/shopify_python_api/pull/649)) + +== Version 12.2.0 + +- Update API version with 2023-01 release ([#631](https://github.com/Shopify/shopify_python_api/pull/631)) + +== Version 12.1.0 + +- Add API version with 2022-10 release + +== Version 12.0.1 + +- Allow up to 10 seconds clock skew to avoid `ImmatureSignatureError` + ([#609](https://github.com/Shopify/shopify_python_api/pull/609)) + +== Version 12.0.0 + +- Update API version with 2022-04 release, remove API version 2021-07 ([#591](https://github.com/Shopify/shopify_python_api/pull/591)) + +== Version 11.0.0 + +- Update API version with 2022-04 release +- remove API version 2020-10, 2021-01, 2021-04 as they are all unsupported as of 2022-04 + +== Version 10.0.0 + +- Update API version with 2022-01 release, remove API version 2020-07 + +== Version 9.0.0 + +- Drop Python 2 support ([#549](https://github.com/Shopify/shopify_python_api/pull/549)) +- Update API version with 2021-10 release, remove API version 2020-04 ([#548](https://github.com/Shopify/shopify_python_api/pull/548)) + == Version 8.4.2 + - Update API version with 2021-07 release, remove API version 2020-01 ([#521](https://github.com/Shopify/shopify_python_api/pull/521)) == Version 8.4.1 + - Bug fix: `sanitize_shop_domain` now returns `None` rather than `'none.myshopify.com'` if no `shop_domain` arg is passed in ([#499](https://github.com/Shopify/shopify_python_api/pull/499)) == Version 8.4.0 + - Revert Feature #441 Dynamic API Versioning ([#495](https://github.com/Shopify/shopify_python_api/pull/495)) == Version 8.3.1 + - Fix bug: Add the `shopify/utils` sub-package when building the source distribution ([#493](https://github.com/Shopify/shopify_python_api/pull/493)) == Version 8.3.0 + - Add support for [session tokens](https://shopify.dev/concepts/apps/building-embedded-apps-using-session-tokens) ([#479](https://github.com/Shopify/shopify_python_api/pull/479)) - Use `session_token.decode_from_header` to obtain a decoded session token from an HTTP Authorization header - Create a `utils` sub-package with a `shop_url` utility file ([#483](https://github.com/Shopify/shopify_python_api/pull/483)) @@ -26,297 +85,325 @@ - Add support for retrieving all orders using customer_id ([#466](https://github.com/Shopify/shopify_python_api/pull/466)) == Version 8.2.0 + - [Feature] Add support for Dynamic API Versioning. When the library is initialized, it will now make a request to -Shopify to fetch a list of the available API versions. ([#441](https://github.com/Shopify/shopify_python_api/pull/441)) + Shopify to fetch a list of the available API versions. ([#441](https://github.com/Shopify/shopify_python_api/pull/441)) == Version 8.1.0 + - [Feature] Add support for Shopify Payments resources (#428) == Version 8.0.4 + - Release API version 2020-10 - Deprecate API version 2019-10 == Version 8.0.3 -- Patch for replacing call to _build_list() with _build_collection() in gift_card.py + +- Patch for replacing call to \_build_list() with \_build_collection() in gift_card.py == Version 8.0.2 + - Patch for product updating with variants == Version 8.0.1 + - release api version 2020-07 - deprecate api version 2019-07 - Add support for FulfillmentOrder resource (#390) == Version 8.0.0 + - release api version 2020-04 - deprecate api version 2019-04 == Version 7.0.3 + - bug fix for temporary sessions - deprecation fix for regexs == Version 7.0.2 + - bug fix for variant updates after the 2019-10 api version == Version 7.0.1 + - bug fix for string interpolation == Version 7.0.0 -* Made no_iter_next default to True on collection so that by default it only -fetches a single page -* Passes kwargs to paginated collections so that attributes can be set with -find() -* Allow case insensitive check for the link header for cursor pagination. + +- Made no_iter_next default to True on collection so that by default it only + fetches a single page +- Passes kwargs to paginated collections so that attributes can be set with + find() +- Allow case insensitive check for the link header for cursor pagination. == Version 6.0.1 -* Made the collection access more consistent so that there is no confusion -between a collection and a paginated collection + +- Made the collection access more consistent so that there is no confusion + between a collection and a paginated collection == Version 6.0.0 -* Add Cursor pagination support + +- Add Cursor pagination support == Version 5.1.2 -* Add version 2020-01 to known ApiVersions. This version will not be usable until October 2019. + +- Add version 2020-01 to known ApiVersions. This version will not be usable until October 2019. == Version 5.1.1 -* Fix initializing API with basic auth URL. + +- Fix initializing API with basic auth URL. == Version 5.1.0 -* Added support for GraphQL queries with a GraphQL resource + +- Added support for GraphQL queries with a GraphQL resource == Version 5.0.1 -* Fixing missing class variable causing exception when creating a session without a token + +- Fixing missing class variable causing exception when creating a session without a token == Version 5.0.0 -* Added support for Shopify API Versioning + +- Added support for Shopify API Versioning == Version 4.0.0 -* Added AccessScope resource -* Added ApiPermission resource -* Added User resource -* Added Publication, CollectionPublication and ProductPublication resources -* Added Currency resource -* Added TenderTransaction resource -* Added shopify.Limits class, for retrieving the current status of Shopify rate limiting. -* Added support for Refund.calculate -* Added support for Location.inventory_levels -* Added support for PriceRule batch operations -* Removed `cancel()` method for RecurringApplicationCharge resource (use `destroy()` going forward) -* Fix for handling array query parameters (e.g. `foo[]=1&foo[]=2`) during HMAC calculation -* Fixed Python 3 compatibility with the API console + +- Added AccessScope resource +- Added ApiPermission resource +- Added User resource +- Added Publication, CollectionPublication and ProductPublication resources +- Added Currency resource +- Added TenderTransaction resource +- Added shopify.Limits class, for retrieving the current status of Shopify rate limiting. +- Added support for Refund.calculate +- Added support for Location.inventory_levels +- Added support for PriceRule batch operations +- Removed `cancel()` method for RecurringApplicationCharge resource (use `destroy()` going forward) +- Fix for handling array query parameters (e.g. `foo[]=1&foo[]=2`) during HMAC calculation +- Fixed Python 3 compatibility with the API console == Version 3.1.0 -* Adds InventoryItem resource -* Adds InventoryLevel resource -* Adds GiftCardAdjustment resource -* Fix to properly handle byte data for Asset.attach() + +- Adds InventoryItem resource +- Adds InventoryLevel resource +- Adds GiftCardAdjustment resource +- Fix to properly handle byte data for Asset.attach() == Version 3.0.0 -* Added CollectListing resource -* Added ResourceFeedback resource -* Added StorefrontAccessToken resource -* Added ProductListing resource -* Removed deprecated ProductSearchEngine resource -* Removed deprecated Discount resource -* Fixed Python3 compatibility issue with `Image.attach_image()` + +- Added CollectListing resource +- Added ResourceFeedback resource +- Added StorefrontAccessToken resource +- Added ProductListing resource +- Removed deprecated ProductSearchEngine resource +- Removed deprecated Discount resource +- Fixed Python3 compatibility issue with `Image.attach_image()` == Version 2.6.0 -* Added support for Marketing Event API through Marketing Event resource + +- Added support for Marketing Event API through Marketing Event resource == Version 2.5.1 -* Fixed an issue preventing creation of Order Risk resources + +- Fixed an issue preventing creation of Order Risk resources == Version 2.5.0 -* Added Price Rule and Discount Code resources + +- Added Price Rule and Discount Code resources == Version 2.4.0 -* Add support for report publishing + +- Add support for report publishing == Version 2.3.0 -* Add support for customer#send_invite + +- Add support for customer#send_invite == Version 2.2.0 -* Add support for draft orders + +- Add support for draft orders == Version 2.1.8 -* Added support for `open` method on fulfillments + +- Added support for `open` method on fulfillments == Version 2.1.7 -* Removed all references to the deprecated MD5 `signature` parameter which is no longer provided by Shopify. +- Removed all references to the deprecated MD5 `signature` parameter which is no longer provided by Shopify. == Version 2.1.6 -* Added Refund resource +- Added Refund resource == Version 2.1.5 -* bump pyactiveresource for camelcase bugfix +- bump pyactiveresource for camelcase bugfix == Version 2.1.4 == Version 2.1.3 -* Fixed hmac signature validation for params with delimiters (`&`, `=` or `%`) +- Fixed hmac signature validation for params with delimiters (`&`, `=` or `%`) == Version 2.1.2 -* Fixed an issue with unicode strings in params passed to validate_hmac -* Added shop domain verification when creating a session +- Fixed an issue with unicode strings in params passed to validate_hmac +- Added shop domain verification when creating a session == Version 2.1.1 -* Added Checkout resource -* Updated to pyactiveresource v2.1.1 which includes a test-related bugfix -* Changed OAuth validation from MD5 to HMAC-SHA256 +- Added Checkout resource +- Updated to pyactiveresource v2.1.1 which includes a test-related bugfix +- Changed OAuth validation from MD5 to HMAC-SHA256 == Version 2.1.0 -* Added python 3 compatibility -* Fixed setting the format attribute on carrier and fulfillment services -* Add a specific exception for signature validation failures +- Added python 3 compatibility +- Fixed setting the format attribute on carrier and fulfillment services +- Add a specific exception for signature validation failures == Version 2.0.4 -* Bug fixes -* Added CarrierService resource -* Added Property resource to LineItem +- Bug fixes +- Added CarrierService resource +- Added Property resource to LineItem == Version 2.0.3 -* Add Order Risk resource +- Add Order Risk resource == Version 2.0.2 -* Add access to FulfillmentService endpoint -* Fix some import bugs +- Add access to FulfillmentService endpoint +- Fix some import bugs == Version 2.0.1 -* Package bug fix +- Package bug fix == Version 2.0.0 -* Removed support for legacy auth -* Updated to pyactiveresource v2.0.0 which changes the default form to JSON -* in Session::request_token params is no longer optional, you must pass all the params +- Removed support for legacy auth +- Updated to pyactiveresource v2.0.0 which changes the default form to JSON +- in Session::request_token params is no longer optional, you must pass all the params and the method will now extract the code -* made create_permission_url an instance method, you'll need an instance +- made create_permission_url an instance method, you'll need an instance of session to call this method from now on -* Updated session.request_token -* Updated Session to better match the ShopifyAPI Ruby gem -* Updated the readme to better describe how to use the library -* Added support for CustomerSavedSearch (CustomerGroup is deprecated) +- Updated session.request_token +- Updated Session to better match the ShopifyAPI Ruby gem +- Updated the readme to better describe how to use the library +- Added support for CustomerSavedSearch (CustomerGroup is deprecated) == Version 1.0.7 -* Fix thread local headers to store a copy of the default hash which -prevents activate_session in one thread from affecting other threads. +- Fix thread local headers to store a copy of the default hash which + prevents activate_session in one thread from affecting other threads. == Version 1.0.6 -* Fix deserializing and serializing fulfillments which can now contain -arrays of strings in the tracking_urls attribute. +- Fix deserializing and serializing fulfillments which can now contain + arrays of strings in the tracking_urls attribute. == Version 1.0.5 -* Fix parameter passing for order cancellation. -* Fix Product.price_range method for variants with different prices. +- Fix parameter passing for order cancellation. +- Fix Product.price_range method for variants with different prices. == Version 1.0.4 -* Fixed another bug in Image size methods regex. +- Fixed another bug in Image size methods regex. == Version 1.0.3 -* Fix bug in setting format attribute on Webhook instances. -* Fixed missing slash in return value of Image size methods -* Upgrade pyactiveresource to fix unicode encoding issues +- Fix bug in setting format attribute on Webhook instances. +- Fixed missing slash in return value of Image size methods +- Upgrade pyactiveresource to fix unicode encoding issues == Version 1.0.2 -* Made ShopifyResource.clear_session idempotent. +- Made ShopifyResource.clear_session idempotent. == Version 1.0.1 -* Use the correct redirect parameter in Session.create_permission_url. -Was redirect_url but corrected to redirect_uri. +- Use the correct redirect parameter in Session.create_permission_url. + Was redirect_url but corrected to redirect_uri. == Version 1.0.0 -* Added support for OAuth2. -* ShopifyResource.activate_session must now be used with OAuth2 instead -of setting ShopifyResource.site directly. -* Session.__init__ no longer allows params to be passed in as **params -* Session.__init__ now makes an HTTP request when using OAuth2 if -params are specified -* Session now exposes the access token through the token instance -variable to simplify session saving and resuming +- Added support for OAuth2. +- ShopifyResource.activate_session must now be used with OAuth2 instead + of setting ShopifyResource.site directly. +- Session.**init** no longer allows params to be passed in as \*\*params +- Session.**init** now makes an HTTP request when using OAuth2 if + params are specified +- Session now exposes the access token through the token instance + variable to simplify session saving and resuming == Version 0.4.0 -* Using setup.py no longer requires all dependencies -* More compatibility fixes for using the latest pyactiveresource -* ShopifyResource.activate_session is not recommended over setting site -directly for forward compatibility with coming OAuth2 changes. +- Using setup.py no longer requires all dependencies +- More compatibility fixes for using the latest pyactiveresource +- ShopifyResource.activate_session is not recommended over setting site + directly for forward compatibility with coming OAuth2 changes. == Version 0.3.1 -* Compatibility fixes for using latest (unreleased) pyactiveresource +- Compatibility fixes for using latest (unreleased) pyactiveresource == Version 0.3.0 -* Added support for customer search and customer group search. -* Resource errors are cleared on save from previous save attempt. -* Made the library thread-safe using thread-local connections. +- Added support for customer search and customer group search. +- Resource errors are cleared on save from previous save attempt. +- Made the library thread-safe using thread-local connections. == Version 0.2.1 -* Fixed a regression that caused a different connection -object to be created on each resource. +- Fixed a regression that caused a different connection + object to be created on each resource. == Version 0.2.0 -* Made responses available through the connection object. +- Made responses available through the connection object. == Version 0.1.8 -* Added ability to add metafields on customers. +- Added ability to add metafields on customers. == Version 0.1.7 -* Fixed missing theme_id in return value of Asset.find. +- Fixed missing theme_id in return value of Asset.find. == Version 0.1.6 -* Fixed attribute setting on Asset objects -* Strip path from shop_url to get just the shop's domain. +- Fixed attribute setting on Asset objects +- Strip path from shop_url to get just the shop's domain. == Version 0.1.5 -* Fixed Asset.find() -* Fixed Variant.find(id) -* Allow running from source directory with PYTHONPATH=./lib +- Fixed Asset.find() +- Fixed Variant.find(id) +- Allow running from source directory with PYTHONPATH=./lib == Version 0.1.4 -* Fixed a bug in metafields method caused by missing import. -* Prefix options can be specified in the attributes dict on creation -* Allow count method to be used the same way as find +- Fixed a bug in metafields method caused by missing import. +- Prefix options can be specified in the attributes dict on creation +- Allow count method to be used the same way as find == Version 0.1.3 -* Fixed the automatic download of dependencies. -* Updated the README instructions. +- Fixed the automatic download of dependencies. +- Updated the README instructions. == Version 0.1.2 -* Add python 2.5 compatibility +- Add python 2.5 compatibility == Version 0.1.1 -* Make creating a session simpler with django +- Make creating a session simpler with django == Version 0.1.0 -* ported ShopifyAPI from ruby to python +- ported ShopifyAPI from ruby to python diff --git a/README.md b/README.md index 6dc85ff6..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) -- [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 @@ -46,23 +55,27 @@ pip install --upgrade ShopifyAPI 1. We then need to supply these keys to the Shopify Session Class so that it knows how to authenticate. ```python + import shopify + shopify.Session.setup(api_key=API_KEY, secret=API_SECRET) ``` 1. In order to access a shop's data, apps need an access token from that specific shop. We need to authenticate with that shop using OAuth, which we can start in the following way: ```python shop_url = "SHOP_NAME.myshopify.com" - api_version = '2020-10' + 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 ``` -1. Once the merchant accepts, the shop redirects the owner to the `redirect_uri` of your application with a parameter named 'code'. This is a temporary token that the app can exchange for a permanent access token. You should compare the state you provided above with the one you recieved back to ensure the request is correct. Now we can exchange the code for an access_token when you get the request from shopify in your callback handler: +1. Once the merchant accepts, the shop redirects the owner to the `redirect_uri` of your application with a parameter named 'code'. This is a temporary token that the app can exchange for a permanent access token. You should compare the state you provided above with the one you received back to ensure the request is correct. Now we can exchange the code for an access_token when you get the request from shopify in your callback handler: ```python session = shopify.Session(shop_url, api_version) @@ -76,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 } }") ``` @@ -139,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. @@ -146,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 @@ -160,11 +182,18 @@ product.destroy() # Delete the resource from the remote server (i.e. Shopify) ``` +Here is another example to retrieve a list of open orders using certain parameters: + +```python +new_orders = shopify.Order.find(status="open", limit="50") +``` + ### Prefix options 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) ``` @@ -179,10 +208,52 @@ 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 } }') ``` +You can perform more complex operations using the `variables` and `operation_name` parameters of `execute`. + +For example, this GraphQL document uses a fragment to construct two named queries - one for a single order, and one for multiple orders: + +```graphql + # ./order_queries.graphql + + fragment OrderInfo on Order { + id + name + createdAt + } + + query GetOneOrder($order_id: ID!){ + node(id: $order_id){ + ...OrderInfo + } + } + + query GetManyOrders($order_ids: [ID]!){ + nodes(ids: $order_ids){ + ...OrderInfo + } + } +``` + +Now you can choose which operation to execute: + +```python +# Load the document with both queries +document = Path("./order_queries.graphql").read_text() + +# Specify the named operation to execute, and the parameters for the query +result = shopify.GraphQL().execute( + query=document, + variables={"order_id": "gid://shopify/Order/12345"}, + operation_name="GetOneOrder", +) +``` ## Using Development Version @@ -203,7 +274,7 @@ python setup.py test ## Relative Cursor Pagination Cursor based pagination support has been added in 6.0.0. -``` +```python import shopify page1 = shopify.Product.find() @@ -216,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 @@ -234,3 +305,6 @@ Currently there is no support for: * [developers.shopify.com](https://developers.shopify.com) * [Shopify.dev](https://shopify.dev) * [Ask questions on the Shopify forums](http://ecommerce.shopify.com/c/shopify-apis-and-technology) + +### Sample apps built using this library +* [Sample Django app](https://github.com/shopify/sample-django-app) 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/dev.yml b/dev.yml index 6c37588a..84a23200 100644 --- a/dev.yml +++ b/dev.yml @@ -4,7 +4,7 @@ name: shopify-python-api type: python up: - - python: 3.8.0 + - python: 3.8.13 - pip: - requirements.txt diff --git a/docs/api-access.md b/docs/api-access.md index 41de61db..52424a24 100644 --- a/docs/api-access.md +++ b/docs/api-access.md @@ -24,7 +24,7 @@ another_api_access = ApiAccess("read_products, write_products, unauthenticated_r api_access = ApiAccess(["read_products", "write_orders", "unauthenticated_read_themes"]) access_scopes_list = list(api_access) # ["read_products", "write_orders", "unauthenticated_read_themes"] -comma_delmited_access_scopes = str(api_access) # "read_products,write_orders,unauthenticated_read_themes" +comma_delimited_access_scopes = str(api_access) # "read_products,write_orders,unauthenticated_read_themes" ``` ### Comparing ApiAccess objects @@ -60,7 +60,7 @@ from shopify import ApiAccess def oauth_on_access_scopes_mismatch(func): def wrapper(*args, **kwargs): - shop_domain = get_shop_query_paramer(request) # shop query param when loading app + shop_domain = get_shop_query_parameter(request) # shop query param when loading app current_shop_scopes = ApiAccess(ShopStore.get_record(shopify_domain = shop_domain).access_scopes) expected_access_scopes = ApiAccess(SHOPIFY_API_SCOPES) diff --git a/scripts/shopify_api.py b/scripts/shopify_api.py index 355987ad..bab35f15 100755 --- a/scripts/shopify_api.py +++ b/scripts/shopify_api.py @@ -16,10 +16,18 @@ def start_interpreter(**variables): # add the current working directory to the sys paths sys.path.append(os.getcwd()) - console = type("shopify " + shopify.version.VERSION, (code.InteractiveConsole, object), {}) - import readline + try: + from IPython import start_ipython + from traitlets.config.loader import Config - console(variables).interact() + config = Config(TerminalInteractiveShell={"banner2": "(shopify %s)" % shopify.version.VERSION}) + start_ipython(argv=[], user_ns=variables, config=config) + + except ImportError: + console = type("shopify " + shopify.version.VERSION, (code.InteractiveConsole, object), {}) + import readline + + console(variables).interact() class ConfigFileError(Exception): @@ -58,7 +66,7 @@ def run_task(cls, task=None, *args): cls.help() return - # Allow unambigious abbreviations of tasks + # Allow unambiguous abbreviations of tasks if task not in cls._tasks: matches = filter(lambda item: item.startswith(task), cls._tasks) list_of_matches = list(matches) @@ -120,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 cbd2a336..eb23ab08 100755 --- a/setup.py +++ b/setup.py @@ -23,9 +23,9 @@ license="MIT License", install_requires=[ "pyactiveresource>=2.2.2", - "PyJWT <= 1.7.1; python_version == '2.7'", - "PyJWT >= 2.0.0; python_version >= '3.6'", - "PyYAML", + "PyJWT >= 2.0.0", + "PyYAML>=6.0.1; python_version>='3.12'", + "PyYAML; python_version<'3.12'", "six", ], test_suite="test", @@ -39,14 +39,13 @@ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/shipit.yml b/shipit.pypi.yml similarity index 100% rename from shipit.yml rename to shipit.pypi.yml 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 8deac0e8..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 @@ -27,12 +30,19 @@ def define_version(cls, version): @classmethod def define_known_versions(cls): cls.define_version(Unstable()) - cls.define_version(Release("2020-04")) - cls.define_version(Release("2020-07")) - cls.define_version(Release("2020-10")) - cls.define_version(Release("2021-01")) - cls.define_version(Release("2021-04")) - cls.define_version(Release("2021-07")) + cls.define_version(Release("2021-10")) + cls.define_version(Release("2022-01")) + cls.define_version(Release("2022-04")) + cls.define_version(Release("2022-07")) + cls.define_version(Release("2022-10")) + cls.define_version(Release("2023-01")) + cls.define_version(Release("2023-04")) + 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/base.py b/shopify/base.py index 47f40cb3..449e288b 100644 --- a/shopify/base.py +++ b/shopify/base.py @@ -17,9 +17,6 @@ class ShopifyConnection(pyactiveresource.connection.Connection): response = None - def __init__(self, site, user=None, password=None, timeout=None, format=formats.JSONFormat): - super(ShopifyConnection, self).__init__(site, user, password, timeout, format) - def _open(self, *args, **kwargs): self.response = None try: diff --git a/shopify/collection.py b/shopify/collection.py index 42bc8ebb..62728eb9 100644 --- a/shopify/collection.py +++ b/shopify/collection.py @@ -1,6 +1,4 @@ from pyactiveresource.collection import Collection -from six.moves.urllib.parse import urlparse, parse_qs -import cgi class PaginatedCollection(Collection): 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/resources/graphql.py b/shopify/resources/graphql.py index c8110ead..33525ef1 100644 --- a/shopify/resources/graphql.py +++ b/shopify/resources/graphql.py @@ -15,11 +15,11 @@ def merge_headers(self, *headers): merged_headers.update(header) return merged_headers - def execute(self, query, variables=None): + def execute(self, query, variables=None, operation_name=None): endpoint = self.endpoint default_headers = {"Accept": "application/json", "Content-Type": "application/json"} headers = self.merge_headers(default_headers, self.headers) - data = {"query": query, "variables": variables} + data = {"query": query, "variables": variables, "operationName": operation_name} req = urllib.request.Request(self.endpoint, json.dumps(data).encode("utf-8"), headers) 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/session_token.py b/shopify/session_token.py index 19f3105b..91a4970b 100644 --- a/shopify/session_token.py +++ b/shopify/session_token.py @@ -14,6 +14,7 @@ ALGORITHM = "HS256" PREFIX = "Bearer " REQUIRED_FIELDS = ["iss", "dest", "sub", "jti", "sid"] +LEEWAY_SECONDS = 10 class SessionTokenError(Exception): @@ -54,6 +55,9 @@ def _decode_session_token(session_token, api_key, secret): secret, audience=api_key, algorithms=[ALGORITHM], + # AppBridge frequently sends future `nbf`, and it causes `ImmatureSignatureError`. + # Accept few seconds clock skew to avoid this error. + leeway=LEEWAY_SECONDS, options={"require": REQUIRED_FIELDS}, ) except jwt.exceptions.PyJWTError as exception: diff --git a/shopify/version.py b/shopify/version.py index f33ca55d..dfb0b4e4 100644 --- a/shopify/version.py +++ b/shopify/version.py @@ -1 +1 @@ -VERSION = "8.4.2" +VERSION = "12.7.1" diff --git a/shopify/yamlobjects.py b/shopify/yamlobjects.py index d050877f..c7438c42 100644 --- a/shopify/yamlobjects.py +++ b/shopify/yamlobjects.py @@ -15,6 +15,5 @@ class YAMLHashWithIndifferentAccess(yaml.YAMLObject): def from_yaml(cls, loader, node): return loader.construct_mapping(node, cls) - except ImportError: pass 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/base_test.py b/test/base_test.py index a07368b6..5cc19a60 100644 --- a/test/base_test.py +++ b/test/base_test.py @@ -17,9 +17,6 @@ def setUpClass(self): def tearDownClass(self): shopify.ApiVersion.clear_defined_versions() - def setUp(self): - super(BaseTest, self).setUp() - def tearDown(self): shopify.ShopifyResource.clear_session() @@ -107,7 +104,7 @@ def test_setting_with_user_and_pass_strips_them(self): url="https://this-is-my-test-show.myshopify.com/admin/shop.json", method="GET", body=self.load_fixture("shop"), - headers={"Authorization": u"Basic dXNlcjpwYXNz"}, + headers={"Authorization": "Basic dXNlcjpwYXNz"}, ) API_KEY = "user" PASSWORD = "pass" diff --git a/test/graphql_test.py b/test/graphql_test.py index 50f89416..dc32b935 100644 --- a/test/graphql_test.py +++ b/test/graphql_test.py @@ -9,7 +9,7 @@ def setUp(self): shopify.ApiVersion.define_known_versions() shopify_session = shopify.Session("this-is-my-test-show.myshopify.com", "unstable", "token") shopify.ShopifyResource.activate_session(shopify_session) - client = shopify.GraphQL() + self.client = shopify.GraphQL() self.fake( "graphql", method="POST", @@ -20,6 +20,8 @@ def setUp(self): "Content-Type": "application/json", }, ) + + def test_fetch_shop_with_graphql(self): query = """ { shop { @@ -28,7 +30,17 @@ def setUp(self): } } """ - self.result = client.execute(query) + result = self.client.execute(query) + self.assertTrue(json.loads(result)["shop"]["name"] == "Apple Computers") - def test_fetch_shop_with_graphql(self): - self.assertTrue(json.loads(self.result)["shop"]["name"] == "Apple Computers") + def test_specify_operation_name(self): + query = """ + query GetShop{ + shop { + name + id + } + } + """ + result = self.client.execute(query, operation_name="GetShop") + self.assertTrue(json.loads(result)["shop"]["name"] == "Apple Computers") diff --git a/test/marketing_event_test.py b/test/marketing_event_test.py index a3753d63..2f73029d 100644 --- a/test/marketing_event_test.py +++ b/test/marketing_event_test.py @@ -4,9 +4,6 @@ class MarketingEventTest(TestCase): - def setUp(self): - super(MarketingEventTest, self).setUp() - def test_get_marketing_event(self): self.fake("marketing_events/1", method="GET", body=self.load_fixture("marketing_event")) marketing_event = shopify.MarketingEvent.find(1) 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") diff --git a/test/session_token_test.py b/test/session_token_test.py index 38e43808..0df7147f 100644 --- a/test/session_token_test.py +++ b/test/session_token_test.py @@ -48,7 +48,7 @@ def test_raises_if_token_authentication_header_is_not_bearer(self): self.assertEqual("The HTTP_AUTHORIZATION_HEADER provided does not contain a Bearer token", str(cm.exception)) def test_raises_jwt_error_if_session_token_is_expired(self): - self.payload["exp"] = timestamp((datetime.now() + timedelta(0, -10))) + self.payload["exp"] = timestamp((datetime.now() + timedelta(0, -11))) with self.assertRaises(session_token.SessionTokenError) as cm: session_token.decode_from_header(self.build_auth_header(), api_key=self.api_key, secret=self.secret) @@ -79,7 +79,7 @@ def test_raises_if_aud_doesnt_match_api_key(self): with self.assertRaises(session_token.SessionTokenError) as cm: session_token.decode_from_header(self.build_auth_header(), api_key=self.api_key, secret=self.secret) - self.assertEqual("Invalid audience", str(cm.exception)) + self.assertEqual("Audience doesn't match", str(cm.exception)) def test_raises_if_issuer_hostname_is_invalid(self): self.payload["iss"] = "bad_shop_hostname" @@ -103,3 +103,8 @@ def test_returns_decoded_payload(self): ) self.assertEqual(self.payload, decoded_payload) + + def test_allow_10_seconds_clock_skew_in_nbf(self): + self.payload["nbf"] = timestamp((datetime.now() + timedelta(seconds=10))) + + session_token.decode_from_header(self.build_auth_header(), api_key=self.api_key, secret=self.secret)