airbyte-source-shopify 2.4.14.dev202407181247__py3-none-any.whl → 3.1.0__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.
Files changed (29) hide show
  1. {airbyte_source_shopify-2.4.14.dev202407181247.dist-info → airbyte_source_shopify-3.1.0.dist-info}/METADATA +4 -4
  2. {airbyte_source_shopify-2.4.14.dev202407181247.dist-info → airbyte_source_shopify-3.1.0.dist-info}/RECORD +25 -27
  3. {airbyte_source_shopify-2.4.14.dev202407181247.dist-info → airbyte_source_shopify-3.1.0.dist-info}/WHEEL +1 -1
  4. source_shopify/auth.py +0 -1
  5. source_shopify/config_migrations.py +4 -1
  6. source_shopify/http_request.py +4 -2
  7. source_shopify/schemas/countries.json +7 -19
  8. source_shopify/schemas/customer_journey_summary.json +228 -148
  9. source_shopify/schemas/deleted_products.json +27 -0
  10. source_shopify/schemas/orders.json +38 -0
  11. source_shopify/schemas/product_variants.json +26 -8
  12. source_shopify/schemas/profile_location_groups.json +10 -0
  13. source_shopify/scopes.py +7 -6
  14. source_shopify/shopify_graphql/bulk/exceptions.py +6 -1
  15. source_shopify/shopify_graphql/bulk/job.py +173 -65
  16. source_shopify/shopify_graphql/bulk/query.py +440 -88
  17. source_shopify/shopify_graphql/bulk/record.py +260 -29
  18. source_shopify/shopify_graphql/bulk/retry.py +12 -12
  19. source_shopify/shopify_graphql/bulk/tools.py +17 -2
  20. source_shopify/source.py +6 -10
  21. source_shopify/spec.json +11 -5
  22. source_shopify/streams/base_streams.py +181 -54
  23. source_shopify/streams/streams.py +211 -58
  24. source_shopify/utils.py +47 -12
  25. source_shopify/schemas/customer_saved_search.json +0 -32
  26. source_shopify/schemas/products_graph_ql.json +0 -123
  27. source_shopify/shopify_graphql/graphql.py +0 -64
  28. source_shopify/shopify_graphql/schema.py +0 -29442
  29. {airbyte_source_shopify-2.4.14.dev202407181247.dist-info → airbyte_source_shopify-3.1.0.dist-info}/entry_points.txt +0 -0
@@ -11,6 +11,7 @@ from typing import Any, Iterable, List, Mapping, MutableMapping, Optional, Union
11
11
 
12
12
  from attr import dataclass
13
13
  from graphql_query import Argument, Field, InlineFragment, Operation, Query
14
+ from setuptools.command.alias import alias
14
15
 
15
16
  from .tools import BULK_PARENT_KEY, BulkTools
16
17
 
@@ -68,6 +69,7 @@ class ShopifyBulkTemplates:
68
69
  createdAt
69
70
  }
70
71
  userErrors {
72
+ code
71
73
  field
72
74
  message
73
75
  }
@@ -79,7 +81,12 @@ class ShopifyBulkTemplates:
79
81
 
80
82
  @dataclass
81
83
  class ShopifyBulkQuery:
82
- shop_id: int
84
+ config: Mapping[str, Any]
85
+ parent_stream_cursor_alias: Optional[str] = None
86
+
87
+ @property
88
+ def shop_id(self) -> int:
89
+ return self.config.get("shop_id")
83
90
 
84
91
  @property
85
92
  def tools(self) -> BulkTools:
@@ -112,6 +119,14 @@ class ShopifyBulkQuery:
112
119
  """
113
120
  return None
114
121
 
122
+ @property
123
+ def supports_checkpointing(self) -> bool:
124
+ """
125
+ The presence of `sort_key = "UPDATED_AT"` for a query instance, usually means,
126
+ the server-side BULK Job results are fetched and ordered correctly, suitable for checkpointing.
127
+ """
128
+ return self.sort_key == "UPDATED_AT"
129
+
115
130
  @property
116
131
  def query_nodes(self) -> Optional[Union[List[Field], List[str]]]:
117
132
  """
@@ -120,6 +135,12 @@ class ShopifyBulkQuery:
120
135
  """
121
136
  return ["__typename", "id"]
122
137
 
138
+ def inject_parent_cursor_field(self, nodes: List[Field], key: str = "updatedAt", index: int = 2) -> List[Field]:
139
+ if self.parent_stream_cursor_alias:
140
+ # inject parent cursor key as alias to the `updatedAt` parent cursor field
141
+ nodes.insert(index, Field(name=key, alias=self.parent_stream_cursor_alias))
142
+ return nodes
143
+
123
144
  def get(self, filter_field: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None) -> str:
124
145
  # define filter query string, if passed
125
146
  filter_query = f"{filter_field}:>='{start}' AND {filter_field}:<='{end}'" if filter_field else None
@@ -191,7 +212,7 @@ class MetafieldType(Enum):
191
212
  ORDERS = "orders"
192
213
  DRAFT_ORDERS = "draftOrders"
193
214
  PRODUCTS = "products"
194
- PRODUCT_IMAGES = ["products", "images"]
215
+ PRODUCT_IMAGES = "products"
195
216
  PRODUCT_VARIANTS = "productVariants"
196
217
  COLLECTIONS = "collections"
197
218
  LOCATIONS = "locations"
@@ -273,15 +294,22 @@ class Metafield(ShopifyBulkQuery):
273
294
  List of available fields:
274
295
  https://shopify.dev/docs/api/admin-graphql/unstable/objects/Metafield
275
296
  """
297
+
298
+ nodes = super().query_nodes
299
+
276
300
  # define metafield node
277
301
  metafield_node = self.get_edge_node("metafields", self.metafield_fields)
278
302
 
279
303
  if isinstance(self.type.value, list):
280
- return ["__typename", "id", self.get_edge_node(self.type.value[1], ["__typename", "id", metafield_node])]
304
+ nodes = [*nodes, self.get_edge_node(self.type.value[1], [*nodes, metafield_node])]
281
305
  elif isinstance(self.type.value, str):
282
- return ["__typename", "id", metafield_node]
306
+ nodes = [*nodes, metafield_node]
283
307
 
