agno 2.1.2__py3-none-any.whl → 2.3.13__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 (314) hide show
  1. agno/agent/agent.py +5540 -2273
  2. agno/api/api.py +2 -0
  3. agno/api/os.py +1 -1
  4. agno/compression/__init__.py +3 -0
  5. agno/compression/manager.py +247 -0
  6. agno/culture/__init__.py +3 -0
  7. agno/culture/manager.py +956 -0
  8. agno/db/async_postgres/__init__.py +3 -0
  9. agno/db/base.py +689 -6
  10. agno/db/dynamo/dynamo.py +933 -37
  11. agno/db/dynamo/schemas.py +174 -10
  12. agno/db/dynamo/utils.py +63 -4
  13. agno/db/firestore/firestore.py +831 -9
  14. agno/db/firestore/schemas.py +51 -0
  15. agno/db/firestore/utils.py +102 -4
  16. agno/db/gcs_json/gcs_json_db.py +660 -12
  17. agno/db/gcs_json/utils.py +60 -26
  18. agno/db/in_memory/in_memory_db.py +287 -14
  19. agno/db/in_memory/utils.py +60 -2
  20. agno/db/json/json_db.py +590 -14
  21. agno/db/json/utils.py +60 -26
  22. agno/db/migrations/manager.py +199 -0
  23. agno/db/migrations/v1_to_v2.py +43 -13
  24. agno/db/migrations/versions/__init__.py +0 -0
  25. agno/db/migrations/versions/v2_3_0.py +938 -0
  26. agno/db/mongo/__init__.py +15 -1
  27. agno/db/mongo/async_mongo.py +2760 -0
  28. agno/db/mongo/mongo.py +879 -11
  29. agno/db/mongo/schemas.py +42 -0
  30. agno/db/mongo/utils.py +80 -8
  31. agno/db/mysql/__init__.py +2 -1
  32. agno/db/mysql/async_mysql.py +2912 -0
  33. agno/db/mysql/mysql.py +946 -68
  34. agno/db/mysql/schemas.py +72 -10
  35. agno/db/mysql/utils.py +198 -7
  36. agno/db/postgres/__init__.py +2 -1
  37. agno/db/postgres/async_postgres.py +2579 -0
  38. agno/db/postgres/postgres.py +942 -57
  39. agno/db/postgres/schemas.py +81 -18
  40. agno/db/postgres/utils.py +164 -2
  41. agno/db/redis/redis.py +671 -7
  42. agno/db/redis/schemas.py +50 -0
  43. agno/db/redis/utils.py +65 -7
  44. agno/db/schemas/__init__.py +2 -1
  45. agno/db/schemas/culture.py +120 -0
  46. agno/db/schemas/evals.py +1 -0
  47. agno/db/schemas/memory.py +17 -2
  48. agno/db/singlestore/schemas.py +63 -0
  49. agno/db/singlestore/singlestore.py +949 -83
  50. agno/db/singlestore/utils.py +60 -2
  51. agno/db/sqlite/__init__.py +2 -1
  52. agno/db/sqlite/async_sqlite.py +2911 -0
  53. agno/db/sqlite/schemas.py +62 -0
  54. agno/db/sqlite/sqlite.py +965 -46
  55. agno/db/sqlite/utils.py +169 -8
  56. agno/db/surrealdb/__init__.py +3 -0
  57. agno/db/surrealdb/metrics.py +292 -0
  58. agno/db/surrealdb/models.py +334 -0
  59. agno/db/surrealdb/queries.py +71 -0
  60. agno/db/surrealdb/surrealdb.py +1908 -0
  61. agno/db/surrealdb/utils.py +147 -0
  62. agno/db/utils.py +2 -0
  63. agno/eval/__init__.py +10 -0
  64. agno/eval/accuracy.py +75 -55
  65. agno/eval/agent_as_judge.py +861 -0
  66. agno/eval/base.py +29 -0
  67. agno/eval/performance.py +16 -7
  68. agno/eval/reliability.py +28 -16
  69. agno/eval/utils.py +35 -17
  70. agno/exceptions.py +27 -2
  71. agno/filters.py +354 -0
  72. agno/guardrails/prompt_injection.py +1 -0
  73. agno/hooks/__init__.py +3 -0
  74. agno/hooks/decorator.py +164 -0
  75. agno/integrations/discord/client.py +1 -1
  76. agno/knowledge/chunking/agentic.py +13 -10
  77. agno/knowledge/chunking/fixed.py +4 -1
  78. agno/knowledge/chunking/semantic.py +9 -4
  79. agno/knowledge/chunking/strategy.py +59 -15
  80. agno/knowledge/embedder/fastembed.py +1 -1
  81. agno/knowledge/embedder/nebius.py +1 -1
  82. agno/knowledge/embedder/ollama.py +8 -0
  83. agno/knowledge/embedder/openai.py +8 -8
  84. agno/knowledge/embedder/sentence_transformer.py +6 -2
  85. agno/knowledge/embedder/vllm.py +262 -0
  86. agno/knowledge/knowledge.py +1618 -318
  87. agno/knowledge/reader/base.py +6 -2
  88. agno/knowledge/reader/csv_reader.py +8 -10
  89. agno/knowledge/reader/docx_reader.py +5 -6
  90. agno/knowledge/reader/field_labeled_csv_reader.py +16 -20
  91. agno/knowledge/reader/json_reader.py +5 -4
  92. agno/knowledge/reader/markdown_reader.py +8 -8
  93. agno/knowledge/reader/pdf_reader.py +17 -19
  94. agno/knowledge/reader/pptx_reader.py +101 -0
  95. agno/knowledge/reader/reader_factory.py +32 -3
  96. agno/knowledge/reader/s3_reader.py +3 -3
  97. agno/knowledge/reader/tavily_reader.py +193 -0
  98. agno/knowledge/reader/text_reader.py +22 -10
  99. agno/knowledge/reader/web_search_reader.py +1 -48
  100. agno/knowledge/reader/website_reader.py +10 -10
  101. agno/knowledge/reader/wikipedia_reader.py +33 -1
  102. agno/knowledge/types.py +1 -0
  103. agno/knowledge/utils.py +72 -7
  104. agno/media.py +22 -6
  105. agno/memory/__init__.py +14 -1
  106. agno/memory/manager.py +544 -83
  107. agno/memory/strategies/__init__.py +15 -0
  108. agno/memory/strategies/base.py +66 -0
  109. agno/memory/strategies/summarize.py +196 -0
  110. agno/memory/strategies/types.py +37 -0
  111. agno/models/aimlapi/aimlapi.py +17 -0
  112. agno/models/anthropic/claude.py +515 -40
  113. agno/models/aws/bedrock.py +102 -21
  114. agno/models/aws/claude.py +131 -274
  115. agno/models/azure/ai_foundry.py +41 -19
  116. agno/models/azure/openai_chat.py +39 -8
  117. agno/models/base.py +1249 -525
  118. agno/models/cerebras/cerebras.py +91 -21
  119. agno/models/cerebras/cerebras_openai.py +21 -2
  120. agno/models/cohere/chat.py +40 -6
  121. agno/models/cometapi/cometapi.py +18 -1
  122. agno/models/dashscope/dashscope.py +2 -3
  123. agno/models/deepinfra/deepinfra.py +18 -1
  124. agno/models/deepseek/deepseek.py +69 -3
  125. agno/models/fireworks/fireworks.py +18 -1
  126. agno/models/google/gemini.py +877 -80
  127. agno/models/google/utils.py +22 -0
  128. agno/models/groq/groq.py +51 -18
  129. agno/models/huggingface/huggingface.py +17 -6
  130. agno/models/ibm/watsonx.py +16 -6
  131. agno/models/internlm/internlm.py +18 -1
  132. agno/models/langdb/langdb.py +13 -1
  133. agno/models/litellm/chat.py +44 -9
  134. agno/models/litellm/litellm_openai.py +18 -1
  135. agno/models/message.py +28 -5
  136. agno/models/meta/llama.py +47 -14
  137. agno/models/meta/llama_openai.py +22 -17
  138. agno/models/mistral/mistral.py +8 -4
  139. agno/models/nebius/nebius.py +6 -7
  140. agno/models/nvidia/nvidia.py +20 -3
  141. agno/models/ollama/chat.py +24 -8
  142. agno/models/openai/chat.py +104 -29
  143. agno/models/openai/responses.py +101 -81
  144. agno/models/openrouter/openrouter.py +60 -3
  145. agno/models/perplexity/perplexity.py +17 -1
  146. agno/models/portkey/portkey.py +7 -6
  147. agno/models/requesty/requesty.py +24 -4
  148. agno/models/response.py +73 -2
  149. agno/models/sambanova/sambanova.py +20 -3
  150. agno/models/siliconflow/siliconflow.py +19 -2
  151. agno/models/together/together.py +20 -3
  152. agno/models/utils.py +254 -8
  153. agno/models/vercel/v0.py +20 -3
  154. agno/models/vertexai/__init__.py +0 -0
  155. agno/models/vertexai/claude.py +190 -0
  156. agno/models/vllm/vllm.py +19 -14
  157. agno/models/xai/xai.py +19 -2
  158. agno/os/app.py +549 -152
  159. agno/os/auth.py +190 -3
  160. agno/os/config.py +23 -0
  161. agno/os/interfaces/a2a/router.py +8 -11
  162. agno/os/interfaces/a2a/utils.py +1 -1
  163. agno/os/interfaces/agui/router.py +18 -3
  164. agno/os/interfaces/agui/utils.py +152 -39
  165. agno/os/interfaces/slack/router.py +55 -37
  166. agno/os/interfaces/slack/slack.py +9 -1
  167. agno/os/interfaces/whatsapp/router.py +0 -1
  168. agno/os/interfaces/whatsapp/security.py +3 -1
  169. agno/os/mcp.py +110 -52
  170. agno/os/middleware/__init__.py +2 -0
  171. agno/os/middleware/jwt.py +676 -112
  172. agno/os/router.py +40 -1478
  173. agno/os/routers/agents/__init__.py +3 -0
  174. agno/os/routers/agents/router.py +599 -0
  175. agno/os/routers/agents/schema.py +261 -0
  176. agno/os/routers/evals/evals.py +96 -39
  177. agno/os/routers/evals/schemas.py +65 -33
  178. agno/os/routers/evals/utils.py +80 -10
  179. agno/os/routers/health.py +10 -4
  180. agno/os/routers/knowledge/knowledge.py +196 -38
  181. agno/os/routers/knowledge/schemas.py +82 -22
  182. agno/os/routers/memory/memory.py +279 -52
  183. agno/os/routers/memory/schemas.py +46 -17
  184. agno/os/routers/metrics/metrics.py +20 -8
  185. agno/os/routers/metrics/schemas.py +16 -16
  186. agno/os/routers/session/session.py +462 -34
  187. agno/os/routers/teams/__init__.py +3 -0
  188. agno/os/routers/teams/router.py +512 -0
  189. agno/os/routers/teams/schema.py +257 -0
  190. agno/os/routers/traces/__init__.py +3 -0
  191. agno/os/routers/traces/schemas.py +414 -0
  192. agno/os/routers/traces/traces.py +499 -0
  193. agno/os/routers/workflows/__init__.py +3 -0
  194. agno/os/routers/workflows/router.py +624 -0
  195. agno/os/routers/workflows/schema.py +75 -0
  196. agno/os/schema.py +256 -693
  197. agno/os/scopes.py +469 -0
  198. agno/os/utils.py +514 -36
  199. agno/reasoning/anthropic.py +80 -0
  200. agno/reasoning/gemini.py +73 -0
  201. agno/reasoning/openai.py +5 -0
  202. agno/reasoning/vertexai.py +76 -0
  203. agno/run/__init__.py +6 -0
  204. agno/run/agent.py +155 -32
  205. agno/run/base.py +55 -3
  206. agno/run/requirement.py +181 -0
  207. agno/run/team.py +125 -38
  208. agno/run/workflow.py +72 -18
  209. agno/session/agent.py +102 -89
  210. agno/session/summary.py +56 -15
  211. agno/session/team.py +164 -90
  212. agno/session/workflow.py +405 -40
  213. agno/table.py +10 -0
  214. agno/team/team.py +3974 -1903
  215. agno/tools/dalle.py +2 -4
  216. agno/tools/eleven_labs.py +23 -25
  217. agno/tools/exa.py +21 -16
  218. agno/tools/file.py +153 -23
  219. agno/tools/file_generation.py +16 -10
  220. agno/tools/firecrawl.py +15 -7
  221. agno/tools/function.py +193 -38
  222. agno/tools/gmail.py +238 -14
  223. agno/tools/google_drive.py +271 -0
  224. agno/tools/googlecalendar.py +36 -8
  225. agno/tools/googlesheets.py +20 -5
  226. agno/tools/jira.py +20 -0
  227. agno/tools/mcp/__init__.py +10 -0
  228. agno/tools/mcp/mcp.py +331 -0
  229. agno/tools/mcp/multi_mcp.py +347 -0
  230. agno/tools/mcp/params.py +24 -0
  231. agno/tools/mcp_toolbox.py +3 -3
  232. agno/tools/models/nebius.py +5 -5
  233. agno/tools/models_labs.py +20 -10
  234. agno/tools/nano_banana.py +151 -0
  235. agno/tools/notion.py +204 -0
  236. agno/tools/parallel.py +314 -0
  237. agno/tools/postgres.py +76 -36
  238. agno/tools/redshift.py +406 -0
  239. agno/tools/scrapegraph.py +1 -1
  240. agno/tools/shopify.py +1519 -0
  241. agno/tools/slack.py +18 -3
  242. agno/tools/spotify.py +919 -0
  243. agno/tools/tavily.py +146 -0
  244. agno/tools/toolkit.py +25 -0
  245. agno/tools/workflow.py +8 -1
  246. agno/tools/yfinance.py +12 -11
  247. agno/tracing/__init__.py +12 -0
  248. agno/tracing/exporter.py +157 -0
  249. agno/tracing/schemas.py +276 -0
  250. agno/tracing/setup.py +111 -0
  251. agno/utils/agent.py +938 -0
  252. agno/utils/cryptography.py +22 -0
  253. agno/utils/dttm.py +33 -0
  254. agno/utils/events.py +151 -3
  255. agno/utils/gemini.py +15 -5
  256. agno/utils/hooks.py +118 -4
  257. agno/utils/http.py +113 -2
  258. agno/utils/knowledge.py +12 -5
  259. agno/utils/log.py +1 -0
  260. agno/utils/mcp.py +92 -2
  261. agno/utils/media.py +187 -1
  262. agno/utils/merge_dict.py +3 -3
  263. agno/utils/message.py +60 -0
  264. agno/utils/models/ai_foundry.py +9 -2
  265. agno/utils/models/claude.py +49 -14
  266. agno/utils/models/cohere.py +9 -2
  267. agno/utils/models/llama.py +9 -2
  268. agno/utils/models/mistral.py +4 -2
  269. agno/utils/print_response/agent.py +109 -16
  270. agno/utils/print_response/team.py +223 -30
  271. agno/utils/print_response/workflow.py +251 -34
  272. agno/utils/streamlit.py +1 -1
  273. agno/utils/team.py +98 -9
  274. agno/utils/tokens.py +657 -0
  275. agno/vectordb/base.py +39 -7
  276. agno/vectordb/cassandra/cassandra.py +21 -5
  277. agno/vectordb/chroma/chromadb.py +43 -12
  278. agno/vectordb/clickhouse/clickhousedb.py +21 -5
  279. agno/vectordb/couchbase/couchbase.py +29 -5
  280. agno/vectordb/lancedb/lance_db.py +92 -181
  281. agno/vectordb/langchaindb/langchaindb.py +24 -4
  282. agno/vectordb/lightrag/lightrag.py +17 -3
  283. agno/vectordb/llamaindex/llamaindexdb.py +25 -5
  284. agno/vectordb/milvus/milvus.py +50 -37
  285. agno/vectordb/mongodb/__init__.py +7 -1
  286. agno/vectordb/mongodb/mongodb.py +36 -30
  287. agno/vectordb/pgvector/pgvector.py +201 -77
  288. agno/vectordb/pineconedb/pineconedb.py +41 -23
  289. agno/vectordb/qdrant/qdrant.py +67 -54
  290. agno/vectordb/redis/__init__.py +9 -0
  291. agno/vectordb/redis/redisdb.py +682 -0
  292. agno/vectordb/singlestore/singlestore.py +50 -29
  293. agno/vectordb/surrealdb/surrealdb.py +31 -41
  294. agno/vectordb/upstashdb/upstashdb.py +34 -6
  295. agno/vectordb/weaviate/weaviate.py +53 -14
  296. agno/workflow/__init__.py +2 -0
  297. agno/workflow/agent.py +299 -0
  298. agno/workflow/condition.py +120 -18
  299. agno/workflow/loop.py +77 -10
  300. agno/workflow/parallel.py +231 -143
  301. agno/workflow/router.py +118 -17
  302. agno/workflow/step.py +609 -170
  303. agno/workflow/steps.py +73 -6
  304. agno/workflow/types.py +96 -21
  305. agno/workflow/workflow.py +2039 -262
  306. {agno-2.1.2.dist-info → agno-2.3.13.dist-info}/METADATA +201 -66
  307. agno-2.3.13.dist-info/RECORD +613 -0
  308. agno/tools/googlesearch.py +0 -98
  309. agno/tools/mcp.py +0 -679
  310. agno/tools/memori.py +0 -339
  311. agno-2.1.2.dist-info/RECORD +0 -543
  312. {agno-2.1.2.dist-info → agno-2.3.13.dist-info}/WHEEL +0 -0
  313. {agno-2.1.2.dist-info → agno-2.3.13.dist-info}/licenses/LICENSE +0 -0
  314. {agno-2.1.2.dist-info → agno-2.3.13.dist-info}/top_level.txt +0 -0
agno/tools/shopify.py ADDED
@@ -0,0 +1,1519 @@
1
+ """
2
+ Shopify Toolkit for Agno SDK
3
+
4
+ A toolkit for analyzing sales data, product performance, and customer insights using the Shopify Admin GraphQL API.
5
+ Requires a valid Shopify access token with appropriate scopes.
6
+
7
+ Required scopes:
8
+ - read_orders (for order and sales data)
9
+ - read_products (for product information)
10
+ - read_customers (for customer insights)
11
+ - read_analytics (for analytics data)
12
+ """
13
+
14
+ import json
15
+ from collections import Counter
16
+ from datetime import datetime, timedelta
17
+ from itertools import combinations
18
+ from os import getenv
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ import httpx
22
+
23
+ from agno.tools import Toolkit
24
+ from agno.utils.log import log_debug
25
+
26
+
27
+ class ShopifyTools(Toolkit):
28
+ """
29
+ Shopify toolkit for analyzing sales data and product performance.
30
+
31
+ Args:
32
+ shop_name: Your Shopify store name (e.g., 'my-store' from my-store.myshopify.com).
33
+ access_token: Shopify Admin API access token with required scopes.
34
+ api_version: Shopify API version (default: '2025-10').
35
+ timeout: Request timeout in seconds.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ shop_name: Optional[str] = None,
41
+ access_token: Optional[str] = None,
42
+ api_version: str = "2025-10",
43
+ timeout: int = 30,
44
+ **kwargs,
45
+ ):
46
+ self.shop_name = shop_name or getenv("SHOPIFY_SHOP_NAME")
47
+ self.access_token = access_token or getenv("SHOPIFY_ACCESS_TOKEN")
48
+ self.api_version = api_version
49
+ self.timeout = timeout
50
+ self.base_url = f"https://{self.shop_name}.myshopify.com/admin/api/{self.api_version}/graphql.json"
51
+
52
+ tools: List[Any] = [
53
+ self.get_shop_info,
54
+ self.get_products,
55
+ self.get_orders,
56
+ self.get_top_selling_products,
57
+ self.get_products_bought_together,
58
+ self.get_sales_by_date_range,
59
+ self.get_order_analytics,
60
+ self.get_product_sales_breakdown,
61
+ self.get_customer_order_history,
62
+ self.get_inventory_levels,
63
+ self.get_low_stock_products,
64
+ self.get_sales_trends,
65
+ self.get_average_order_value,
66
+ self.get_repeat_customers,
67
+ ]
68
+
69
+ super().__init__(name="shopify", tools=tools, **kwargs)
70
+
71
+ def _make_graphql_request(self, query: str, variables: Optional[Dict] = None) -> Dict:
72
+ """Make an authenticated GraphQL request to the Shopify Admin API."""
73
+ headers = {
74
+ "X-Shopify-Access-Token": self.access_token or "",
75
+ "Content-Type": "application/json",
76
+ }
77
+
78
+ body: Dict[str, Any] = {"query": query}
79
+ if variables:
80
+ body["variables"] = variables
81
+
82
+ with httpx.Client(timeout=self.timeout) as client:
83
+ response = client.post(
84
+ self.base_url,
85
+ headers=headers,
86
+ json=body,
87
+ )
88
+
89
+ try:
90
+ result = response.json()
91
+ if "errors" in result:
92
+ return {"error": result["errors"]}
93
+ return result.get("data", {})
94
+ except json.JSONDecodeError:
95
+ return {"error": f"Failed to parse response: {response.text}"}
96
+
97
+ def get_shop_info(self) -> str:
98
+ """Get basic information about the Shopify store.
99
+
100
+ Returns:
101
+ JSON string containing shop name, email, currency, and other details.
102
+ """
103
+ log_debug("Fetching Shopify shop info")
104
+
105
+ query = """
106
+ query {
107
+ shop {
108
+ name
109
+ email
110
+ currencyCode
111
+ primaryDomain {
112
+ url
113
+ }
114
+ billingAddress {
115
+ country
116
+ city
117
+ }
118
+ plan {
119
+ displayName
120
+ }
121
+ }
122
+ }
123
+ """
124
+
125
+ result = self._make_graphql_request(query)
126
+
127
+ if "error" in result:
128
+ return json.dumps(result, indent=2)
129
+
130
+ return json.dumps(result.get("shop", {}), indent=2)
131
+
132
+ def get_products(
133
+ self,
134
+ max_results: int = 50,
135
+ status: Optional[str] = None,
136
+ ) -> str:
137
+ """Get products from the store.
138
+
139
+ Args:
140
+ max_results: Maximum number of products to return (default 50, max 250).
141
+ status: Filter by status - 'ACTIVE', 'ARCHIVED', or 'DRAFT' (optional).
142
+
143
+ Returns:
144
+ JSON string containing list of products with id, title, status, variants, and pricing.
145
+ """
146
+ log_debug(f"Fetching products: max_results={max_results}, status={status}")
147
+
148
+ query_filter = ""
149
+ if status:
150
+ query_filter = f', query: "status:{status}"'
151
+
152
+ query = f"""
153
+ query {{
154
+ products(first: {min(max_results, 250)}{query_filter}) {{
155
+ edges {{
156
+ node {{
157
+ id
158
+ title
159
+ status
160
+ totalInventory
161
+ createdAt
162
+ updatedAt
163
+ priceRangeV2 {{
164
+ minVariantPrice {{
165
+ amount
166
+ currencyCode
167
+ }}
168
+ maxVariantPrice {{
169
+ amount
170
+ currencyCode
171
+ }}
172
+ }}
173
+ variants(first: 10) {{
174
+ edges {{
175
+ node {{
176
+ id
177
+ title
178
+ sku
179
+ price
180
+ inventoryQuantity
181
+ }}
182
+ }}
183
+ }}
184
+ }}
185
+ }}
186
+ }}
187
+ }}
188
+ """
189
+
190
+ result = self._make_graphql_request(query)
191
+
192
+ if "error" in result:
193
+ return json.dumps(result, indent=2)
194
+
195
+ products = []
196
+ for edge in result.get("products", {}).get("edges", []):
197
+ node = edge["node"]
198
+ products.append(
199
+ {
200
+ "id": node["id"],
201
+ "title": node["title"],
202
+ "status": node["status"],
203
+ "total_inventory": node.get("totalInventory"),
204
+ "created_at": node.get("createdAt"),
205
+ "price_range": {
206
+ "min": node.get("priceRangeV2", {}).get("minVariantPrice", {}).get("amount"),
207
+ "max": node.get("priceRangeV2", {}).get("maxVariantPrice", {}).get("amount"),
208
+ "currency": node.get("priceRangeV2", {}).get("minVariantPrice", {}).get("currencyCode"),
209
+ },
210
+ "variants": [
211
+ {
212
+ "id": v["node"]["id"],
213
+ "title": v["node"]["title"],
214
+ "sku": v["node"].get("sku"),
215
+ "price": v["node"]["price"],
216
+ "inventory": v["node"].get("inventoryQuantity"),
217
+ }
218
+ for v in node.get("variants", {}).get("edges", [])
219
+ ],
220
+ }
221
+ )
222
+
223
+ return json.dumps(products, indent=2)
224
+
225
+ def get_orders(
226
+ self,
227
+ max_results: int = 50,
228
+ status: Optional[str] = None,
229
+ created_after: Optional[str] = None,
230
+ created_before: Optional[str] = None,
231
+ ) -> str:
232
+ """Get recent orders from the store.
233
+
234
+ Args:
235
+ max_results: Maximum number of orders to return (default 50, max 250).
236
+ status: Filter by financial status - 'paid', 'pending', 'refunded' (optional).
237
+ created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
238
+ created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
239
+
240
+ Returns:
241
+ JSON string containing list of orders with id, total, customer, and line items.
242
+ """
243
+ log_debug(
244
+ f"Fetching orders: max_results={max_results}, status={status}, created_after={created_after}, created_before={created_before}"
245
+ )
246
+
247
+ query_parts = []
248
+ if created_after:
249
+ query_parts.append(f"created_at:>={created_after}")
250
+ if created_before:
251
+ query_parts.append(f"created_at:<={created_before}")
252
+ if status:
253
+ query_parts.append(f"financial_status:{status}")
254
+
255
+ query_filter = " AND ".join(query_parts) if query_parts else ""
256
+ query_param = f', query: "{query_filter}"' if query_filter else ""
257
+
258
+ query = f"""
259
+ query {{
260
+ orders(first: {min(max_results, 250)}{query_param}, sortKey: CREATED_AT, reverse: true) {{
261
+ edges {{
262
+ node {{
263
+ id
264
+ name
265
+ createdAt
266
+ displayFinancialStatus
267
+ displayFulfillmentStatus
268
+ totalPriceSet {{
269
+ shopMoney {{
270
+ amount
271
+ currencyCode
272
+ }}
273
+ }}
274
+ subtotalPriceSet {{
275
+ shopMoney {{
276
+ amount
277
+ }}
278
+ }}
279
+ customer {{
280
+ id
281
+ email
282
+ firstName
283
+ lastName
284
+ }}
285
+ lineItems(first: 50) {{
286
+ edges {{
287
+ node {{
288
+ id
289
+ title
290
+ quantity
291
+ variant {{
292
+ id
293
+ sku
294
+ }}
295
+ originalUnitPriceSet {{
296
+ shopMoney {{
297
+ amount
298
+ }}
299
+ }}
300
+ }}
301
+ }}
302
+ }}
303
+ }}
304
+ }}
305
+ }}
306
+ }}
307
+ """
308
+
309
+ result = self._make_graphql_request(query)
310
+
311
+ if "error" in result:
312
+ return json.dumps(result, indent=2)
313
+
314
+ orders = []
315
+ for edge in result.get("orders", {}).get("edges", []):
316
+ node = edge["node"]
317
+ customer = node.get("customer") or {}
318
+ orders.append(
319
+ {
320
+ "id": node["id"],
321
+ "name": node["name"],
322
+ "created_at": node["createdAt"],
323
+ "financial_status": node.get("displayFinancialStatus"),
324
+ "fulfillment_status": node.get("displayFulfillmentStatus"),
325
+ "total": node.get("totalPriceSet", {}).get("shopMoney", {}).get("amount"),
326
+ "subtotal": node.get("subtotalPriceSet", {}).get("shopMoney", {}).get("amount"),
327
+ "currency": node.get("totalPriceSet", {}).get("shopMoney", {}).get("currencyCode"),
328
+ "customer": {
329
+ "id": customer.get("id"),
330
+ "email": customer.get("email"),
331
+ "name": f"{customer.get('firstName', '')} {customer.get('lastName', '')}".strip(),
332
+ }
333
+ if customer
334
+ else None,
335
+ "line_items": [
336
+ {
337
+ "id": item["node"]["id"],
338
+ "title": item["node"]["title"],
339
+ "quantity": item["node"]["quantity"],
340
+ "unit_price": item["node"]
341
+ .get("originalUnitPriceSet", {})
342
+ .get("shopMoney", {})
343
+ .get("amount"),
344
+ "sku": item["node"].get("variant", {}).get("sku") if item["node"].get("variant") else None,
345
+ }
346
+ for item in node.get("lineItems", {}).get("edges", [])
347
+ ],
348
+ }
349
+ )
350
+
351
+ return json.dumps(orders, indent=2)
352
+
353
+ def get_top_selling_products(
354
+ self,
355
+ limit: int = 10,
356
+ created_after: Optional[str] = None,
357
+ created_before: Optional[str] = None,
358
+ ) -> str:
359
+ """Get the top selling products by quantity sold.
360
+
361
+ Analyzes order data to find which products sell the most.
362
+
363
+ Args:
364
+ limit: Number of top products to return (default 10).
365
+ created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
366
+ created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
367
+
368
+ Returns:
369
+ JSON string containing ranked list of top products with sales data.
370
+ """
371
+ log_debug(
372
+ f"Calculating top selling products: limit={limit}, created_after={created_after}, created_before={created_before}"
373
+ )
374
+
375
+ query_parts = ["financial_status:paid"]
376
+ if created_after:
377
+ query_parts.append(f"created_at:>={created_after}")
378
+ if created_before:
379
+ query_parts.append(f"created_at:<={created_before}")
380
+ query_filter = " AND ".join(query_parts)
381
+
382
+ query = f"""
383
+ query {{
384
+ orders(first: 250, query: "{query_filter}", sortKey: CREATED_AT) {{
385
+ edges {{
386
+ node {{
387
+ lineItems(first: 100) {{
388
+ edges {{
389
+ node {{
390
+ title
391
+ quantity
392
+ variant {{
393
+ id
394
+ product {{
395
+ id
396
+ title
397
+ }}
398
+ }}
399
+ originalUnitPriceSet {{
400
+ shopMoney {{
401
+ amount
402
+ }}
403
+ }}
404
+ }}
405
+ }}
406
+ }}
407
+ }}
408
+ }}
409
+ }}
410
+ }}
411
+ """
412
+
413
+ result = self._make_graphql_request(query)
414
+
415
+ if "error" in result:
416
+ return json.dumps(result, indent=2)
417
+
418
+ # Aggregate sales by product
419
+ product_sales: Dict[str, Dict[str, Any]] = {}
420
+
421
+ for order_edge in result.get("orders", {}).get("edges", []):
422
+ for item_edge in order_edge["node"].get("lineItems", {}).get("edges", []):
423
+ item = item_edge["node"]
424
+ variant = item.get("variant")
425
+ if not variant or not variant.get("product"):
426
+ continue
427
+
428
+ product_id = variant["product"]["id"]
429
+ product_title = variant["product"]["title"]
430
+ quantity = item["quantity"]
431
+ unit_price = float(item.get("originalUnitPriceSet", {}).get("shopMoney", {}).get("amount", 0))
432
+
433
+ if product_id not in product_sales:
434
+ product_sales[product_id] = {
435
+ "id": product_id,
436
+ "title": product_title,
437
+ "total_quantity": 0,
438
+ "total_revenue": 0.0,
439
+ "order_count": 0,
440
+ }
441
+
442
+ product_sales[product_id]["total_quantity"] += quantity
443
+ product_sales[product_id]["total_revenue"] += quantity * unit_price
444
+ product_sales[product_id]["order_count"] += 1
445
+
446
+ # Sort by quantity and limit
447
+ sorted_products = sorted(product_sales.values(), key=lambda x: x["total_quantity"], reverse=True)[:limit]
448
+
449
+ # Add ranking
450
+ for i, product in enumerate(sorted_products):
451
+ product["rank"] = i + 1
452
+ product["total_revenue"] = round(product["total_revenue"], 2)
453
+
454
+ return json.dumps(sorted_products, indent=2)
455
+
456
+ def get_products_bought_together(
457
+ self,
458
+ min_occurrences: int = 2,
459
+ limit: int = 20,
460
+ created_after: Optional[str] = None,
461
+ created_before: Optional[str] = None,
462
+ ) -> str:
463
+ """Find products that are frequently bought together.
464
+
465
+ Analyzes orders to find product pairs that appear together most often.
466
+ Useful for cross-selling and bundle recommendations.
467
+
468
+ Args:
469
+ min_occurrences: Minimum times a pair must appear together (default 2).
470
+ limit: Number of product pairs to return (default 20).
471
+ created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
472
+ created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
473
+
474
+ Returns:
475
+ JSON string containing ranked list of product pairs with co-occurrence count.
476
+ """
477
+ log_debug(f"Finding products bought together: created_after={created_after}, created_before={created_before}")
478
+
479
+ query_parts = ["financial_status:paid"]
480
+ if created_after:
481
+ query_parts.append(f"created_at:>={created_after}")
482
+ if created_before:
483
+ query_parts.append(f"created_at:<={created_before}")
484
+ query_filter = " AND ".join(query_parts)
485
+
486
+ query = f"""
487
+ query {{
488
+ orders(first: 250, query: "{query_filter}") {{
489
+ edges {{
490
+ node {{
491
+ lineItems(first: 100) {{
492
+ edges {{
493
+ node {{
494
+ variant {{
495
+ product {{
496
+ id
497
+ title
498
+ }}
499
+ }}
500
+ }}
501
+ }}
502
+ }}
503
+ }}
504
+ }}
505
+ }}
506
+ }}
507
+ """
508
+
509
+ result = self._make_graphql_request(query)
510
+
511
+ if "error" in result:
512
+ return json.dumps(result, indent=2)
513
+
514
+ # Count co-occurrences
515
+ pair_counter: Counter = Counter()
516
+ product_info: Dict[str, str] = {}
517
+
518
+ for order_edge in result.get("orders", {}).get("edges", []):
519
+ products_in_order = []
520
+ for item_edge in order_edge["node"].get("lineItems", {}).get("edges", []):
521
+ variant = item_edge["node"].get("variant")
522
+ if variant and variant.get("product"):
523
+ product_id = variant["product"]["id"]
524
+ product_title = variant["product"]["title"]
525
+ products_in_order.append(product_id)
526
+ product_info[product_id] = product_title
527
+
528
+ # Get unique products in this order and count pairs
529
+ unique_products = list(set(products_in_order))
530
+ if len(unique_products) >= 2:
531
+ for pair in combinations(sorted(unique_products), 2):
532
+ pair_counter[pair] += 1
533
+
534
+ # Filter by minimum occurrences and sort
535
+ frequent_pairs = [
536
+ {
537
+ "product_1": {
538
+ "id": pair[0],
539
+ "title": product_info.get(pair[0], "Unknown"),
540
+ },
541
+ "product_2": {
542
+ "id": pair[1],
543
+ "title": product_info.get(pair[1], "Unknown"),
544
+ },
545
+ "times_bought_together": count,
546
+ }
547
+ for pair, count in pair_counter.most_common(limit)
548
+ if count >= min_occurrences
549
+ ]
550
+
551
+ return json.dumps(frequent_pairs, indent=2)
552
+
553
+ def get_sales_by_date_range(
554
+ self,
555
+ start_date: str,
556
+ end_date: str,
557
+ ) -> str:
558
+ """Get sales summary for a specific date range.
559
+
560
+ Args:
561
+ start_date: Start date in YYYY-MM-DD format.
562
+ end_date: End date in YYYY-MM-DD format.
563
+
564
+ Returns:
565
+ JSON string containing total revenue, order count, and daily breakdown.
566
+ """
567
+ log_debug(f"Fetching sales for date range: {start_date} to {end_date}")
568
+
569
+ query = f"""
570
+ query {{
571
+ orders(first: 250, query: "created_at:>={start_date} AND created_at:<={end_date} AND financial_status:paid", sortKey: CREATED_AT) {{
572
+ edges {{
573
+ node {{
574
+ createdAt
575
+ totalPriceSet {{
576
+ shopMoney {{
577
+ amount
578
+ currencyCode
579
+ }}
580
+ }}
581
+ lineItems(first: 100) {{
582
+ edges {{
583
+ node {{
584
+ quantity
585
+ }}
586
+ }}
587
+ }}
588
+ }}
589
+ }}
590
+ }}
591
+ }}
592
+ """
593
+
594
+ result = self._make_graphql_request(query)
595
+
596
+ if "error" in result:
597
+ return json.dumps(result, indent=2)
598
+
599
+ # Aggregate data
600
+ total_revenue = 0.0
601
+ total_orders = 0
602
+ total_items = 0
603
+ daily_breakdown: Dict[str, Dict[str, Any]] = {}
604
+ currency = None
605
+
606
+ for order_edge in result.get("orders", {}).get("edges", []):
607
+ order = order_edge["node"]
608
+ amount = float(order.get("totalPriceSet", {}).get("shopMoney", {}).get("amount", 0))
609
+ currency = order.get("totalPriceSet", {}).get("shopMoney", {}).get("currencyCode")
610
+ date = order["createdAt"][:10] # Extract date part
611
+
612
+ items_in_order = sum(item["node"]["quantity"] for item in order.get("lineItems", {}).get("edges", []))
613
+
614
+ total_revenue += amount
615
+ total_orders += 1
616
+ total_items += items_in_order
617
+
618
+ if date not in daily_breakdown:
619
+ daily_breakdown[date] = {"date": date, "revenue": 0.0, "orders": 0, "items": 0}
620
+ daily_breakdown[date]["revenue"] += amount
621
+ daily_breakdown[date]["orders"] += 1
622
+ daily_breakdown[date]["items"] += items_in_order
623
+
624
+ # Sort daily breakdown by date
625
+ sorted_daily = sorted(daily_breakdown.values(), key=lambda x: x["date"])
626
+ for day in sorted_daily:
627
+ day["revenue"] = round(day["revenue"], 2)
628
+
629
+ summary = {
630
+ "period": {"start": start_date, "end": end_date},
631
+ "total_revenue": round(total_revenue, 2),
632
+ "total_orders": total_orders,
633
+ "total_items_sold": total_items,
634
+ "average_order_value": round(total_revenue / total_orders, 2) if total_orders > 0 else 0,
635
+ "currency": currency,
636
+ "daily_breakdown": sorted_daily,
637
+ }
638
+
639
+ return json.dumps(summary, indent=2)
640
+
641
+ def get_order_analytics(
642
+ self,
643
+ created_after: Optional[str] = None,
644
+ created_before: Optional[str] = None,
645
+ ) -> str:
646
+ """Get comprehensive order analytics for a time period.
647
+
648
+ Provides metrics like total orders, revenue, average order value,
649
+ fulfillment rates, and more.
650
+
651
+ Args:
652
+ created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
653
+ created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
654
+
655
+ Returns:
656
+ JSON string containing various order metrics and statistics.
657
+ """
658
+ log_debug(f"Generating order analytics: created_after={created_after}, created_before={created_before}")
659
+
660
+ query_parts = []
661
+ if created_after:
662
+ query_parts.append(f"created_at:>={created_after}")
663
+ if created_before:
664
+ query_parts.append(f"created_at:<={created_before}")
665
+ query_filter = " AND ".join(query_parts) if query_parts else ""
666
+ query_param = f', query: "{query_filter}"' if query_filter else ""
667
+
668
+ query = f"""
669
+ query {{
670
+ orders(first: 250{query_param}, sortKey: CREATED_AT) {{
671
+ edges {{
672
+ node {{
673
+ displayFinancialStatus
674
+ displayFulfillmentStatus
675
+ totalPriceSet {{
676
+ shopMoney {{
677
+ amount
678
+ currencyCode
679
+ }}
680
+ }}
681
+ subtotalPriceSet {{
682
+ shopMoney {{
683
+ amount
684
+ }}
685
+ }}
686
+ totalShippingPriceSet {{
687
+ shopMoney {{
688
+ amount
689
+ }}
690
+ }}
691
+ totalTaxSet {{
692
+ shopMoney {{
693
+ amount
694
+ }}
695
+ }}
696
+ lineItems(first: 100) {{
697
+ edges {{
698
+ node {{
699
+ quantity
700
+ }}
701
+ }}
702
+ }}
703
+ }}
704
+ }}
705
+ }}
706
+ }}
707
+ """
708
+
709
+ result = self._make_graphql_request(query)
710
+
711
+ if "error" in result:
712
+ return json.dumps(result, indent=2)
713
+
714
+ orders = result.get("orders", {}).get("edges", [])
715
+
716
+ if not orders:
717
+ return json.dumps({"message": "No orders found in the specified period"}, indent=2)
718
+
719
+ # Calculate metrics
720
+ total_orders = len(orders)
721
+ total_revenue = 0.0
722
+ total_subtotal = 0.0
723
+ total_shipping = 0.0
724
+ total_tax = 0.0
725
+ total_items = 0
726
+ currency = None
727
+
728
+ financial_status_counts: Counter = Counter()
729
+ fulfillment_status_counts: Counter = Counter()
730
+ order_values: List[float] = []
731
+
732
+ for order_edge in orders:
733
+ order = order_edge["node"]
734
+
735
+ amount = float(order.get("totalPriceSet", {}).get("shopMoney", {}).get("amount", 0))
736
+ currency = order.get("totalPriceSet", {}).get("shopMoney", {}).get("currencyCode")
737
+
738
+ total_revenue += amount
739
+ total_subtotal += float(order.get("subtotalPriceSet", {}).get("shopMoney", {}).get("amount", 0))
740
+ total_shipping += float(order.get("totalShippingPriceSet", {}).get("shopMoney", {}).get("amount", 0))
741
+ total_tax += float(order.get("totalTaxSet", {}).get("shopMoney", {}).get("amount", 0))
742
+
743
+ order_values.append(amount)
744
+
745
+ items = sum(item["node"]["quantity"] for item in order.get("lineItems", {}).get("edges", []))
746
+ total_items += items
747
+
748
+ financial_status_counts[order.get("displayFinancialStatus", "UNKNOWN")] += 1
749
+ fulfillment_status_counts[order.get("displayFulfillmentStatus", "UNKNOWN")] += 1
750
+
751
+ avg_order_value = total_revenue / total_orders if total_orders > 0 else 0
752
+ avg_items_per_order = total_items / total_orders if total_orders > 0 else 0
753
+
754
+ analytics = {
755
+ "period": {
756
+ "created_after": created_after,
757
+ "created_before": created_before,
758
+ },
759
+ "total_orders": total_orders,
760
+ "total_revenue": round(total_revenue, 2),
761
+ "total_subtotal": round(total_subtotal, 2),
762
+ "total_shipping": round(total_shipping, 2),
763
+ "total_tax": round(total_tax, 2),
764
+ "currency": currency,
765
+ "average_order_value": round(avg_order_value, 2),
766
+ "total_items_sold": total_items,
767
+ "average_items_per_order": round(avg_items_per_order, 2),
768
+ "min_order_value": round(min(order_values), 2) if order_values else 0,
769
+ "max_order_value": round(max(order_values), 2) if order_values else 0,
770
+ "financial_status_breakdown": dict(financial_status_counts),
771
+ "fulfillment_status_breakdown": dict(fulfillment_status_counts),
772
+ }
773
+
774
+ return json.dumps(analytics, indent=2)
775
+
776
+ def get_product_sales_breakdown(
777
+ self,
778
+ product_id: str,
779
+ created_after: Optional[str] = None,
780
+ created_before: Optional[str] = None,
781
+ ) -> str:
782
+ """Get detailed sales breakdown for a specific product.
783
+
784
+ Args:
785
+ product_id: The Shopify product ID (gid://shopify/Product/xxx or just the number).
786
+ created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
787
+ created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
788
+
789
+ Returns:
790
+ JSON string containing product sales data including quantity, revenue, and variant breakdown.
791
+ """
792
+ log_debug(f"Fetching sales breakdown for product: {product_id}")
793
+
794
+ # Normalize product ID
795
+ if not product_id.startswith("gid://"):
796
+ product_id = f"gid://shopify/Product/{product_id}"
797
+
798
+ query_parts = ["financial_status:paid"]
799
+ if created_after:
800
+ query_parts.append(f"created_at:>={created_after}")
801
+ if created_before:
802
+ query_parts.append(f"created_at:<={created_before}")
803
+ query_filter = " AND ".join(query_parts)
804
+
805
+ query = f"""
806
+ query {{
807
+ orders(first: 250, query: "{query_filter}") {{
808
+ edges {{
809
+ node {{
810
+ createdAt
811
+ lineItems(first: 100) {{
812
+ edges {{
813
+ node {{
814
+ title
815
+ quantity
816
+ variant {{
817
+ id
818
+ title
819
+ sku
820
+ product {{
821
+ id
822
+ title
823
+ }}
824
+ }}
825
+ originalUnitPriceSet {{
826
+ shopMoney {{
827
+ amount
828
+ currencyCode
829
+ }}
830
+ }}
831
+ }}
832
+ }}
833
+ }}
834
+ }}
835
+ }}
836
+ }}
837
+ }}
838
+ """
839
+
840
+ result = self._make_graphql_request(query)
841
+
842
+ if "error" in result:
843
+ return json.dumps(result, indent=2)
844
+
845
+ # Filter for specific product and aggregate
846
+ product_title = None
847
+ total_quantity = 0
848
+ total_revenue = 0.0
849
+ order_count = 0
850
+ currency = None
851
+ variant_breakdown: Dict[str, Dict[str, Any]] = {}
852
+ daily_sales: Dict[str, Dict[str, Any]] = {}
853
+
854
+ for order_edge in result.get("orders", {}).get("edges", []):
855
+ order = order_edge["node"]
856
+ order_date = order["createdAt"][:10]
857
+ found_in_order = False
858
+
859
+ for item_edge in order.get("lineItems", {}).get("edges", []):
860
+ item = item_edge["node"]
861
+ variant = item.get("variant")
862
+ if not variant or not variant.get("product"):
863
+ continue
864
+
865
+ if variant["product"]["id"] == product_id:
866
+ product_title = variant["product"]["title"]
867
+ quantity = item["quantity"]
868
+ unit_price = float(item.get("originalUnitPriceSet", {}).get("shopMoney", {}).get("amount", 0))
869
+ currency = item.get("originalUnitPriceSet", {}).get("shopMoney", {}).get("currencyCode")
870
+ variant_id = variant["id"]
871
+ variant_title = variant.get("title", "Default")
872
+
873
+ total_quantity += quantity
874
+ total_revenue += quantity * unit_price
875
+ found_in_order = True
876
+
877
+ # Variant breakdown
878
+ if variant_id not in variant_breakdown:
879
+ variant_breakdown[variant_id] = {
880
+ "variant_id": variant_id,
881
+ "variant_title": variant_title,
882
+ "sku": variant.get("sku"),
883
+ "quantity": 0,
884
+ "revenue": 0.0,
885
+ }
886
+ variant_breakdown[variant_id]["quantity"] += quantity
887
+ variant_breakdown[variant_id]["revenue"] += quantity * unit_price
888
+
889
+ # Daily breakdown
890
+ if order_date not in daily_sales:
891
+ daily_sales[order_date] = {"date": order_date, "quantity": 0, "revenue": 0.0}
892
+ daily_sales[order_date]["quantity"] += quantity
893
+ daily_sales[order_date]["revenue"] += quantity * unit_price
894
+
895
+ if found_in_order:
896
+ order_count += 1
897
+
898
+ if product_title is None:
899
+ return json.dumps({"error": "Product not found in any orders during this period"}, indent=2)
900
+
901
+ # Format variants and daily data
902
+ for v in variant_breakdown.values():
903
+ v["revenue"] = round(v["revenue"], 2)
904
+
905
+ sorted_daily = sorted(daily_sales.values(), key=lambda x: x["date"])
906
+ for d in sorted_daily:
907
+ d["revenue"] = round(d["revenue"], 2)
908
+
909
+ breakdown = {
910
+ "product_id": product_id,
911
+ "product_title": product_title,
912
+ "period": {
913
+ "created_after": created_after,
914
+ "created_before": created_before,
915
+ },
916
+ "total_quantity_sold": total_quantity,
917
+ "total_revenue": round(total_revenue, 2),
918
+ "order_count": order_count,
919
+ "currency": currency,
920
+ "average_units_per_order": round(total_quantity / order_count, 2) if order_count > 0 else 0,
921
+ "variant_breakdown": list(variant_breakdown.values()),
922
+ "daily_sales": sorted_daily,
923
+ }
924
+
925
+ return json.dumps(breakdown, indent=2)
926
+
927
+ def get_customer_order_history(
928
+ self,
929
+ customer_email: str,
930
+ max_orders: int = 50,
931
+ ) -> str:
932
+ """Get order history for a specific customer.
933
+
934
+ Args:
935
+ customer_email: The customer's email address.
936
+ max_orders: Maximum number of orders to return (default 50).
937
+
938
+ Returns:
939
+ JSON string containing customer info and their order history.
940
+ """
941
+ log_debug(f"Fetching order history for customer: {customer_email}")
942
+
943
+ query = f"""
944
+ query {{
945
+ orders(first: {min(max_orders, 250)}, query: "email:{customer_email}", sortKey: CREATED_AT, reverse: true) {{
946
+ edges {{
947
+ node {{
948
+ id
949
+ name
950
+ createdAt
951
+ displayFinancialStatus
952
+ displayFulfillmentStatus
953
+ totalPriceSet {{
954
+ shopMoney {{
955
+ amount
956
+ currencyCode
957
+ }}
958
+ }}
959
+ customer {{
960
+ id
961
+ firstName
962
+ lastName
963
+ numberOfOrders
964
+ amountSpent {{
965
+ amount
966
+ currencyCode
967
+ }}
968
+ }}
969
+ lineItems(first: 20) {{
970
+ edges {{
971
+ node {{
972
+ title
973
+ quantity
974
+ }}
975
+ }}
976
+ }}
977
+ }}
978
+ }}
979
+ }}
980
+ }}
981
+ """
982
+
983
+ result = self._make_graphql_request(query)
984
+
985
+ if "error" in result:
986
+ return json.dumps(result, indent=2)
987
+
988
+ orders = result.get("orders", {}).get("edges", [])
989
+
990
+ if not orders:
991
+ return json.dumps({"message": f"No orders found for {customer_email}"}, indent=2)
992
+
993
+ # Get customer info from first order
994
+ first_customer = orders[0]["node"].get("customer", {}) if orders else {}
995
+
996
+ customer_info = {
997
+ "email": customer_email,
998
+ "id": first_customer.get("id"),
999
+ "name": f"{first_customer.get('firstName', '')} {first_customer.get('lastName', '')}".strip(),
1000
+ "total_orders": first_customer.get("numberOfOrders"),
1001
+ "total_spent": first_customer.get("amountSpent", {}).get("amount"),
1002
+ "currency": first_customer.get("amountSpent", {}).get("currencyCode"),
1003
+ }
1004
+
1005
+ order_list = []
1006
+ for order_edge in orders:
1007
+ order = order_edge["node"]
1008
+ order_list.append(
1009
+ {
1010
+ "id": order["id"],
1011
+ "name": order["name"],
1012
+ "created_at": order["createdAt"],
1013
+ "financial_status": order.get("displayFinancialStatus"),
1014
+ "fulfillment_status": order.get("displayFulfillmentStatus"),
1015
+ "total": order.get("totalPriceSet", {}).get("shopMoney", {}).get("amount"),
1016
+ "items": [
1017
+ {"title": item["node"]["title"], "quantity": item["node"]["quantity"]}
1018
+ for item in order.get("lineItems", {}).get("edges", [])
1019
+ ],
1020
+ }
1021
+ )
1022
+
1023
+ response = {
1024
+ "customer": customer_info,
1025
+ "orders": order_list,
1026
+ }
1027
+
1028
+ return json.dumps(response, indent=2)
1029
+
1030
+ def get_inventory_levels(
1031
+ self,
1032
+ max_results: int = 100,
1033
+ ) -> str:
1034
+ """Get current inventory levels for all products.
1035
+
1036
+ Args:
1037
+ max_results: Maximum number of products to return (default 100).
1038
+
1039
+ Returns:
1040
+ JSON string containing products with their inventory quantities.
1041
+ """
1042
+ log_debug(f"Fetching inventory levels: max_results={max_results}")
1043
+
1044
+ query = f"""
1045
+ query {{
1046
+ products(first: {min(max_results, 250)}, query: "status:ACTIVE") {{
1047
+ edges {{
1048
+ node {{
1049
+ id
1050
+ title
1051
+ totalInventory
1052
+ tracksInventory
1053
+ variants(first: 50) {{
1054
+ edges {{
1055
+ node {{
1056
+ id
1057
+ title
1058
+ sku
1059
+ inventoryQuantity
1060
+ inventoryPolicy
1061
+ }}
1062
+ }}
1063
+ }}
1064
+ }}
1065
+ }}
1066
+ }}
1067
+ }}
1068
+ """
1069
+
1070
+ result = self._make_graphql_request(query)
1071
+
1072
+ if "error" in result:
1073
+ return json.dumps(result, indent=2)
1074
+
1075
+ products = []
1076
+ for edge in result.get("products", {}).get("edges", []):
1077
+ node = edge["node"]
1078
+ products.append(
1079
+ {
1080
+ "id": node["id"],
1081
+ "title": node["title"],
1082
+ "total_inventory": node.get("totalInventory"),
1083
+ "tracks_inventory": node.get("tracksInventory"),
1084
+ "variants": [
1085
+ {
1086
+ "id": v["node"]["id"],
1087
+ "title": v["node"]["title"],
1088
+ "sku": v["node"].get("sku"),
1089
+ "inventory_quantity": v["node"].get("inventoryQuantity"),
1090
+ "inventory_policy": v["node"].get("inventoryPolicy"),
1091
+ }
1092
+ for v in node.get("variants", {}).get("edges", [])
1093
+ ],
1094
+ }
1095
+ )
1096
+
1097
+ return json.dumps(products, indent=2)
1098
+
1099
+ def get_low_stock_products(
1100
+ self,
1101
+ threshold: int = 10,
1102
+ max_results: int = 50,
1103
+ ) -> str:
1104
+ """Get products that are running low on stock.
1105
+
1106
+ Args:
1107
+ threshold: Inventory level below which a product is considered low stock (default 10).
1108
+ max_results: Maximum number of products to return (default 50).
1109
+
1110
+ Returns:
1111
+ JSON string containing low stock products sorted by inventory level.
1112
+ """
1113
+ log_debug(f"Finding low stock products: threshold={threshold}")
1114
+
1115
+ query = """
1116
+ query {{
1117
+ products(first: 250, query: "status:ACTIVE") {{
1118
+ edges {{
1119
+ node {{
1120
+ id
1121
+ title
1122
+ totalInventory
1123
+ variants(first: 50) {{
1124
+ edges {{
1125
+ node {{
1126
+ id
1127
+ title
1128
+ sku
1129
+ inventoryQuantity
1130
+ }}
1131
+ }}
1132
+ }}
1133
+ }}
1134
+ }}
1135
+ }}
1136
+ }}
1137
+ """
1138
+
1139
+ result = self._make_graphql_request(query)
1140
+
1141
+ if "error" in result:
1142
+ return json.dumps(result, indent=2)
1143
+
1144
+ low_stock = []
1145
+ for edge in result.get("products", {}).get("edges", []):
1146
+ node = edge["node"]
1147
+ total_inv = node.get("totalInventory", 0)
1148
+
1149
+ if total_inv is not None and total_inv <= threshold:
1150
+ low_stock_variants = [
1151
+ {
1152
+ "id": v["node"]["id"],
1153
+ "title": v["node"]["title"],
1154
+ "sku": v["node"].get("sku"),
1155
+ "inventory_quantity": v["node"].get("inventoryQuantity"),
1156
+ }
1157
+ for v in node.get("variants", {}).get("edges", [])
1158
+ if v["node"].get("inventoryQuantity") is not None and v["node"]["inventoryQuantity"] <= threshold
1159
+ ]
1160
+
1161
+ if low_stock_variants:
1162
+ low_stock.append(
1163
+ {
1164
+ "id": node["id"],
1165
+ "title": node["title"],
1166
+ "total_inventory": total_inv,
1167
+ "low_stock_variants": low_stock_variants,
1168
+ }
1169
+ )
1170
+
1171
+ # Sort by total inventory (lowest first)
1172
+ low_stock.sort(key=lambda x: x["total_inventory"])
1173
+
1174
+ return json.dumps(low_stock[:max_results], indent=2)
1175
+
1176
+ def get_sales_trends(
1177
+ self,
1178
+ created_after: Optional[str] = None,
1179
+ created_before: Optional[str] = None,
1180
+ compare_previous_period: bool = True,
1181
+ ) -> str:
1182
+ """Get sales trends comparing current period to previous period.
1183
+
1184
+ Args:
1185
+ created_after: Start date for analysis period (YYYY-MM-DD format, optional).
1186
+ created_before: End date for analysis period (YYYY-MM-DD format, optional).
1187
+ compare_previous_period: Whether to compare with previous period of same length (default True).
1188
+
1189
+ Returns:
1190
+ JSON string containing current period metrics and comparison with previous period.
1191
+ """
1192
+ log_debug(f"Calculating sales trends: created_after={created_after}, created_before={created_before}")
1193
+
1194
+ # Use provided dates or default to last 30 days
1195
+ now = datetime.now()
1196
+ if created_before:
1197
+ current_end_dt = datetime.strptime(created_before, "%Y-%m-%d")
1198
+ else:
1199
+ current_end_dt = now
1200
+ current_end = current_end_dt.strftime("%Y-%m-%d")
1201
+
1202
+ if created_after:
1203
+ current_start_dt = datetime.strptime(created_after, "%Y-%m-%d")
1204
+ else:
1205
+ current_start_dt = current_end_dt - timedelta(days=30)
1206
+ current_start = current_start_dt.strftime("%Y-%m-%d")
1207
+
1208
+ # Calculate previous period of same length
1209
+ period_days = (current_end_dt - current_start_dt).days
1210
+ previous_end_dt = current_start_dt - timedelta(days=1)
1211
+ previous_start_dt = previous_end_dt - timedelta(days=period_days)
1212
+ previous_start = previous_start_dt.strftime("%Y-%m-%d")
1213
+ previous_end = previous_end_dt.strftime("%Y-%m-%d")
1214
+
1215
+ def fetch_period_data(start: str, end: str) -> Dict[str, Any]:
1216
+ query = f"""
1217
+ query {{
1218
+ orders(first: 250, query: "created_at:>={start} AND created_at:<{end} AND financial_status:paid") {{
1219
+ edges {{
1220
+ node {{
1221
+ totalPriceSet {{
1222
+ shopMoney {{
1223
+ amount
1224
+ currencyCode
1225
+ }}
1226
+ }}
1227
+ lineItems(first: 100) {{
1228
+ edges {{
1229
+ node {{
1230
+ quantity
1231
+ }}
1232
+ }}
1233
+ }}
1234
+ }}
1235
+ }}
1236
+ }}
1237
+ }}
1238
+ """
1239
+ result = self._make_graphql_request(query)
1240
+ if "error" in result:
1241
+ return {"error": result["error"]}
1242
+
1243
+ orders = result.get("orders", {}).get("edges", [])
1244
+ total_revenue = sum(
1245
+ float(o["node"].get("totalPriceSet", {}).get("shopMoney", {}).get("amount", 0)) for o in orders
1246
+ )
1247
+ total_items = sum(
1248
+ sum(item["node"]["quantity"] for item in o["node"].get("lineItems", {}).get("edges", []))
1249
+ for o in orders
1250
+ )
1251
+ currency = (
1252
+ orders[0]["node"].get("totalPriceSet", {}).get("shopMoney", {}).get("currencyCode") if orders else None
1253
+ )
1254
+
1255
+ return {
1256
+ "total_orders": len(orders),
1257
+ "total_revenue": round(total_revenue, 2),
1258
+ "total_items_sold": total_items,
1259
+ "average_order_value": round(total_revenue / len(orders), 2) if orders else 0,
1260
+ "currency": currency,
1261
+ }
1262
+
1263
+ current_data = fetch_period_data(current_start, current_end)
1264
+ if "error" in current_data:
1265
+ return json.dumps(current_data, indent=2)
1266
+
1267
+ result = {
1268
+ "current_period": {
1269
+ "start": current_start,
1270
+ "end": current_end,
1271
+ **current_data,
1272
+ }
1273
+ }
1274
+
1275
+ if compare_previous_period:
1276
+ previous_data = fetch_period_data(previous_start, previous_end)
1277
+ if "error" not in previous_data:
1278
+ result["previous_period"] = {
1279
+ "start": previous_start,
1280
+ "end": previous_end,
1281
+ **previous_data,
1282
+ }
1283
+
1284
+ # Calculate changes
1285
+ if previous_data["total_revenue"] > 0:
1286
+ revenue_change = (
1287
+ (current_data["total_revenue"] - previous_data["total_revenue"])
1288
+ / previous_data["total_revenue"]
1289
+ ) * 100
1290
+ else:
1291
+ revenue_change = 100 if current_data["total_revenue"] > 0 else 0
1292
+
1293
+ if previous_data["total_orders"] > 0:
1294
+ orders_change = (
1295
+ (current_data["total_orders"] - previous_data["total_orders"]) / previous_data["total_orders"]
1296
+ ) * 100
1297
+ else:
1298
+ orders_change = 100 if current_data["total_orders"] > 0 else 0
1299
+
1300
+ result["comparison"] = {
1301
+ "revenue_change_percent": round(revenue_change, 2),
1302
+ "orders_change_percent": round(orders_change, 2),
1303
+ "revenue_trend": "up" if revenue_change > 0 else ("down" if revenue_change < 0 else "flat"),
1304
+ "orders_trend": "up" if orders_change > 0 else ("down" if orders_change < 0 else "flat"),
1305
+ }
1306
+
1307
+ return json.dumps(result, indent=2)
1308
+
1309
+ def get_average_order_value(
1310
+ self,
1311
+ group_by: str = "day",
1312
+ created_after: Optional[str] = None,
1313
+ created_before: Optional[str] = None,
1314
+ ) -> str:
1315
+ """Get average order value over time.
1316
+
1317
+ Args:
1318
+ group_by: How to group data - 'day', 'week', or 'month' (default 'day').
1319
+ created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
1320
+ created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
1321
+
1322
+ Returns:
1323
+ JSON string containing average order value trends.
1324
+ """
1325
+ log_debug(
1326
+ f"Calculating AOV: group_by={group_by}, created_after={created_after}, created_before={created_before}"
1327
+ )
1328
+
1329
+ query_parts = ["financial_status:paid"]
1330
+ if created_after:
1331
+ query_parts.append(f"created_at:>={created_after}")
1332
+ if created_before:
1333
+ query_parts.append(f"created_at:<={created_before}")
1334
+ query_filter = " AND ".join(query_parts)
1335
+
1336
+ query = f"""
1337
+ query {{
1338
+ orders(first: 250, query: "{query_filter}", sortKey: CREATED_AT) {{
1339
+ edges {{
1340
+ node {{
1341
+ createdAt
1342
+ totalPriceSet {{
1343
+ shopMoney {{
1344
+ amount
1345
+ currencyCode
1346
+ }}
1347
+ }}
1348
+ }}
1349
+ }}
1350
+ }}
1351
+ }}
1352
+ """
1353
+
1354
+ result = self._make_graphql_request(query)
1355
+
1356
+ if "error" in result:
1357
+ return json.dumps(result, indent=2)
1358
+
1359
+ orders = result.get("orders", {}).get("edges", [])
1360
+
1361
+ if not orders:
1362
+ return json.dumps({"message": "No orders found in the specified period"}, indent=2)
1363
+
1364
+ # Group orders
1365
+ grouped: Dict[str, List[float]] = {}
1366
+ currency = None
1367
+
1368
+ for order_edge in orders:
1369
+ order = order_edge["node"]
1370
+ created_at = order["createdAt"][:10]
1371
+ amount = float(order.get("totalPriceSet", {}).get("shopMoney", {}).get("amount", 0))
1372
+ currency = order.get("totalPriceSet", {}).get("shopMoney", {}).get("currencyCode")
1373
+
1374
+ if group_by == "week":
1375
+ # Get ISO week
1376
+ date_obj = datetime.strptime(created_at, "%Y-%m-%d")
1377
+ key = f"{date_obj.isocalendar()[0]}-W{date_obj.isocalendar()[1]:02d}"
1378
+ elif group_by == "month":
1379
+ key = created_at[:7] # YYYY-MM
1380
+ else:
1381
+ key = created_at
1382
+
1383
+ if key not in grouped:
1384
+ grouped[key] = []
1385
+ grouped[key].append(amount)
1386
+
1387
+ # Calculate averages
1388
+ aov_data = [
1389
+ {
1390
+ "period": key,
1391
+ "order_count": len(values),
1392
+ "total_revenue": round(sum(values), 2),
1393
+ "average_order_value": round(sum(values) / len(values), 2),
1394
+ }
1395
+ for key, values in sorted(grouped.items())
1396
+ ]
1397
+
1398
+ overall_avg = sum(o["total_revenue"] for o in aov_data) / sum(o["order_count"] for o in aov_data) # type: ignore
1399
+
1400
+ response = {
1401
+ "period": {
1402
+ "created_after": created_after,
1403
+ "created_before": created_before,
1404
+ },
1405
+ "group_by": group_by,
1406
+ "overall_average_order_value": round(overall_avg, 2),
1407
+ "currency": currency,
1408
+ "breakdown": aov_data,
1409
+ }
1410
+
1411
+ return json.dumps(response, indent=2)
1412
+
1413
+ def get_repeat_customers(
1414
+ self,
1415
+ min_orders: int = 2,
1416
+ limit: int = 50,
1417
+ created_after: Optional[str] = None,
1418
+ created_before: Optional[str] = None,
1419
+ ) -> str:
1420
+ """Find customers who have made multiple purchases.
1421
+
1422
+ Args:
1423
+ min_orders: Minimum number of orders to qualify as repeat customer (default 2).
1424
+ limit: Maximum number of customers to return (default 50).
1425
+ created_after: Only include orders created after this date (YYYY-MM-DD format, optional).
1426
+ created_before: Only include orders created before this date (YYYY-MM-DD format, optional).
1427
+
1428
+ Returns:
1429
+ JSON string containing repeat customers with their order counts and total spend.
1430
+ """
1431
+ log_debug(
1432
+ f"Finding repeat customers: min_orders={min_orders}, created_after={created_after}, created_before={created_before}"
1433
+ )
1434
+
1435
+ query_parts = ["financial_status:paid"]
1436
+ if created_after:
1437
+ query_parts.append(f"created_at:>={created_after}")
1438
+ if created_before:
1439
+ query_parts.append(f"created_at:<={created_before}")
1440
+ query_filter = " AND ".join(query_parts)
1441
+
1442
+ query = f"""
1443
+ query {{
1444
+ orders(first: 250, query: "{query_filter}") {{
1445
+ edges {{
1446
+ node {{
1447
+ customer {{
1448
+ id
1449
+ email
1450
+ firstName
1451
+ lastName
1452
+ numberOfOrders
1453
+ amountSpent {{
1454
+ amount
1455
+ currencyCode
1456
+ }}
1457
+ }}
1458
+ totalPriceSet {{
1459
+ shopMoney {{
1460
+ amount
1461
+ }}
1462
+ }}
1463
+ }}
1464
+ }}
1465
+ }}
1466
+ }}
1467
+ """
1468
+
1469
+ result = self._make_graphql_request(query)
1470
+
1471
+ if "error" in result:
1472
+ return json.dumps(result, indent=2)
1473
+
1474
+ # Aggregate by customer
1475
+ customer_data: Dict[str, Dict[str, Any]] = {}
1476
+
1477
+ for order_edge in result.get("orders", {}).get("edges", []):
1478
+ customer = order_edge["node"].get("customer")
1479
+ if not customer or not customer.get("id"):
1480
+ continue
1481
+
1482
+ customer_id = customer["id"]
1483
+ order_amount = float(order_edge["node"].get("totalPriceSet", {}).get("shopMoney", {}).get("amount", 0))
1484
+
1485
+ if customer_id not in customer_data:
1486
+ customer_data[customer_id] = {
1487
+ "id": customer_id,
1488
+ "email": customer.get("email"),
1489
+ "name": f"{customer.get('firstName', '')} {customer.get('lastName', '')}".strip(),
1490
+ "total_orders_all_time": customer.get("numberOfOrders"),
1491
+ "total_spent_all_time": customer.get("amountSpent", {}).get("amount"),
1492
+ "currency": customer.get("amountSpent", {}).get("currencyCode"),
1493
+ "orders_in_period": 0,
1494
+ "spent_in_period": 0.0,
1495
+ }
1496
+
1497
+ customer_data[customer_id]["orders_in_period"] += 1
1498
+ customer_data[customer_id]["spent_in_period"] += order_amount
1499
+
1500
+ # Filter repeat customers and sort
1501
+ repeat_customers = [
1502
+ {**c, "spent_in_period": round(c["spent_in_period"], 2)}
1503
+ for c in customer_data.values()
1504
+ if c["orders_in_period"] >= min_orders
1505
+ ]
1506
+
1507
+ repeat_customers.sort(key=lambda x: x["orders_in_period"], reverse=True)
1508
+
1509
+ response = {
1510
+ "period": {
1511
+ "created_after": created_after,
1512
+ "created_before": created_before,
1513
+ },
1514
+ "min_orders_threshold": min_orders,
1515
+ "repeat_customer_count": len(repeat_customers),
1516
+ "customers": repeat_customers[:limit],
1517
+ }
1518
+
1519
+ return json.dumps(response, indent=2)