airbyte-source-shopify 3.1.0.dev202512181928__py3-none-any.whl → 3.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: airbyte-source-shopify
3
- Version: 3.1.0.dev202512181928
3
+ Version: 3.1.2
4
4
  Summary: Source CDK implementation for Shopify.
5
5
  Home-page: https://airbyte.com
6
6
  License: ELv2
@@ -14,6 +14,7 @@ source_shopify/schemas/custom_collections.json,sha256=ElDY1y_G_VFPOGr9ipU022AZDW
14
14
  source_shopify/schemas/customer_address.json,sha256=kLKylul_xK7eO_aduvDddj078k8aQLM4JtUEpa5Tp_8,2628
15
15
  source_shopify/schemas/customer_journey_summary.json,sha256=GlZmIqFeR1x84DwgwMo28HQ8QseImkhvYxLSNRkUmwg,12733
16
16
  source_shopify/schemas/customers.json,sha256=pywejBEtvMnRHvvnJQhfA8NlCBjELv7XuMlKdpS4cZI,10233
17
+ source_shopify/schemas/deleted_products.json,sha256=7TbqrABNDg9ZFAeLJCPe2Z5jtk9QGbJDuoSKofGd1lg,773
17
18
  source_shopify/schemas/discount_codes.json,sha256=YxGptdpjXwM46fmzs5zoi3m96xAWKXrfllC_oIZW0vo,3730
18
19
  source_shopify/schemas/disputes.json,sha256=Uvzy1Gi954cLxij6H0Vbz0OWS6GS10YaJI4DJfY2x8I,1775
19
20
  source_shopify/schemas/draft_orders.json,sha256=3NaZt9Qk4mY8VO9pB5UBzw3GZibL8MvI0z_tlnMsdBI,24211
@@ -49,7 +50,7 @@ source_shopify/schemas/shop.json,sha256=vEGiTvEYX7qnMq06MRVBycqih49h49xjTNC6gJux
49
50
  source_shopify/schemas/smart_collections.json,sha256=kv7dINsvgzJ0RyKfFNKjU0apdNDXwQaHfnNZfQsshcU,2009
50
51
  source_shopify/schemas/tender_transactions.json,sha256=U8fdT-eflycEPzYSpBDiB0lp9wxmJHgioHTrICflh78,2006
51
52
  source_shopify/schemas/transactions.json,sha256=vbwscH3UcAtbSsC70mBka4oNaFR4S3S6IFBmzR7t37U,10226
52
- source_shopify/scopes.py,sha256=N0njfMHn3Q1AQXuTj5VfjQOio10jaDarpC_oLYnWvqc,6490
53
+ source_shopify/scopes.py,sha256=78f9QL3PJZ9UDx1gIWzNwx5fYJE9OB3vPi9RahB_kFw,6533
53
54
  source_shopify/shopify_graphql/bulk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
54
55
  source_shopify/shopify_graphql/bulk/exceptions.py,sha256=4dj7Za4xIfwL-zf8joT9svF_RSoGlE3GviMiIl1e1rs,2532
55
56
  source_shopify/shopify_graphql/bulk/job.py,sha256=c3Cg70_Io9jTD-rU-5MvjHaPmJCtcpeqEYnRtFECGOo,28673
@@ -58,13 +59,13 @@ source_shopify/shopify_graphql/bulk/record.py,sha256=X6VGngugv7a_S8UEeDo121BkdCV
58
59
  source_shopify/shopify_graphql/bulk/retry.py,sha256=R5rSJJE8D5zcj6mN-OmmNO2aFZEIdjAlWclDDVW5KPI,2626
59
60
  source_shopify/shopify_graphql/bulk/status.py,sha256=RmuQ2XsYL3iRCpVGxea9F1wXGmbwasDCSXjaTyL4LMA,328
60
61
  source_shopify/shopify_graphql/bulk/tools.py,sha256=nUQ2ZmPTKJNJdfLToR6KJtLKcJFCChSifkAOvwg0Vss,4065
61
- source_shopify/source.py,sha256=txb3wIm-3xXd8-5QLSeu2TeHBSnppwy5PEIOEl40mVw,8517
62
+ source_shopify/source.py,sha256=SOoU8rDYjvf44ZBORLdgd2O_QeXOGMGynlzFLHJgqBg,8815
62
63
  source_shopify/spec.json,sha256=ITYWiQ-NrI5VISk5qmUQhp9ChUE2FV18d8xzVzPwvAg,6144