284
- def record_process_components(self, record: MutableMapping[str, Any]) -> Iterable[MutableMapping[str, Any]]:
308
+ nodes = self.inject_parent_cursor_field(nodes)
309
+
310
+ return nodes
311
+
312
+ def _process_metafield(self, record: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
285
313
  # resolve parent id from `str` to `int`
286
314
  record["owner_id"] = self.tools.resolve_str_id(record.get(BULK_PARENT_KEY))
287
315
  # add `owner_resource` field
@@ -292,7 +320,25 @@ class Metafield(ShopifyBulkQuery):
292
320
  record["createdAt"] = self.tools.from_iso8601_to_rfc3339(record, "createdAt")
293
321
  record["updatedAt"] = self.tools.from_iso8601_to_rfc3339(record, "updatedAt")
294
322
  record = self.tools.fields_names_to_snake_case(record)
295
- yield record
323
+ return record
324
+
325
+ def _process_components(self, entity: List[dict]) -> Iterable[MutableMapping[str, Any]]:
326
+ for item in entity:
327
+ # resolve the id from string
328
+ item["admin_graphql_api_id"] = item.get("id")
329
+ item["id"] = self.tools.resolve_str_id(item.get("id"))
330
+ yield self._process_metafield(item)
331
+
332
+ def record_process_components(self, record: MutableMapping[str, Any]) -> Iterable[MutableMapping[str, Any]]:
333
+ # get the joined record components collected for the record
334
+ record_components = record.get("record_components", {})
335
+ # process record components
336
+ if not record_components:
337
+ yield self._process_metafield(record)
338
+ else:
339
+ metafields = record_components.get("Metafield", [])
340
+ if len(metafields) > 0:
341
+ yield from self._process_components(metafields)
296
342
 
297
343
 
298
344
  class MetafieldCollection(Metafield):
@@ -331,7 +377,9 @@ class MetafieldCustomer(Metafield):
331
377
  customers(query: "updated_at:>='2023-02-07T00:00:00+00:00' AND updated_at:<='2023-12-04T00:00:00+00:00'", sortKey: UPDATED_AT) {
332
378
  edges {
333
379
  node {
380
+ __typename
334
381
  id
382
+ customer_updated_at: updatedAt
335
383
  metafields {
336
384
  edges {
337
385
  node {
@@ -354,6 +402,11 @@ class MetafieldCustomer(Metafield):
354
402
 
355
403
  type = MetafieldType.CUSTOMERS
356
404
 
405
+ record_composition = {
406
+ "new_record": "Customer",
407
+ "record_components": ["Metafield"],
408
+ }
409
+
357
410
 
358
411
  class MetafieldLocation(Metafield):
359
412
  """
@@ -452,7 +505,9 @@ class MetafieldProduct(Metafield):
452
505
  products(query: "updated_at:>='2023-02-07T00:00:00+00:00' AND updated_at:<='2023-12-04T00:00:00+00:00'", sortKey: UPDATED_AT) {
453
506
  edges {
454
507
  node {
508
+ __typename
455
509
  id
510
+ product_updated_at: updatedAt
456
511
  metafields {
457
512
  edges {
458
513
  node {
@@ -475,29 +530,40 @@ class MetafieldProduct(Metafield):
475
530
 
476
531
  type = MetafieldType.PRODUCTS
477
532
 
533
+ record_composition = {
534
+ "new_record": "Product",
535
+ "record_components": ["Metafield"],
536
+ }
537
+
478
538
 
479
539
  class MetafieldProductImage(Metafield):
480
540
  """
481
541
  {
482
- products(query: "updated_at:>='2023-02-07T00:00:00+00:00' AND updated_at:<='2023-12-04T00:00:00+00:00'", sortKey: UPDATED_AT) {
542
+ products(query: "updated_at:>='2023-01-08T00:00:00+00:00' AND updated_at:<='2024-08-02T15:12:41.689153+00:00'", sortKey: UPDATED_AT) {
483
543
  edges {
484
544
  node {
545
+ __typename
485
546
  id
486
- images{
487
- edges{
488
- node{
547
+ product_updated_at: updatedAt
548
+ media {
549
+ edges {
550
+ node {
551
+ __typename
489
552
  id
490
- metafields {
491
- edges {
492
- node {
493
- id
494
- namespace
495
- value
496
- key
497
- description
498
- createdAt
499
- updatedAt
500
- type
553
+ ... on MediaImage {
554
+ metafields {
555
+ edges {
556
+ node {
557
+ __typename
558
+ id
559
+ namespace
560
+ value
561
+ key
562
+ description
563
+ createdAt
564
+ updatedAt
565
+ type
566
+ }
501
567
  }
502
568
  }
503
569
  }
@@ -512,6 +578,32 @@ class MetafieldProductImage(Metafield):
512
578
 
513
579
  type = MetafieldType.PRODUCT_IMAGES
514
580
 
581
+ record_composition = {
582
+ "new_record": "Product",
583
+ "record_components": ["Metafield"],
584
+ }
585
+
586
+ @property
587
+ def query_nodes(self) -> List[Field]:
588
+ """
589
+ This is the overide for the default `query_nodes` method,
590
+ because the usual way of retrieving the metafields for product images` was suddently deprecated,
591
+ for `2024-10`, but the changes are reflected in the `2024-04` as well, starting: `2024-08-01T00:06:44`
592
+
593
+ More info here:
594
+ https://shopify.dev/docs/api/release-notes/2024-04#productimage-value-removed
595
+ """
596
+
597
+ # define metafield node
598
+ metafield_node = self.get_edge_node("metafields", self.metafield_fields)
599
+ media_fields: List[Field] = ["__typename", "id", InlineFragment(type="MediaImage", fields=[metafield_node])]
600
+ media_node = self.get_edge_node("media", media_fields)
601
+
602
+ fields: List[Field] = ["__typename", "id", media_node]
603
+ fields = self.inject_parent_cursor_field(fields)
604
+
605
+ return fields
606
+
515
607
 
516
608
  class MetafieldProductVariant(Metafield):
517
609
  """
@@ -1015,6 +1107,29 @@ class CustomerJourney(ShopifyBulkQuery):
1015
1107
  term
1016
1108
  }
1017
1109
  }
1110
+ customerJourney {
1111
+ moments {
1112
+ ... on CustomerVisit {
1113
+ id
1114
+ landingPage
1115
+ landingPageHtml
1116
+ occurredAt
1117
+ referralCode
1118
+ referralInfoHtml
1119
+ referrerUrl
1120
+ source
1121
+ sourceDescription
1122
+ sourceType
1123
+ utmParameters {
1124
+ campaign
1125
+ content
1126
+ medium
1127
+ source
1128
+ term
1129
+ }
1130
+ }
1131
+ }
1132
+ }
1018
1133
  }
1019
1134
  }
1020
1135
  }
@@ -1037,6 +1152,16 @@ class CustomerJourney(ShopifyBulkQuery):
1037
1152
  "sourceDescription",
1038
1153
  Field(name="utmParameters", fields=["campaign", "content", "medium", "source", "term"]),
1039
1154
  ]
1155
+
1156
+ customer_visit_fragment: List[InlineFragment] = [
1157
+ InlineFragment(type="CustomerVisit", fields=visit_fields),
1158
+ ]
1159
+
1160
+ # # use this in the next version
1161
+ # moments_fields: List[Field] = [
1162
+ # Field(name="edges", fields=[Field(name="node", fields=customer_visit_fragment)]),
1163
+ # ]
1164
+
1040
1165
  customer_journey_summary_fields: List[Field] = [
1041
1166
  "ready",
1042
1167
  Field(name="momentsCount", fields=["count", "precision"]),
@@ -1044,6 +1169,8 @@ class CustomerJourney(ShopifyBulkQuery):
1044
1169
  "daysToConversion",
1045
1170
  Field(name="firstVisit", fields=visit_fields),
1046
1171
  Field(name="lastVisit", fields=visit_fields),
1172
+ # # use this in the next version
1173
+ # Field(name="moments", fields=moments_fields),
1047
1174
  ]
1048
1175
 
1049
1176
  query_nodes: List[Field] = [
@@ -1052,6 +1179,12 @@ class CustomerJourney(ShopifyBulkQuery):
1052
1179
  "createdAt",
1053
1180
  "updatedAt",
1054
1181
  Field(name="customerJourneySummary", fields=customer_journey_summary_fields),
1182
+ Field(
1183
+ name="customerJourney",
1184
+ fields=[
1185
+ Field(name="moments", fields=customer_visit_fragment),
1186
+ ],
1187
+ ),
1055
1188
  ]
1056
1189
 
1057
1190
  record_composition = {
@@ -1062,6 +1195,9 @@ class CustomerJourney(ShopifyBulkQuery):
1062
1195
  self,
1063
1196
  visit_data: Mapping[str, Any],
1064
1197
  ) -> MutableMapping[str, Any]:
1198
+ if not visit_data:
1199
+ return {}
1200
+
1065
1201
  # save the id before it's resolved
1066
1202
  visit_data["admin_graphql_api_id"] = visit_data.get("id")
1067
1203
  # resolve the order_id to str
@@ -1072,14 +1208,24 @@ class CustomerJourney(ShopifyBulkQuery):
1072
1208
  visit_data = self.tools.fields_names_to_snake_case(visit_data)
1073
1209
  return visit_data
1074
1210
 
1211
+ def process_moments(self, entity: List[Mapping[str, Any]]) -> List[MutableMapping[str, Any]]:
1212
+ moments = []
1213
+ for item in entity:
1214
+ moments.append(self.process_visit(item))
1215
+ return moments
1216
+
1075
1217
  def process_customer_journey(self, record: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
1076
1218
  customer_journey_summary = record.get("customerJourneySummary", {})
1077
1219
  if customer_journey_summary:
1078
1220
  # process first, last visit data
1079
- first_visit = customer_journey_summary.get("firstVisit", {})
1080
- last_visit = customer_journey_summary.get("lastVisit", {})
1081
- customer_journey_summary["firstVisit"] = self.process_visit(first_visit) if first_visit else {}
1082
- customer_journey_summary["lastVisit"] = self.process_visit(last_visit) if last_visit else {}
1221
+ customer_journey_summary["firstVisit"] = self.process_visit(customer_journey_summary.get("firstVisit"))
1222
+ customer_journey_summary["lastVisit"] = self.process_visit(customer_journey_summary.get("lastVisit"))
1223
+
1224
+ # # this will be a part of summary in the next api version
1225
+ if customer_journey := record.get("customerJourney", {}):
1226
+ moments = customer_journey.get("moments", [])
1227
+ customer_journey_summary["moments"] = self.process_moments(moments)
1228
+
1083
1229
  # cast field names to snake_case
1084
1230
  customer_journey_summary = self.tools.fields_names_to_snake_case(customer_journey_summary)
1085
1231
  return customer_journey_summary
@@ -2199,6 +2345,7 @@ class ProductImage(ShopifyBulkQuery):
2199
2345
  node {
2200
2346
  __typename
2201
2347
  id
2348
+ products_updated_at: updatedAt
2202
2349
  # THE MEDIA NODE IS NEEDED TO PROVIDE THE CURSORS
2203
2350
  media {
2204
2351
  edges {
@@ -2275,8 +2422,7 @@ class ProductImage(ShopifyBulkQuery):
2275
2422
  # media property fields
2276
2423
  media_fields: List[Field] = [Field(name="edges", fields=[Field(name="node", fields=media_fragment)])]
2277
2424
 
2278
- # main query
2279
- query_nodes: List[Field] = [
2425
+ nodes: List[Field] = [
2280
2426
  "__typename",
2281
2427
  "id",
2282
2428
  Field(name="media", fields=media_fields),
@@ -2291,6 +2437,10 @@ class ProductImage(ShopifyBulkQuery):
2291
2437
  "record_components": ["MediaImage", "Image"],
2292
2438
  }
2293
2439
 
2440
+ @property
2441
+ def query_nodes(self) -> List[Field]:
2442
+ return self.inject_parent_cursor_field(self.nodes)
2443
+
2294
2444
  def _process_component(self, entity: List[dict]) -> List[dict]:
2295
2445
  for item in entity:
2296
2446
  # remove the `__parentId` from the object
@@ -2374,7 +2524,6 @@ class ProductImage(ShopifyBulkQuery):
2374
2524
  if len(images) > 0:
2375
2525
  # convert dates from ISO-8601 to RFC-3339
2376
2526
  record["images"] = self._convert_datetime_to_rfc3339(images)
2377
-
2378
2527
  yield from self._emit_complete_records(images)
2379
2528
 
2380
2529
 
@@ -2382,8 +2531,7 @@ class ProductVariant(ShopifyBulkQuery):
2382
2531
  """
2383
2532
  {
2384
2533
  productVariants(
2385
- query: "updated_at:>='2019-04-13T00:00:00+00:00' AND updated_at:<='2024-04-30T12:16:17.273363+00:00'"
2386
- sortKey: UPDATED_AT
2534
+ query: "updatedAt:>='2019-04-13T00:00:00+00:00' AND updatedAt:<='2024-04-30T12:16:17.273363+00:00'"
2387
2535
  ) {
2388
2536
  edges {
2389
2537
  node {
@@ -2418,6 +2566,10 @@ class ProductVariant(ShopifyBulkQuery):
2418
2566
  color
2419
2567
  image {
2420
2568
  id
2569
+ image {
2570
+ src
2571
+ url
2572
+ }
2421
2573
  }
2422
2574
  }
2423
2575
  }
@@ -2425,6 +2577,8 @@ class ProductVariant(ShopifyBulkQuery):
2425
2577
  grams: weight
2426
2578
  image {
2427
2579
  image_id: id
2580
+ image_src: src
2581
+ image_url: url
2428
2582
  }
2429
2583
  old_inventory_quantity: inventoryQuantity
2430
2584
  product {
@@ -2457,64 +2611,83 @@ class ProductVariant(ShopifyBulkQuery):
2457
2611
  """
2458
2612
 
2459
2613
  query_name = "productVariants"
2460
- sort_key = "ID"
2461
2614
 
2462
- prices_fields: List[str] = ["amount", "currencyCode"]
2463
- presentment_prices_fields: List[Field] = [
2464
- Field(
2465
- name="edges",
2466
- fields=[
2467
- Field(
2468
- name="node",
2469
- fields=["__typename", Field(name="price", fields=prices_fields), Field(name="compareAtPrice", fields=prices_fields)],
2470
- )
2471
- ],
2472
- )
2473
- ]
2615
+ @property
2616
+ def _should_include_presentment_prices(self) -> bool:
2617
+ return self.config.get("job_product_variants_include_pres_prices", True)
2474
2618
 
2475
- option_value_fields: List[Field] = [
2476
- "id",
2477
- "name",
2478
- Field(name="hasVariants", alias="has_variants"),
2479
- Field(name="swatch", fields=["color", Field(name="image", fields=["id"])]),
2480
- ]
2481
- option_fields: List[Field] = [
2482
- "name",
2483
- "value",
2484
- Field(name="optionValue", alias="option_value", fields=option_value_fields),
2485
- ]
2619
+ @property
2620
+ def query_nodes(self) -> Optional[Union[List[Field], List[str]]]:
2621
+ prices_fields: List[str] = ["amount", "currencyCode"]
2622
+ presentment_prices_fields: List[Field] = [
2623
+ Field(
2624
+ name="edges",
2625
+ fields=[
2626
+ Field(
2627
+ name="node",
2628
+ fields=[
2629
+ "__typename",
2630
+ Field(name="price", fields=prices_fields),
2631
+ Field(name="compareAtPrice", fields=prices_fields),
2632
+ ],
2633
+ )
2634
+ ],
2635
+ )
2636
+ ]
2637
+ option_value_fields: List[Field] = [
2638
+ "id",
2639
+ "name",
2640
+ Field(name="hasVariants", alias="has_variants"),
2641
+ Field(name="swatch", fields=["color", Field(name="image", fields=["id", Field(name="image", fields=["src", "url"])])]),
2642
+ ]
2643
+ option_fields: List[Field] = [
2644
+ "name",
2645
+ "value",
2646
+ Field(name="optionValue", alias="option_value", fields=option_value_fields),
2647
+ ]
2648
+ presentment_prices = (
2649
+ [Field(name="presentmentPrices", fields=presentment_prices_fields)] if self._should_include_presentment_prices else []
2650
+ )
2486
2651
 
2487
- # main query
2488
- query_nodes: List[Field] = [
2489
- "__typename",
2490
- "id",
2491
- "title",
2492
- "price",
2493
- "sku",
2494
- "position",
2495
- "inventoryPolicy",
2496
- "compareAtPrice",
2497
- "inventoryManagement",
2498
- "createdAt",
2499
- "updatedAt",
2500
- "taxable",
2501
- "barcode",
2502
- "weight",
2503
- "weightUnit",
2504
- "inventoryQuantity",
2505
- "requiresShipping",
2506
- "availableForSale",
2507
- "displayName",
2508
- "taxCode",
2509
- Field(name="selectedOptions", alias="options", fields=option_fields),
2510
- Field(name="weight", alias="grams"),
2511
- Field(name="image", fields=[Field(name="id", alias="image_id")]),
2512
- Field(name="inventoryQuantity", alias="old_inventory_quantity"),
2513
- Field(name="product", fields=[Field(name="id", alias="product_id")]),
2514
- Field(name="fulfillmentService", fields=[Field(name="handle", alias="fulfillment_service")]),
2515
- Field(name="inventoryItem", fields=[Field(name="id", alias="inventory_item_id")]),
2516
- Field(name="presentmentPrices", fields=presentment_prices_fields),
2517
- ]
2652
+ image_fields = [
2653
+ Field(name="id", alias="image_id"),
2654
+ Field(name="src", alias="image_src"),
2655
+ Field(name="url", alias="image_url"),
2656
+ ]
2657
+ measurement_fields = [
2658
+ Field(name="weight", fields=["value", "unit"]),
2659
+ ]
2660
+ inventory_item_fields = [
2661
+ Field(name="id", alias="inventory_item_id"),
2662
+ Field(name="tracked", alias="tracked"),
2663
+ Field(name="requiresShipping", alias="requires_shipping"),
2664
+ Field(name="measurement", alias="measurement", fields=measurement_fields),
2665
+ ]
2666
+ query_nodes: List[Field] = [
2667
+ "__typename",
2668
+ "id",
2669
+ "title",
2670
+ "price",
2671
+ "sku",
2672
+ "position",
2673
+ "inventoryPolicy",
2674
+ "compareAtPrice",
2675
+ "createdAt",
2676
+ "updatedAt",
2677
+ "taxable",
2678
+ "barcode",
2679
+ "inventoryQuantity",
2680
+ "availableForSale",
2681
+ "displayName",
2682
+ "taxCode",
2683
+ Field(name="selectedOptions", alias="options", fields=option_fields),
2684
+ Field(name="image", fields=image_fields),
2685
+ Field(name="inventoryQuantity", alias="old_inventory_quantity"),
2686
+ Field(name="product", fields=[Field(name="id", alias="product_id")]),
2687
+ Field(name="inventoryItem", fields=inventory_item_fields),
2688
+ ] + presentment_prices
2689
+
2690
+ return query_nodes
2518
2691
 
2519
2692
  record_composition = {
2520
2693
  "new_record": "ProductVariant",
@@ -2572,14 +2745,21 @@ class ProductVariant(ShopifyBulkQuery):
2572
2745
  # unnest mandatory fields from their placeholders
2573
2746
  record["product_id"] = self._unnest_and_resolve_id(record, "product", "product_id")
2574
2747
  record["inventory_item_id"] = self._unnest_and_resolve_id(record, "inventoryItem", "inventory_item_id")
2748
+ inventory_item = record.get("inventoryItem")
2749
+ measurement_weight = record.get("inventoryItem", {}).get("measurement", {}).get("weight")
2750
+ record["weight"] = measurement_weight.get("value", 0.0) if measurement_weight is not None else 0.0
2751
+ record["weight_unit"] = measurement_weight.get("unit") if measurement_weight else None
2752
+ record["tracked"] = inventory_item.get("tracked") if inventory_item else None
2753
+ record["requires_shipping"] = inventory_item.get("requires_shipping") if inventory_item else None
2575
2754
  record["image_id"] = self._unnest_and_resolve_id(record, "image", "image_id")
2576
- # unnest `fulfillment_service` from `fulfillmentService`
2577
- record["fulfillment_service"] = record.get("fulfillmentService", {}).get("fulfillment_service")
2755
+ image = record.get("image", {})
2756
+ record["image_src"] = image.get("image_src") if image else None
2757
+ record["image_url"] = image.get("image_url") if image else None
2578
2758
  # cast the `price` to number, could be literally `None`
2579
2759
  price = record.get("price")
2580
2760
  record["price"] = float(price) if price else None
2581
2761
  # cast the `grams` to integer
2582
- record["grams"] = int(record.get("grams", 0))
2762
+ record["grams"] = int(record.get("weight", 0))
2583
2763
  # convert date-time cursors
2584
2764
  record["createdAt"] = self.tools.from_iso8601_to_rfc3339(record, "createdAt")
2585
2765
  record["updatedAt"] = self.tools.from_iso8601_to_rfc3339(record, "updatedAt")
@@ -3021,3 +3201,175 @@ class OrderAgreement(ShopifyBulkQuery):
3021
3201
  record["agreements"] = agreements_with_sales if agreements_with_sales else {}
3022
3202
 
3023
3203
  yield record
3204
+
3205
+
3206
+ class DeliveryZoneList:
3207
+ query_name = "deliveryProfiles"
3208
+ operation_name = "DeliveryZoneList"
3209
+ operation_type = "query"
3210
+
3211
+ query_nodes: List[str] = []
3212
+
3213
+ page_size = 100
3214
+
3215
+ def resolve(self, query: Query) -> str:
3216
+ # return the constructed query operation
3217
+ return Operation(type=self.operation_type, name=self.operation_name, queries=[query]).render()
3218
+
3219
+ def build(self, name: str, query_args: Mapping[str, Any] = None) -> Query:
3220
+ arguments = [Argument(name="first", value=self.page_size)]
3221
+
3222
+ if query_args:
3223
+ if query_args.get("cursor"):
3224
+ cursor = '"' + query_args["cursor"] + '"'
3225
+ arguments.append(Argument(name="after", value=cursor))
3226
+
3227
+ query = Query(name=name, arguments=arguments, fields=self.query_nodes)
3228
+ # return constructed query
3229
+ return query
3230
+
3231
+ def query(self, query_args: Mapping[str, Any] = None) -> Query:
3232
+ return self.build(self.query_name, query_args)
3233
+
3234
+ def get(self, query_args: Mapping[str, Any] = None) -> str:
3235
+ query: Query = self.query(query_args)
3236
+ return self.resolve(query)
3237
+
3238
+
3239
+ class ProfileLocationGroups(ShopifyBulkQuery):
3240
+ query_name = "deliveryProfiles"
3241
+ filter_field = None
3242
+
3243
+ record_composition = {"new_record": "DeliveryProfile"}
3244
+
3245
+ query_nodes: List[Field] = [
3246
+ "__typename",
3247
+ Field(
3248
+ name="profileLocationGroups",
3249
+ fields=[Field(name="locationGroup", fields=["id"])],
3250
+ ),
3251
+ ]
3252
+
3253
+
3254
+ class DeliveryProfile(DeliveryZoneList):
3255
+ """
3256
+ query DeliveryZoneList {
3257
+ deliveryProfiles(
3258
+ first: 1
3259
+ ) {
3260
+ pageInfo {
3261
+ hasNextPage
3262
+ endCursor
3263
+ }
3264
+ nodes {
3265
+ profileLocationGroups(
3266
+ locationGroupId: "<locationGroupId>"
3267
+ ) {
3268
+ locationGroupZones(
3269
+ first: 100
3270
+ ) {
3271
+ nodes {
3272
+ zone {
3273
+ id
3274
+ name
3275
+ countries {
3276
+ id
3277
+ name
3278
+ translatedName
3279
+ code {
3280
+ countryCode
3281
+ restOfWorld
3282
+ }
3283
+ provinces {
3284
+ id
3285
+ translatedName
3286
+ name
3287
+ code
3288
+ }
3289
+ }
3290
+ }
3291
+ }
3292
+ pageInfo {
3293
+ hasNextPage
3294
+ endCursor
3295
+ }
3296
+ }
3297
+ }
3298
+ }
3299
+ }
3300
+ }
3301
+ """
3302
+
3303
+ page_size = 1
3304
+ sub_page_size = 100
3305
+
3306
+ def __init__(self, location_group_id: str, location_group_zones_cursor: str = None):
3307
+ self.location_group_id = location_group_id
3308
+ self.location_group_zones_cursor = location_group_zones_cursor
3309
+
3310
+ @property
3311
+ def query_nodes(self) -> Optional[Union[List[Field], List[str]]]:
3312
+ location_group_id = '"' + self.location_group_id + '"'
3313
+ location_group_zones_arguments = [Argument(name="first", value=self.sub_page_size)]
3314
+ if self.location_group_zones_cursor:
3315
+ cursor = '"' + self.location_group_zones_cursor + '"'
3316
+ location_group_zones_arguments.append(Argument(name="after", value=cursor))
3317
+
3318
+ query_nodes: List[Field] = [
3319
+ Field(name="pageInfo", fields=["hasNextPage", "endCursor"]),
3320
+ Field(
3321
+ name="nodes",
3322
+ fields=[
3323
+ Field(
3324
+ name="profileLocationGroups",
3325
+ arguments=[Argument(name="locationGroupId", value=location_group_id)],
3326
+ fields=[
3327
+ Field(
3328
+ name="locationGroupZones",
3329
+ arguments=location_group_zones_arguments,
3330
+ fields=[
3331
+ Field(
3332
+ name="nodes",
3333
+ fields=[
3334
+ Field(
3335
+ name="zone",
3336
+ fields=[
3337
+ "id",
3338
+ "name",
3339
+ Field(
3340
+ name="countries",
3341
+ fields=[
3342
+ "id",
3343
+ "name",
3344
+ Field(name="translatedName", alias="translated_name"),
3345
+ Field(
3346
+ name="code",
3347
+ fields=[
3348
+ Field(name="countryCode", alias="country_code"),
3349
+ Field(name="restOfWorld", alias="rest_of_world"),
3350
+ ],
3351
+ ),
3352
+ Field(
3353
+ name="provinces",
3354
+ fields=[
3355
+ "id",
3356
+ "name",
3357
+ "code",
3358
+ Field(name="translatedName", alias="translated_name"),
3359
+ ],
3360
+ ),
3361
+ ],
3362
+ ),
3363
+ ],
3364
+ )
3365
+ ],
3366
+ ),
3367
+ Field(name="pageInfo", fields=["hasNextPage", "endCursor"]),
3368
+ ],
3369
+ ),
3370
+ ],
3371
+ ),
3372
+ ],
3373
+ ),
3374
+ ]
3375
+ return query_nodes