63
64
  source_shopify/streams/base_streams.py,sha256=k_4uLaLADLRTUcSmP8uA_830uuzRvnqUaCVGcb0Zpd8,42625
64
- source_shopify/streams/streams.py,sha256=le0urCpcjNcSv2hvoD7Cj4kFprmCZmfpP6418wY5LFI,16028
65
+ source_shopify/streams/streams.py,sha256=Ro0JXboCQHpfdI5VsLR4TseSL077GiFMZ-gOOnCCqtc,19356
65
66
  source_shopify/transform.py,sha256=mn0htL812_90zc_YszGQa0hHcIZQpYYdmk8IqpZm5TI,4685
66
67
  source_shopify/utils.py,sha256=DSqEchu-MQJ7zust7CNfqOkGIv9OSR-5UUsuD-bsDa8,16224
67
- airbyte_source_shopify-3.1.0.dev202512181928.dist-info/METADATA,sha256=DQq22wAHRNP-a3Gf4kXkup3-wXc1S6yQRxyG01qKBFo,5313
68
- airbyte_source_shopify-3.1.0.dev202512181928.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
69
- airbyte_source_shopify-3.1.0.dev202512181928.dist-info/entry_points.txt,sha256=SyTwKSsPk9MCdPf01saWpnp8hcmZOgBssVcSIvMbBeQ,57
70
- airbyte_source_shopify-3.1.0.dev202512181928.dist-info/RECORD,,
68
+ airbyte_source_shopify-3.1.2.dist-info/METADATA,sha256=CaPhg3HO2FzEmCAddrOhC52KBB0zt5Rl-vBA2syFaTE,5297
69
+ airbyte_source_shopify-3.1.2.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
70
+ airbyte_source_shopify-3.1.2.dist-info/entry_points.txt,sha256=SyTwKSsPk9MCdPf01saWpnp8hcmZOgBssVcSIvMbBeQ,57
71
+ airbyte_source_shopify-3.1.2.dist-info/RECORD,,
@@ -0,0 +1,27 @@
1
+ {
2
+ "type": ["object", "null"],
3
+ "additionalProperties": true,
4
+ "properties": {
5
+ "id": {
6
+ "description": "The unique identifier of the deleted product.",
7
+ "type": ["null", "integer"]
8
+ },
9
+ "deleted_at": {
10
+ "description": "The date and time when the product was deleted.",
11
+ "type": ["null", "string"],
12
+ "format": "date-time"
13
+ },
14
+ "deleted_message": {
15
+ "description": "Message related to the deletion of the product.",
16
+ "type": ["null", "string"]
17
+ },
18
+ "deleted_description": {
19
+ "description": "Description of the reason for deletion.",
20
+ "type": ["null", "string"]
21
+ },
22
+ "shop_url": {
23
+ "description": "The URL of the shop where the product was listed.",
24
+ "type": ["null", "string"]
25
+ }
26
+ }
27
+ }
source_shopify/scopes.py CHANGED
@@ -37,6 +37,7 @@ SCOPES_MAPPING: Mapping[str, set[str]] = {
37
37
  "MetafieldDraftOrders": ("read_draft_orders",),
38
38
  # SCOPE: read_products
39
39
  "Products": ("read_products",),
40
+ "DeletedProducts": ("read_products",),
40
41
  "MetafieldProducts": ("read_products",),
41
42
  "ProductImages": ("read_products",),
42
43
  "MetafieldProductImages": ("read_products",),
source_shopify/source.py CHANGED
@@ -11,6 +11,7 @@ from requests.exceptions import ConnectionError, RequestException, SSLError
11
11
  from airbyte_cdk.models import FailureType, SyncMode
12
12
  from airbyte_cdk.sources import AbstractSource
13
13
  from airbyte_cdk.sources.streams import Stream
14
+ from airbyte_cdk.sources.streams.http.exceptions import BaseBackoffException
14
15
  from airbyte_cdk.utils import AirbyteTracedException
15
16
 
16
17
  from .auth import MissingAccessTokenError, ShopifyAuthenticator
@@ -27,6 +28,7 @@ from .streams.streams import (
27
28
  CustomerAddress,
28
29
  CustomerJourneySummary,
29
30
  Customers,
31
+ DeletedProducts,
30
32
  DiscountCodes,
31
33
  Disputes,
32
34
  DraftOrders,
@@ -107,6 +109,8 @@ class ConnectionCheckTest:
107
109
  return False, self.describe_error("index_error", shop_name, response)
108
110
  except MissingAccessTokenError:
109
111
  return False, self.describe_error("missing_token_error")
112
+ except (BaseBackoffException, AirbyteTracedException) as error:
113
+ return False, self.describe_error("connection_error", shop_name) or str(error)
110
114
 
111
115
  def get_shop_id(self) -> str:
112
116
  """
@@ -211,6 +215,7 @@ class SourceShopify(AbstractSource):
211
215
  PriceRules(config),
212
216
  ProductImages(config),
213
217
  Products(config),
218
+ DeletedProducts(config),
214
219
  ProductVariants(config),
215
220
  Shop(config),
216
221
  SmartCollections(config),
@@ -135,6 +135,110 @@ class Products(IncrementalShopifyGraphQlBulkStream):
135
135
  bulk_query: Product = Product
136
136
 
137
137
 
138
+ class DeletedProducts(IncrementalShopifyStream):
139
+ """
140
+ Stream for fetching deleted products using the Shopify GraphQL Events API.
141
+ This stream queries events with action:destroy and subject_type:Product to get deleted product records.
142
+ https://shopify.dev/docs/api/admin-graphql/latest/queries/events
143
+
144
+ Note: This stream extends IncrementalShopifyStream (REST base class) rather than IncrementalShopifyGraphQlBulkStream
145
+ because it uses Shopify's standard GraphQL Events API, NOT the Bulk Operations API (bulkOperationRunQuery).
146
+ The Events API has a fundamentally different architecture:
147
+ - Uses immediate queries with cursor pagination (not async job creation + file download)
148
+ - Returns results in response.data.events.nodes (not JSONL files)
149
+ - Requires different URL endpoint (/graphql.json), request format (POST with query in body), and pagination logic
150
+
151
+ Therefore, url_base, path, request_params, next_page_token, and parse_response are overridden to accommodate
152
+ the GraphQL request/response structure while maintaining incremental sync capabilities from the base class.
153
+ """
154
+
155
+ data_field = "graphql"
156
+ cursor_field = "deleted_at"
157
+ http_method = "POST"
158
+ filter_field = None
159
+
160
+ _page_cursor: Optional[str] = None
161
+
162
+ EVENTS_QUERY = """
163
+ query GetDeletedProductEvents($first: Int!, $after: String, $query: String) {
164
+ events(first: $first, after: $after, query: $query, sortKey: CREATED_AT) {
165
+ pageInfo {
166
+ hasNextPage
167
+ endCursor
168
+ }
169
+ nodes {
170
+ ... on BasicEvent {
171
+ id
172
+ createdAt
173
+ message
174
+ subjectId
175
+ subjectType
176
+ }
177
+ }
178
+ }
179
+ }
180
+ """
181
+
182
+ @property
183
+ def url_base(self) -> str:
184
+ return f"https://{self.config['shop']}.myshopify.com/admin/api/{self.api_version}/graphql.json"
185
+
186
+ def path(self, **kwargs) -> str:
187
+ return ""
188
+
189
+ def request_params(
190
+ self, stream_state: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None, **kwargs
191
+ ) -> MutableMapping[str, Any]:
192
+ return {}
193
+
194
+ def request_body_json(
195
+ self,
196
+ stream_state: Optional[Mapping[str, Any]] = None,
197
+ stream_slice: Optional[Mapping[str, Any]] = None,
198
+ next_page_token: Optional[Mapping[str, Any]] = None,
199
+ ) -> Optional[Mapping[str, Any]]:
200
+ query_filter = "action:destroy AND subject_type:Product"
201
+ if stream_state and stream_state.get(self.cursor_field):
202
+ state_value = stream_state[self.cursor_field]
203
+ query_filter += f" AND created_at:>'{state_value}'"
204
+
205
+ variables = {
206
+ "first": 250,
207
+ "query": query_filter,
208
+ }
209
+
210
+ if next_page_token and next_page_token.get("cursor"):
211
+ variables["after"] = next_page_token["cursor"]
212
+
213
+ return {
214
+ "query": self.EVENTS_QUERY,
215
+ "variables": variables,
216
+ }
217
+
218
+ def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, Any]]:
219
+ json_response = response.json()
220
+ page_info = json_response.get("data", {}).get("events", {}).get("pageInfo", {})
221
+ if page_info.get("hasNextPage"):
222
+ return {"cursor": page_info.get("endCursor")}
223
+ return None
224
+
225
+ def parse_response(self, response: requests.Response, **kwargs) -> Iterable[Mapping]:
226
+ json_response = response.json()
227
+ events = json_response.get("data", {}).get("events", {}).get("nodes", [])
228
+ for event in events:
229
+ if event.get("subjectType") == "PRODUCT" and event.get("subjectId"):
230
+ subject_id = event.get("subjectId", "")
231
+ product_id = int(subject_id.split("/")[-1]) if "/" in subject_id else None
232
+ if product_id:
233
+ yield {
234
+ "id": product_id,
235
+ "deleted_at": event.get("createdAt"),
236
+ "deleted_message": event.get("message"),
237
+ "deleted_description": None,
238
+ "shop_url": self.config.get("shop"),
239
+ }
240
+
241
+
138
242
  class MetafieldProducts(IncrementalShopifyGraphQlBulkStream):
139
243
  parent_stream_class = Products
140
244
  bulk_query: MetafieldProduct = MetafieldProduct
@@ -197,47 +301,18 @@ class MetafieldSmartCollections(MetafieldShopifySubstream):
197
301
 
198
302
  class Collects(IncrementalShopifyStream):
199
303
  """
200
- Collects stream uses client-side incremental sync because the Shopify REST API
201
- does not support filtering by updated_at for this endpoint - only `since_id` is available.
202
- https://shopify.dev/docs/api/admin-rest/latest/resources/collect
203
-
204
- The Collect stream is the link between Products and Collections. To capture both new records
205
- AND updates to existing records, we fetch all records and filter client-side based on updated_at.
304
+ Collects stream does not support Incremental Refresh based on datetime fields, only `since_id` is supported:
305
+ https://shopify.dev/docs/admin-api/rest/reference/products/collect
206
306
 
207
- Breaking change from previous versions: cursor_field changed from `id` to `updated_at`.
307
+ The Collect stream is the link between Products and Collections, if the Collection is created for Products,
308
+ the `collect` record is created, it's reasonable to Full Refresh all collects. As for Incremental refresh -
309
+ we would use the since_id specificaly for this stream.
208
310
  """
209
311
 
210
312
  data_field = "collects"
211
- cursor_field = "updated_at"
313
+ cursor_field = "id"
212
314
  order_field = "id"
213
-
214
- def request_params(
215
- self, stream_state: Optional[Mapping[str, Any]] = None, next_page_token: Optional[Mapping[str, Any]] = None, **kwargs
216
- ) -> MutableMapping[str, Any]:
217
- """
218
- Override to implement client-side incremental sync.
219
- We fetch all records (no server-side filtering) and filter client-side using filter_records_newer_than_state.
220
- The Shopify Collects API only supports since_id filtering, which cannot capture updates to existing records.
221
- """
222
- params = {"limit": self.limit}
223
- if next_page_token:
224
- params.update(**next_page_token)
225
- else:
226
- params["order"] = f"{self.order_field} asc"
227
- return params
228
-
229
- def read_records(
230
- self,
231
- stream_state: Optional[Mapping[str, Any]] = None,
232
- stream_slice: Optional[Mapping[str, Any]] = None,
233
- **kwargs,
234
- ) -> Iterable[Mapping[str, Any]]:
235
- """
236
- Override to apply client-side filtering based on updated_at cursor.
237
- This ensures we capture both new records and updates to existing records.
238
- """
239
- records = super().read_records(stream_state=stream_state, stream_slice=stream_slice, **kwargs)
240
- yield from self.filter_records_newer_than_state(stream_state, records)
315
+ filter_field = "since_id"
241
316
 
242
317
 
243
318
  class Collections(IncrementalShopifyGraphQlBulkStream):
@@ -397,6 +472,17 @@ class Countries(HttpSubStream, FullRefreshShopifyGraphQlBulkStream):
397
472
  query = DeliveryProfile
398
473
  response_field = "deliveryProfiles"
399
474
 
475
+ def stream_slices(
476
+ self,
477
+ stream_state: Optional[Mapping[str, Any]] = None,
478
+ **kwargs,
479
+ ) -> Iterable[Optional[Mapping[str, Any]]]:
480
+ for stream_slice in super().stream_slices(stream_state=stream_state, **kwargs):
481
+ parent = stream_slice.get("parent", {})
482
+ profile_location_groups = parent.get("profile_location_groups", [])
483
+ if profile_location_groups:
484
+ yield stream_slice
485
+
400
486
  def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, Any]]:
401
487
  json_response = response.json().get("data", {})
402
488
  if not json_response: