From 2f829625563cb5212884d5dcdd1b6ba0e6b1b945 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Mon, 13 May 2024 19:24:13 -0400
Subject: [PATCH 01/25] Add search strategy parameter

---
 action.yml    |  7 +++++++
 entrypoint.py | 11 +++++++++--
 2 files changed, 16 insertions(+), 2 deletions(-)

diff --git a/action.yml b/action.yml
index 835b247..5b8444a 100644
--- a/action.yml
+++ b/action.yml
@@ -19,6 +19,11 @@ inputs:
     description: >
       The name of the quantity column in the BOM file. Defaults to 'Quantity'.
     default: Quantity
+  search_strategy:
+    description: >
+      The Cofactr search strategy. Can be: default, mpn_sku_mfr, mpn_exact, mpn_exact_mfr. Defaults
+      to 'default'.
+    default: default
   output_file:
     description: >
       The path to the output file. Defaults to stdout, i.e. printing to the
@@ -40,6 +45,8 @@ runs:
     - ${{ inputs.bom_part_number_column }}
     - "--bom-quantity-column"
     - ${{ inputs.bom_quantity_column }}
+    - "--search-strategy"
+    - ${{ inputs.search_strategy }}
     - "--output-file"
     - ${{ inputs.output_file }}
     - ${{ inputs.bom_file }}
diff --git a/entrypoint.py b/entrypoint.py
index 26952b3..bbaec37 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -14,7 +14,7 @@ import sys
 import requests
 
 
-def fetch_price_for_part(part_number: str) -> dict[int, float]:
+def fetch_price_for_part(part_number: str, search_strategy: str) -> dict[int, float]:
     """
     Get the price of a component per n units.
 
@@ -61,6 +61,7 @@ def fetch_price_for_part(part_number: str) -> dict[int, float]:
         },
         params={
             "q": part_number,
+            "search_strategy": search_strategy,
             "schema": "product-offers-v0",
             "external": "true",
             "limit": "1",
@@ -114,6 +115,12 @@ if __name__ == "__main__":
         help="The name of the quantity column in the BOM file. Defaults to '%(default)s'.",
         default="Quantity",
     )
+    parser.add_argument(
+        "--search-strategy",
+        help="The Cofactr search strategy. Can be: default, mpn_sku_mfr, mpn_exact, mpn_exact_mfr. "
+        + "Defaults to '%(default)s'.",
+        default="default",
+    )
     parser.add_argument(
         "--output-file",
         help="The path to the output file. Defaults to stdout, i.e. printing to the console.",
@@ -140,7 +147,7 @@ if __name__ == "__main__":
 
     for part in parts:
         part_number = part[part_number_column]
-        prices = fetch_price_for_part(part_number)
+        prices = fetch_price_for_part(part_number, args.search_strategy)
         if prices and len(prices) > 0:
             prices_for_parts[part_number] = prices
 

From 76ec2e2e8f417d7dbf7c5e4f6dd7e7d4588a53c5 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Mon, 13 May 2024 19:31:38 -0400
Subject: [PATCH 02/25] Add manufacturer to API query

---
 action.yml    |  6 ++++++
 entrypoint.py | 20 +++++++++++++++++---
 2 files changed, 23 insertions(+), 3 deletions(-)

diff --git a/action.yml b/action.yml
index 5b8444a..c57ca94 100644
--- a/action.yml
+++ b/action.yml
@@ -15,6 +15,10 @@ inputs:
       The name of the part number column in the BOM file. Defaults to 'Part
       Number'.
     default: Part Number
+  bom_manufacturer_column:
+    description: >
+      The name of the manufacturer column in the BOM file. Defaults to 'Manufacturer'.
+    default: Manufacturer
   bom_quantity_column:
     description: >
       The name of the quantity column in the BOM file. Defaults to 'Quantity'.
@@ -43,6 +47,8 @@ runs:
     - "${{ inputs.quantities }}"
     - "--bom-part-number-column"
     - ${{ inputs.bom_part_number_column }}
+    - "--bom-manufacturer-column"
+    - ${{ inputs.bom_manufacturer_column }}
     - "--bom-quantity-column"
     - ${{ inputs.bom_quantity_column }}
     - "--search-strategy"
diff --git a/entrypoint.py b/entrypoint.py
index bbaec37..ef6cc00 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -11,10 +11,13 @@ from contextlib import ExitStack
 import csv
 import os
 import sys
+
 import requests
 
 
-def fetch_price_for_part(part_number: str, search_strategy: str) -> dict[int, float]:
+def fetch_price_for_part(
+    part_number: str, manufacturer: str, search_strategy: str
+) -> dict[int, float]:
     """
     Get the price of a component per n units.
 
@@ -53,6 +56,10 @@ def fetch_price_for_part(part_number: str, search_strategy: str) -> dict[int, fl
             "Please set the COFACTR_API_KEY and COFACTR_CLIENT_ID environment variables"
         )
 
+    query = part_number
+    if manufacturer:
+        query += f" {manufacturer}"
+
     search_response = requests.get(
         "https://graph.cofactr.com/products/",
         headers={
@@ -60,7 +67,7 @@ def fetch_price_for_part(part_number: str, search_strategy: str) -> dict[int, fl
             "X-CLIENT-ID": client_id,
         },
         params={
-            "q": part_number,
+            "q": query,
             "search_strategy": search_strategy,
             "schema": "product-offers-v0",
             "external": "true",
@@ -110,6 +117,11 @@ if __name__ == "__main__":
         help="The name of the part number column in the BOM file. Defaults to '%(default)s'.",
         default="Part Number",
     )
+    parser.add_argument(
+        "--bom-manufacturer-column",
+        help="The name of the manufacturer column in the BOM file. Defaults to '%(default)s'.",
+        default="Manufacturer",
+    )
     parser.add_argument(
         "--bom-quantity-column",
         help="The name of the quantity column in the BOM file. Defaults to '%(default)s'.",
@@ -131,6 +143,7 @@ if __name__ == "__main__":
     quantities = [int(quantity) for quantity in args.quantities.split(",")]
 
     part_number_column = args.bom_part_number_column
+    manufacturer_column = args.bom_manufacturer_column
     quantity_column = args.bom_quantity_column
 
     with open(args.bom_file, "r") as bom_file:
@@ -147,7 +160,8 @@ if __name__ == "__main__":
 
     for part in parts:
         part_number = part[part_number_column]
-        prices = fetch_price_for_part(part_number, args.search_strategy)
+        manufacturer = part[manufacturer_column]
+        prices = fetch_price_for_part(part_number, manufacturer, args.search_strategy)
         if prices and len(prices) > 0:
             prices_for_parts[part_number] = prices
 

From ea23ac66b56c6bd066c38935c15e70ff281ea34d Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Mon, 13 May 2024 19:32:54 -0400
Subject: [PATCH 03/25] Change default search strategy to be less fuzzy

---
 action.yml    | 4 ++--
 entrypoint.py | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/action.yml b/action.yml
index c57ca94..5b45700 100644
--- a/action.yml
+++ b/action.yml
@@ -26,8 +26,8 @@ inputs:
   search_strategy:
     description: >
       The Cofactr search strategy. Can be: default, mpn_sku_mfr, mpn_exact, mpn_exact_mfr. Defaults
-      to 'default'.
-    default: default
+      to 'mpn_sku_mfr'.
+    default: mpn_sku_mfr
   output_file:
     description: >
       The path to the output file. Defaults to stdout, i.e. printing to the
diff --git a/entrypoint.py b/entrypoint.py
index ef6cc00..242698e 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -131,7 +131,7 @@ if __name__ == "__main__":
         "--search-strategy",
         help="The Cofactr search strategy. Can be: default, mpn_sku_mfr, mpn_exact, mpn_exact_mfr. "
         + "Defaults to '%(default)s'.",
-        default="default",
+        default="mpn_sku_mfr",
     )
     parser.add_argument(
         "--output-file",

From 177376dbf88682f5c4c6a60ac0a940b85aefe95d Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Wed, 15 May 2024 16:57:06 -0400
Subject: [PATCH 04/25] Add manufacturer to output and logging

---
 entrypoint.py | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/entrypoint.py b/entrypoint.py
index 242698e..3a2ff77 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -77,7 +77,7 @@ def fetch_price_for_part(
 
     if search_response.status_code != 200:
         print(
-            f"Warning: Received status code {search_response.status_code} for {part_number}",
+            f"Warning: Received status code {search_response.status_code} for {part_number} {manufacturer}",
             file=sys.stderr,
         )
         return {}
@@ -87,7 +87,7 @@ def fetch_price_for_part(
         reference_prices = search_results.get("data", [])[0].get("reference_prices")
     except IndexError:
         print(
-            f"Warning: No results found for {part_number}",
+            f"Warning: No results found for {part_number} {manufacturer}",
             file=sys.stderr,
         )
         return {}
@@ -173,6 +173,7 @@ if __name__ == "__main__":
 
     headers = [
         "Part Number",
+        "Manufacturer",
         "Quantity",
     ]
 
@@ -185,9 +186,10 @@ if __name__ == "__main__":
 
     for part in parts:
         part_number = part[part_number_column]
+        manufacturer = part[manufacturer_column]
         part_quantity = int(part[quantity_column])
 
-        current_row = [part_number, part_quantity]
+        current_row = [part_number, manufacturer, part_quantity]
 
         for quantity in quantities:
             try:
@@ -216,7 +218,7 @@ if __name__ == "__main__":
         writer.writerow(headers)
         writer.writerows(rows)
 
-        totals_row = ["Totals", None]
+        totals_row = ["Totals", None, None]
         for quantity in quantities:
             totals_row.append(None)
             totals_row.append(str(totals[quantity]))

From 27efa7113b42cfe2a355444e38d37cc772778b60 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Wed, 15 May 2024 16:59:14 -0400
Subject: [PATCH 05/25] Add BOM input parameters to readme

---
 README.md | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/README.md b/README.md
index cd3f63f..ccc1ab3 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,9 @@ Add the following step to your actions:
   uses: https://hub.allspice.io/Actions/cofactr-cogs@v1
   with:
     bom_file: bom.csv
+    bom_part_number_column: Part Number
+    bom_manufacturer_column: Manufacturer
+    bom_quantity_column: Quantity
     quantities: "1,10,100,1000"
     client_id: YOUR_COFACTR_CLIENT_ID
     api_key: ${{ secrets.COFACTR_API_KEY }}

From e38799307b58935619e8b6089901eb90a6507580 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Mon, 20 May 2024 17:54:29 -0400
Subject: [PATCH 06/25] Change to not use manufacturer in query when search
 strategy is mpn_exact

---
 entrypoint.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/entrypoint.py b/entrypoint.py
index 3a2ff77..9bbbe4d 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -57,7 +57,7 @@ def fetch_price_for_part(
         )
 
     query = part_number
-    if manufacturer:
+    if search_strategy != "mpn_exact" and manufacturer:
         query += f" {manufacturer}"
 
     search_response = requests.get(

From a5a142cba4d5df4f7b01a53dfb31e467417f8835 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Mon, 20 May 2024 17:56:02 -0400
Subject: [PATCH 07/25] Add handling of no manufacturer column

Now you can set bom_manufacturer_column to empty string so that lookup
of the manufacturer field doesn't fail.  This is useful when using
search strategy mpn_exact.
---
 entrypoint.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/entrypoint.py b/entrypoint.py
index 9bbbe4d..e09811a 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -158,9 +158,11 @@ if __name__ == "__main__":
 
     prices_for_parts = {}
 
+    use_mfr = bool(manufacturer_column)
+
     for part in parts:
         part_number = part[part_number_column]
-        manufacturer = part[manufacturer_column]
+        manufacturer = part[manufacturer_column] if use_mfr else ""
         prices = fetch_price_for_part(part_number, manufacturer, args.search_strategy)
         if prices and len(prices) > 0:
             prices_for_parts[part_number] = prices
@@ -186,7 +188,7 @@ if __name__ == "__main__":
 
     for part in parts:
         part_number = part[part_number_column]
-        manufacturer = part[manufacturer_column]
+        manufacturer = part[manufacturer_column] if use_mfr else ""
         part_quantity = int(part[quantity_column])
 
         current_row = [part_number, manufacturer, part_quantity]

From 8b8abb2a9c1e3b734ab7c817e772c4c3f72a2c37 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Mon, 20 May 2024 18:00:00 -0400
Subject: [PATCH 08/25] Add more to description of search strategy

---
 action.yml | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/action.yml b/action.yml
index 5b45700..6adb897 100644
--- a/action.yml
+++ b/action.yml
@@ -25,8 +25,9 @@ inputs:
     default: Quantity
   search_strategy:
     description: >
-      The Cofactr search strategy. Can be: default, mpn_sku_mfr, mpn_exact, mpn_exact_mfr. Defaults
-      to 'mpn_sku_mfr'.
+      The Cofactr search strategy. Can be: "default" (uses mpn), "mpn_sku_mfr",
+      "mpn_exact", "mpn_exact_mfr". Defaults to "mpn_sku_mfr".  See Cofactr API
+      documentation for more information on search strategies.
     default: mpn_sku_mfr
   output_file:
     description: >

From 5503458e3e8cdb56eb9c732e59701d3fb6ca2514 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Mon, 20 May 2024 18:15:36 -0400
Subject: [PATCH 09/25] Add check to require manufacturer column when search
 strategy would use it

---
 entrypoint.py | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/entrypoint.py b/entrypoint.py
index e09811a..0602106 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -57,7 +57,7 @@ def fetch_price_for_part(
         )
 
     query = part_number
-    if search_strategy != "mpn_exact" and manufacturer:
+    if query_needs_manufacturer(search_strategy) and manufacturer:
         query += f" {manufacturer}"
 
     search_response = requests.get(
@@ -97,6 +97,10 @@ def fetch_price_for_part(
     return prices
 
 
+def query_needs_manufacturer(search_strategy: str) -> bool:
+    return search_strategy != "mpn_exact"
+
+
 if __name__ == "__main__":
     parser = ArgumentParser()
 
@@ -159,6 +163,10 @@ if __name__ == "__main__":
     prices_for_parts = {}
 
     use_mfr = bool(manufacturer_column)
+    if not use_mfr and query_needs_manufacturer(args.search_strategy):
+        raise ValueError(
+            "Search strategy requires manufacturer, but no BOM manufacturer column was provided"
+        )
 
     for part in parts:
         part_number = part[part_number_column]

From 7b299ff7e6cd2c3005168cfe2edf2a5b0621b129 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Tue, 21 May 2024 14:34:09 -0400
Subject: [PATCH 10/25] Remove default value for BOM manufacturer column name

---
 action.yml    | 5 +++--
 entrypoint.py | 5 +++--
 2 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/action.yml b/action.yml
index 6adb897..307b679 100644
--- a/action.yml
+++ b/action.yml
@@ -17,8 +17,9 @@ inputs:
     default: Part Number
   bom_manufacturer_column:
     description: >
-      The name of the manufacturer column in the BOM file. Defaults to 'Manufacturer'.
-    default: Manufacturer
+      The name of the manufacturer column in the BOM file.  Defaults to ''.  If
+      you use a search strategy that uses manufacturer, you must provide this.
+    default: ''
   bom_quantity_column:
     description: >
       The name of the quantity column in the BOM file. Defaults to 'Quantity'.
diff --git a/entrypoint.py b/entrypoint.py
index 0602106..0fbb271 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -123,8 +123,9 @@ if __name__ == "__main__":
     )
     parser.add_argument(
         "--bom-manufacturer-column",
-        help="The name of the manufacturer column in the BOM file. Defaults to '%(default)s'.",
-        default="Manufacturer",
+        help="The name of the manufacturer column in the BOM file.  Defaults to '%(default)s'.  If "
+        + "you use a search strategy that uses manufacturer, you must provide this.",
+        default="",
     )
     parser.add_argument(
         "--bom-quantity-column",

From e91223ecff27cf80abfb2f37bdf73eed1b83ae6a Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Thu, 23 May 2024 12:26:42 -0400
Subject: [PATCH 11/25] Remove documentation for search strategies that aren't
 recommend by Cofactr

---
 action.yml    | 6 +++---
 entrypoint.py | 5 +++--
 2 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/action.yml b/action.yml
index 307b679..a943515 100644
--- a/action.yml
+++ b/action.yml
@@ -26,9 +26,9 @@ inputs:
     default: Quantity
   search_strategy:
     description: >
-      The Cofactr search strategy. Can be: "default" (uses mpn), "mpn_sku_mfr",
-      "mpn_exact", "mpn_exact_mfr". Defaults to "mpn_sku_mfr".  See Cofactr API
-      documentation for more information on search strategies.
+      The Cofactr search strategy. Can be: "default" (uses mpn), "mpn_sku_mfr".
+      Defaults to "mpn_sku_mfr".  See Cofactr API documentation for more
+      information on search strategies.
     default: mpn_sku_mfr
   output_file:
     description: >
diff --git a/entrypoint.py b/entrypoint.py
index 0fbb271..fea23e2 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -134,8 +134,9 @@ if __name__ == "__main__":
     )
     parser.add_argument(
         "--search-strategy",
-        help="The Cofactr search strategy. Can be: default, mpn_sku_mfr, mpn_exact, mpn_exact_mfr. "
-        + "Defaults to '%(default)s'.",
+        help="The Cofactr search strategy. Can be: default or mpn_sku_mfr. "
+        + "Defaults to '%(default)s'.  The API also supports mpn_exact and mpn_exact_mfr, "
+        + "but they are not recommended.",
         default="mpn_sku_mfr",
     )
     parser.add_argument(

From 57bb688bd9957699a57c379650e2cf43d2d43f5a Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Thu, 23 May 2024 12:37:32 -0400
Subject: [PATCH 12/25] Change search_strategy of "default" to "fuzzy"

---
 action.yml    |  2 +-
 entrypoint.py | 10 +++++++---
 2 files changed, 8 insertions(+), 4 deletions(-)

diff --git a/action.yml b/action.yml
index a943515..df63a50 100644
--- a/action.yml
+++ b/action.yml
@@ -26,7 +26,7 @@ inputs:
     default: Quantity
   search_strategy:
     description: >
-      The Cofactr search strategy. Can be: "default" (uses mpn), "mpn_sku_mfr".
+      The Cofactr search strategy. Can be: "mpn_sku_mfr" or "fuzzy" (uses mpn).
       Defaults to "mpn_sku_mfr".  See Cofactr API documentation for more
       information on search strategies.
     default: mpn_sku_mfr
diff --git a/entrypoint.py b/entrypoint.py
index fea23e2..ce4c5ac 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -134,7 +134,7 @@ if __name__ == "__main__":
     )
     parser.add_argument(
         "--search-strategy",
-        help="The Cofactr search strategy. Can be: default or mpn_sku_mfr. "
+        help="The Cofactr search strategy. Can be: mpn_sku_mfr or fuzzy (uses mpn). "
         + "Defaults to '%(default)s'.  The API also supports mpn_exact and mpn_exact_mfr, "
         + "but they are not recommended.",
         default="mpn_sku_mfr",
@@ -151,6 +151,10 @@ if __name__ == "__main__":
     part_number_column = args.bom_part_number_column
     manufacturer_column = args.bom_manufacturer_column
     quantity_column = args.bom_quantity_column
+    search_strategy = args.search_strategy
+    if search_strategy == "fuzzy":
+        # Cofactr's default search strategy is designed for search results.
+        search_strategy = "default"
 
     with open(args.bom_file, "r") as bom_file:
         bom_csv = csv.DictReader(bom_file)
@@ -165,7 +169,7 @@ if __name__ == "__main__":
     prices_for_parts = {}
 
     use_mfr = bool(manufacturer_column)
-    if not use_mfr and query_needs_manufacturer(args.search_strategy):
+    if not use_mfr and query_needs_manufacturer(search_strategy):
         raise ValueError(
             "Search strategy requires manufacturer, but no BOM manufacturer column was provided"
         )
@@ -173,7 +177,7 @@ if __name__ == "__main__":
     for part in parts:
         part_number = part[part_number_column]
         manufacturer = part[manufacturer_column] if use_mfr else ""
-        prices = fetch_price_for_part(part_number, manufacturer, args.search_strategy)
+        prices = fetch_price_for_part(part_number, manufacturer, search_strategy)
         if prices and len(prices) > 0:
             prices_for_parts[part_number] = prices
 

From a25f1c4404f5b951453c678bad99ac3ba03cf69d Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Thu, 23 May 2024 12:38:50 -0400
Subject: [PATCH 13/25] Add search strategy to readme

---
 README.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/README.md b/README.md
index ccc1ab3..7d3ebf9 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ Add the following step to your actions:
     bom_manufacturer_column: Manufacturer
     bom_quantity_column: Quantity
     quantities: "1,10,100,1000"
+    search_strategy: mpn_sku_mfr
     client_id: YOUR_COFACTR_CLIENT_ID
     api_key: ${{ secrets.COFACTR_API_KEY }}
     output_file: cogs.csv

From 6ada905fccb131cd5d7c10484215000a2968e888 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Thu, 23 May 2024 12:41:58 -0400
Subject: [PATCH 14/25] Change to main function

---
 entrypoint.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/entrypoint.py b/entrypoint.py
index ce4c5ac..78c7e30 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -101,7 +101,7 @@ def query_needs_manufacturer(search_strategy: str) -> bool:
     return search_strategy != "mpn_exact"
 
 
-if __name__ == "__main__":
+def main() -> None:
     parser = ArgumentParser()
 
     parser.add_argument(
@@ -242,3 +242,7 @@ if __name__ == "__main__":
         writer.writerow(totals_row)
 
     print("Computed COGS", file=sys.stderr)
+
+
+if __name__ == "__main__":
+    main()

From 934d7d57e0d183dc086b9a73c825757bea59fde5 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Thu, 23 May 2024 13:22:13 -0400
Subject: [PATCH 15/25] Add Cofactr ID to output

This also fixes it to treat part numbers with different manufacturer as
separate.
---
 entrypoint.py | 47 ++++++++++++++++++++++++++++++++---------------
 1 file changed, 32 insertions(+), 15 deletions(-)

diff --git a/entrypoint.py b/entrypoint.py
index 78c7e30..cd4c721 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -9,15 +9,22 @@
 from argparse import ArgumentParser
 from contextlib import ExitStack
 import csv
+from dataclasses import dataclass
 import os
 import sys
 
 import requests
 
 
+@dataclass
+class PartPrices:
+    cofactr_id: str
+    prices: dict[int, float]
+
+
 def fetch_price_for_part(
     part_number: str, manufacturer: str, search_strategy: str
-) -> dict[int, float]:
+) -> PartPrices | None:
     """
     Get the price of a component per n units.
 
@@ -47,7 +54,7 @@ def fetch_price_for_part(
     """
 
     if part_number.startswith("NOTAPART"):
-        return {}
+        return None
 
     api_key = os.environ.get("COFACTR_API_KEY")
     client_id = os.environ.get("COFACTR_CLIENT_ID")
@@ -80,7 +87,7 @@ def fetch_price_for_part(
             f"Warning: Received status code {search_response.status_code} for {part_number} {manufacturer}",
             file=sys.stderr,
         )
-        return {}
+        return None
 
     search_results = search_response.json()
     try:
@@ -90,11 +97,14 @@ def fetch_price_for_part(
             f"Warning: No results found for {part_number} {manufacturer}",
             file=sys.stderr,
         )
-        return {}
+        return None
 
     prices = {int(price["quantity"]): float(price["price"]) for price in reference_prices}
 
-    return prices
+    return PartPrices(
+        cofactr_id=search_results["data"][0]["id"],
+        prices=prices,
+    )
 
 
 def query_needs_manufacturer(search_strategy: str) -> bool:
@@ -177,9 +187,9 @@ def main() -> None:
     for part in parts:
         part_number = part[part_number_column]
         manufacturer = part[manufacturer_column] if use_mfr else ""
-        prices = fetch_price_for_part(part_number, manufacturer, search_strategy)
-        if prices and len(prices) > 0:
-            prices_for_parts[part_number] = prices
+        part_prices = fetch_price_for_part(part_number, manufacturer, search_strategy)
+        if part_prices is not None:
+            prices_for_parts[(part_number, manufacturer)] = part_prices
 
     print(f"Found prices for {len(prices_for_parts)} parts", file=sys.stderr)
 
@@ -190,6 +200,7 @@ def main() -> None:
     headers = [
         "Part Number",
         "Manufacturer",
+        "Cofactr ID",
         "Quantity",
     ]
 
@@ -205,20 +216,26 @@ def main() -> None:
         manufacturer = part[manufacturer_column] if use_mfr else ""
         part_quantity = int(part[quantity_column])
 
-        current_row = [part_number, manufacturer, part_quantity]
+        part_prices = prices_for_parts.get((part_number, manufacturer))
+        cofactr_id = part_prices.cofactr_id if part_prices else None
+
+        current_row = [part_number, manufacturer, cofactr_id, part_quantity]
 
         for quantity in quantities:
-            try:
-                prices = prices_for_parts[part_number]
+            if part_prices is not None:
                 largest_breakpoint_less_than_qty = max(
-                    [breakpoint for breakpoint in prices.keys() if breakpoint <= quantity]
+                    [
+                        breakpoint
+                        for breakpoint in part_prices.prices.keys()
+                        if breakpoint <= quantity
+                    ]
                 )
-                price_at_breakpoint = prices[largest_breakpoint_less_than_qty]
+                price_at_breakpoint = part_prices.prices[largest_breakpoint_less_than_qty]
                 current_row.append(price_at_breakpoint)
                 total_for_part_at_quantity = price_at_breakpoint * part_quantity
                 current_row.append(total_for_part_at_quantity)
                 totals[quantity] += total_for_part_at_quantity
-            except (ValueError, KeyError):
+            else:
                 current_row.append(None)
                 current_row.append(None)
 
@@ -234,7 +251,7 @@ def main() -> None:
         writer.writerow(headers)
         writer.writerows(rows)
 
-        totals_row = ["Totals", None, None]
+        totals_row = ["Totals", None, None, None]
         for quantity in quantities:
             totals_row.append(None)
             totals_row.append(str(totals[quantity]))

From de56ad5b80c14a57a0a2d65dffcba95f9bc67101 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Thu, 23 May 2024 16:39:31 -0400
Subject: [PATCH 16/25] Fix to not include parts with empty prices

---
 entrypoint.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/entrypoint.py b/entrypoint.py
index cd4c721..e3f6404 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -188,7 +188,7 @@ def main() -> None:
         part_number = part[part_number_column]
         manufacturer = part[manufacturer_column] if use_mfr else ""
         part_prices = fetch_price_for_part(part_number, manufacturer, search_strategy)
-        if part_prices is not None:
+        if part_prices is not None and len(part_prices.prices) > 0:
             prices_for_parts[(part_number, manufacturer)] = part_prices
 
     print(f"Found prices for {len(prices_for_parts)} parts", file=sys.stderr)

From f82285d6904959c0e79c424581252c2cc1a7f380 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Thu, 23 May 2024 16:43:14 -0400
Subject: [PATCH 17/25] Add reference to action parameter name in error message

---
 entrypoint.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/entrypoint.py b/entrypoint.py
index e3f6404..d898353 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -181,7 +181,7 @@ def main() -> None:
     use_mfr = bool(manufacturer_column)
     if not use_mfr and query_needs_manufacturer(search_strategy):
         raise ValueError(
-            "Search strategy requires manufacturer, but no BOM manufacturer column was provided"
+            "Search strategy requires manufacturer, but no BOM manufacturer column was provided.  Please set bom_manufacturer_column."
         )
 
     for part in parts:

From 5730c56703010f3c21604e5c138701be4883488c Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Thu, 23 May 2024 17:22:43 -0400
Subject: [PATCH 18/25] Add debug printing

---
 action.yml    |  5 +++++
 entrypoint.py | 12 ++++++++++++
 2 files changed, 17 insertions(+)

diff --git a/action.yml b/action.yml
index df63a50..0d8bbd7 100644
--- a/action.yml
+++ b/action.yml
@@ -41,6 +41,9 @@ inputs:
   client_id:
     description: "Cofactr Client ID"
     required: true
+  log_level:
+    description: Set log level for debugging
+    default: info
 runs:
   using: "docker"
   image: "Dockerfile"
@@ -57,6 +60,8 @@ runs:
     - ${{ inputs.search_strategy }}
     - "--output-file"
     - ${{ inputs.output_file }}
+    - "--log-level"
+    - ${{ inputs.log_level }}
     - ${{ inputs.bom_file }}
   env:
     ALLSPICE_AUTH_TOKEN: ${{ github.token }}
diff --git a/entrypoint.py b/entrypoint.py
index d898353..e2b575e 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -153,6 +153,11 @@ def main() -> None:
         "--output-file",
         help="The path to the output file. Defaults to stdout, i.e. printing to the console.",
     )
+    parser.add_argument(
+        "--log-level",
+        help="The log level to use.  Defaults to '%(default)s'.",
+        default="info",
+    )
 
     args = parser.parse_args()
 
@@ -184,6 +189,13 @@ def main() -> None:
             "Search strategy requires manufacturer, but no BOM manufacturer column was provided.  Please set bom_manufacturer_column."
         )
 
+    if args.log_level.lower() == "debug":
+        print(f"Using part number column: {part_number_column!r}", file=sys.stderr)
+        print(f"Using manufacturer column: {manufacturer_column!r}", file=sys.stderr)
+        print(f"Using use_mfr: {use_mfr!r}", file=sys.stderr)
+        print(f"Using quantity column: {quantity_column!r}", file=sys.stderr)
+        print(f"Using search strategy: {search_strategy!r}", file=sys.stderr)
+
     for part in parts:
         part_number = part[part_number_column]
         manufacturer = part[manufacturer_column] if use_mfr else ""

From 4bc3eda06070ee6d8e700dae0e690eeaf4db6557 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Thu, 23 May 2024 18:13:02 -0400
Subject: [PATCH 19/25] Fix to work when no price breakpionts for a quantity

---
 entrypoint.py | 18 ++++++++++--------
 1 file changed, 10 insertions(+), 8 deletions(-)

diff --git a/entrypoint.py b/entrypoint.py
index e2b575e..03cb100 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -234,14 +234,9 @@ def main() -> None:
         current_row = [part_number, manufacturer, cofactr_id, part_quantity]
 
         for quantity in quantities:
-            if part_prices is not None:
-                largest_breakpoint_less_than_qty = max(
-                    [
-                        breakpoint
-                        for breakpoint in part_prices.prices.keys()
-                        if breakpoint <= quantity
-                    ]
-                )
+            breakpoints = price_breakpoints(part_prices, quantity)
+            if part_prices and breakpoints:
+                largest_breakpoint_less_than_qty = max(breakpoints)
                 price_at_breakpoint = part_prices.prices[largest_breakpoint_less_than_qty]
                 current_row.append(price_at_breakpoint)
                 total_for_part_at_quantity = price_at_breakpoint * part_quantity
@@ -273,5 +268,12 @@ def main() -> None:
     print("Computed COGS", file=sys.stderr)
 
 
+def price_breakpoints(part_prices: PartPrices | None, quantity: int) -> list[int] | None:
+    if part_prices is None:
+        return None
+    breakpoints = [breakpoint for breakpoint in part_prices.prices.keys() if breakpoint <= quantity]
+    return breakpoints if len(breakpoints) > 0 else None
+
+
 if __name__ == "__main__":
     main()

From 389c84789583af5f389a142b20be11db66dbd2be Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Fri, 24 May 2024 12:13:21 -0400
Subject: [PATCH 20/25] Change search strategy to an enum and split code out to
 module

---
 Dockerfile               |   1 +
 cofactr_cogs/__init__.py |   0
 cofactr_cogs/api.py      | 111 +++++++++++++++++++++++++++++++++++++++
 entrypoint.py            | 106 ++-----------------------------------
 4 files changed, 115 insertions(+), 103 deletions(-)
 create mode 100644 cofactr_cogs/__init__.py
 create mode 100644 cofactr_cogs/api.py

diff --git a/Dockerfile b/Dockerfile
index 8f8098f..7931aae 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,7 @@
 FROM python:3.12-bookworm
 
 COPY entrypoint.py /entrypoint.py
+COPY cofactr_cogs /cofactr_cogs
 
 RUN pip install requests
 
diff --git a/cofactr_cogs/__init__.py b/cofactr_cogs/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/cofactr_cogs/api.py b/cofactr_cogs/api.py
new file mode 100644
index 0000000..39cc650
--- /dev/null
+++ b/cofactr_cogs/api.py
@@ -0,0 +1,111 @@
+from dataclasses import dataclass
+from enum import Enum
+import os
+import sys
+
+import requests
+
+
+class SearchStrategy(Enum):
+    MPN_SKU_MFR = "mpn_sku_mfr"
+    FUZZY = "fuzzy"
+
+    def to_query_value(self) -> str:
+        # Cofactr's default search strategy is designed for search results.
+        if self == SearchStrategy.FUZZY:
+            return "default"
+        return self.value
+
+    def query_needs_manufacturer(self) -> bool:
+        return self == SearchStrategy.MPN_SKU_MFR
+
+
+@dataclass
+class PartPrices:
+    cofactr_id: str
+    prices: dict[int, float]
+
+
+def fetch_price_for_part(
+    part_number: str, manufacturer: str, search_strategy: SearchStrategy
+) -> PartPrices | None:
+    """
+    Get the price of a component per n units.
+
+    The return value of this function is a mapping of number of units to the
+    price in dollars per unit if you purchase that many units. For example::
+
+        {
+            1: 0.5,
+            10: 0.45,
+            500: 0.4,
+            100: 0.35,
+        }
+
+    In this case, the price per unit is 0.5, the price per unit if you buy 10
+    or more is 0.45, the price per unit if you buy 50 or more is 0.4, and so on.
+    Your breakpoints can be any positive integer.
+
+    The implementation of this function depends on the API you are using to get
+    pricing data. This is an example implementation that uses the cofactr API,
+    and will not work unless you have a cofactr API key. You will need to
+    replace this function with your own implementation if you use some other
+    API, such as Octopart or TrustedParts. You have access to the `requests`
+    python library to perform HTTP requests.
+
+    :param part_number: A part number by which to search for the component.
+    :returns: A mapping of price breakpoints to the price at that breakpoint.
+    """
+
+    if part_number.startswith("NOTAPART"):
+        return None
+
+    api_key = os.environ.get("COFACTR_API_KEY")
+    client_id = os.environ.get("COFACTR_CLIENT_ID")
+    if api_key is None or client_id is None:
+        raise ValueError(
+            "Please set the COFACTR_API_KEY and COFACTR_CLIENT_ID environment variables"
+        )
+
+    query = part_number
+    if search_strategy.query_needs_manufacturer() and manufacturer:
+        query += f" {manufacturer}"
+
+    search_response = requests.get(
+        "https://graph.cofactr.com/products/",
+        headers={
+            "X-API-KEY": api_key,
+            "X-CLIENT-ID": client_id,
+        },
+        params={
+            "q": query,
+            "search_strategy": search_strategy.to_query_value(),
+            "schema": "product-offers-v0",
+            "external": "true",
+            "limit": "1",
+        },
+    )
+
+    if search_response.status_code != 200:
+        print(
+            f"Warning: Received status code {search_response.status_code} for {part_number} {manufacturer}",
+            file=sys.stderr,
+        )
+        return None
+
+    search_results = search_response.json()
+    try:
+        reference_prices = search_results.get("data", [])[0].get("reference_prices")
+    except IndexError:
+        print(
+            f"Warning: No results found for {part_number} {manufacturer}",
+            file=sys.stderr,
+        )
+        return None
+
+    prices = {int(price["quantity"]): float(price["price"]) for price in reference_prices}
+
+    return PartPrices(
+        cofactr_id=search_results["data"][0]["id"],
+        prices=prices,
+    )
diff --git a/entrypoint.py b/entrypoint.py
index 03cb100..200fe86 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -9,106 +9,9 @@
 from argparse import ArgumentParser
 from contextlib import ExitStack
 import csv
-from dataclasses import dataclass
-import os
 import sys
 
-import requests
-
-
-@dataclass
-class PartPrices:
-    cofactr_id: str
-    prices: dict[int, float]
-
-
-def fetch_price_for_part(
-    part_number: str, manufacturer: str, search_strategy: str
-) -> PartPrices | None:
-    """
-    Get the price of a component per n units.
-
-    The return value of this function is a mapping of number of units to the
-    price in dollars per unit if you purchase that many units. For example::
-
-        {
-            1: 0.5,
-            10: 0.45,
-            500: 0.4,
-            100: 0.35,
-        }
-
-    In this case, the price per unit is 0.5, the price per unit if you buy 10
-    or more is 0.45, the price per unit if you buy 50 or more is 0.4, and so on.
-    Your breakpoints can be any positive integer.
-
-    The implementation of this function depends on the API you are using to get
-    pricing data. This is an example implementation that uses the cofactr API,
-    and will not work unless you have a cofactr API key. You will need to
-    replace this function with your own implementation if you use some other
-    API, such as Octopart or TrustedParts. You have access to the `requests`
-    python library to perform HTTP requests.
-
-    :param part_number: A part number by which to search for the component.
-    :returns: A mapping of price breakpoints to the price at that breakpoint.
-    """
-
-    if part_number.startswith("NOTAPART"):
-        return None
-
-    api_key = os.environ.get("COFACTR_API_KEY")
-    client_id = os.environ.get("COFACTR_CLIENT_ID")
-    if api_key is None or client_id is None:
-        raise ValueError(
-            "Please set the COFACTR_API_KEY and COFACTR_CLIENT_ID environment variables"
-        )
-
-    query = part_number
-    if query_needs_manufacturer(search_strategy) and manufacturer:
-        query += f" {manufacturer}"
-
-    search_response = requests.get(
-        "https://graph.cofactr.com/products/",
-        headers={
-            "X-API-KEY": api_key,
-            "X-CLIENT-ID": client_id,
-        },
-        params={
-            "q": query,
-            "search_strategy": search_strategy,
-            "schema": "product-offers-v0",
-            "external": "true",
-            "limit": "1",
-        },
-    )
-
-    if search_response.status_code != 200:
-        print(
-            f"Warning: Received status code {search_response.status_code} for {part_number} {manufacturer}",
-            file=sys.stderr,
-        )
-        return None
-
-    search_results = search_response.json()
-    try:
-        reference_prices = search_results.get("data", [])[0].get("reference_prices")
-    except IndexError:
-        print(
-            f"Warning: No results found for {part_number} {manufacturer}",
-            file=sys.stderr,
-        )
-        return None
-
-    prices = {int(price["quantity"]): float(price["price"]) for price in reference_prices}
-
-    return PartPrices(
-        cofactr_id=search_results["data"][0]["id"],
-        prices=prices,
-    )
-
-
-def query_needs_manufacturer(search_strategy: str) -> bool:
-    return search_strategy != "mpn_exact"
+from cofactr_cogs.api import fetch_price_for_part, PartPrices, SearchStrategy
 
 
 def main() -> None:
@@ -166,10 +69,7 @@ def main() -> None:
     part_number_column = args.bom_part_number_column
     manufacturer_column = args.bom_manufacturer_column
     quantity_column = args.bom_quantity_column
-    search_strategy = args.search_strategy
-    if search_strategy == "fuzzy":
-        # Cofactr's default search strategy is designed for search results.
-        search_strategy = "default"
+    search_strategy = SearchStrategy(args.search_strategy)
 
     with open(args.bom_file, "r") as bom_file:
         bom_csv = csv.DictReader(bom_file)
@@ -184,7 +84,7 @@ def main() -> None:
     prices_for_parts = {}
 
     use_mfr = bool(manufacturer_column)
-    if not use_mfr and query_needs_manufacturer(search_strategy):
+    if not use_mfr and search_strategy.query_needs_manufacturer():
         raise ValueError(
             "Search strategy requires manufacturer, but no BOM manufacturer column was provided.  Please set bom_manufacturer_column."
         )

From ac0589f7875e017f9ce4c9918eb4b4adb6409043 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Fri, 24 May 2024 13:08:01 -0400
Subject: [PATCH 21/25] Add link to Cofactr API docs in readme

---
 README.md | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 7d3ebf9..373659d 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
 # Cofactr COGS
 
-Generate cost of goods sold (COGS) using Cofactr.
+Generate cost of goods sold (COGS) in AllSpice Actions using Cofactr.
+
+This uses the Cofactr API.  See the [Cofactr API docs](https://help.cofactr.com/en/articles/8868930-cofactr-component-cloud-api-documentation) for more information.
 
 ## Usage
 

From 5a875823da0993ddcde2bbc8a3f14faab0c42dd7 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Fri, 24 May 2024 13:16:51 -0400
Subject: [PATCH 22/25] Change to only output manufacturer column when it's
 used

---
 entrypoint.py | 28 ++++++++++++++++++++--------
 1 file changed, 20 insertions(+), 8 deletions(-)

diff --git a/entrypoint.py b/entrypoint.py
index 200fe86..8ef4d62 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -109,17 +109,21 @@ def main() -> None:
         print("No prices found for any parts", file=sys.stderr)
         sys.exit(1)
 
-    headers = [
-        "Part Number",
-        "Manufacturer",
-        "Cofactr ID",
-        "Quantity",
-    ]
+    # The number of columns that the output should have.
+    expected_columns = (4 if use_mfr else 3) + 2 * len(quantities)
+
+    headers = ["Part Number"]
+    if use_mfr:
+        headers.append("Manufacturer")
+    headers.append("Cofactr ID")
+    headers.append("Quantity")
 
     for quantity in quantities:
         headers.append(f"Per Unit at {quantity}")
         headers.append(f"Total at {quantity}")
 
+    assert len(headers) == expected_columns
+
     rows = []
     totals: dict[int, float] = {quantity: 0 for quantity in quantities}
 
@@ -131,7 +135,11 @@ def main() -> None:
         part_prices = prices_for_parts.get((part_number, manufacturer))
         cofactr_id = part_prices.cofactr_id if part_prices else None
 
-        current_row = [part_number, manufacturer, cofactr_id, part_quantity]
+        current_row = [part_number]
+        if use_mfr:
+            current_row.append(manufacturer)
+        current_row.append(cofactr_id)
+        current_row.append(part_quantity)
 
         for quantity in quantities:
             breakpoints = price_breakpoints(part_prices, quantity)
@@ -146,6 +154,7 @@ def main() -> None:
                 current_row.append(None)
                 current_row.append(None)
 
+        assert len(current_row) == expected_columns
         rows.append(current_row)
 
     with ExitStack() as stack:
@@ -158,11 +167,14 @@ def main() -> None:
         writer.writerow(headers)
         writer.writerows(rows)
 
-        totals_row = ["Totals", None, None, None]
+        totals_row = ["Totals", None, None]
+        if use_mfr:
+            totals_row.append(None)
         for quantity in quantities:
             totals_row.append(None)
             totals_row.append(str(totals[quantity]))
 
+        assert len(totals_row) == expected_columns
         writer.writerow(totals_row)
 
     print("Computed COGS", file=sys.stderr)

From c331db38f8241ab83d75b2f91588960b216c2442 Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Tue, 21 May 2024 14:40:03 -0400
Subject: [PATCH 23/25] Remove defaults for BOM column names

---
 action.yml    | 10 +++++-----
 entrypoint.py | 14 ++++++++++----
 2 files changed, 15 insertions(+), 9 deletions(-)

diff --git a/action.yml b/action.yml
index 0d8bbd7..a43b6ca 100644
--- a/action.yml
+++ b/action.yml
@@ -12,9 +12,9 @@ inputs:
     default: "1,10,100,1000"
   bom_part_number_column:
     description: >
-      The name of the part number column in the BOM file. Defaults to 'Part
-      Number'.
-    default: Part Number
+      The name of the part number column in the BOM file.  You must provide
+      this.
+    default: ''
   bom_manufacturer_column:
     description: >
       The name of the manufacturer column in the BOM file.  Defaults to ''.  If
@@ -22,8 +22,8 @@ inputs:
     default: ''
   bom_quantity_column:
     description: >
-      The name of the quantity column in the BOM file. Defaults to 'Quantity'.
-    default: Quantity
+      The name of the quantity column in the BOM file.  You must provide this.
+    default: ''
   search_strategy:
     description: >
       The Cofactr search strategy. Can be: "mpn_sku_mfr" or "fuzzy" (uses mpn).
diff --git a/entrypoint.py b/entrypoint.py
index 8ef4d62..92970b5 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -31,8 +31,8 @@ def main() -> None:
     )
     parser.add_argument(
         "--bom-part-number-column",
-        help="The name of the part number column in the BOM file. Defaults to '%(default)s'.",
-        default="Part Number",
+        help="The name of the part number column in the BOM file.  You must provide this.",
+        default="",
     )
     parser.add_argument(
         "--bom-manufacturer-column",
@@ -42,8 +42,8 @@ def main() -> None:
     )
     parser.add_argument(
         "--bom-quantity-column",
-        help="The name of the quantity column in the BOM file. Defaults to '%(default)s'.",
-        default="Quantity",
+        help="The name of the quantity column in the BOM file.  You must provide this.",
+        default="",
     )
     parser.add_argument(
         "--search-strategy",
@@ -67,8 +67,14 @@ def main() -> None:
     quantities = [int(quantity) for quantity in args.quantities.split(",")]
 
     part_number_column = args.bom_part_number_column
+    if not part_number_column:
+        raise ValueError("BOM part number column needs to be specified.")
+
     manufacturer_column = args.bom_manufacturer_column
     quantity_column = args.bom_quantity_column
+    if not quantity_column:
+        raise ValueError("BOM quantity column needs to be specified.")
+
     search_strategy = SearchStrategy(args.search_strategy)
 
     with open(args.bom_file, "r") as bom_file:

From 7992c93281ff428308970ddd070209be6111e56f Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Thu, 23 May 2024 16:48:04 -0400
Subject: [PATCH 24/25] Add reference to action parameters in error messages

---
 entrypoint.py | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/entrypoint.py b/entrypoint.py
index 92970b5..8786782 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -68,12 +68,16 @@ def main() -> None:
 
     part_number_column = args.bom_part_number_column
     if not part_number_column:
-        raise ValueError("BOM part number column needs to be specified.")
+        raise ValueError(
+            "BOM part number column needs to be specified.  Please set bom_part_number_column."
+        )
 
     manufacturer_column = args.bom_manufacturer_column
     quantity_column = args.bom_quantity_column
     if not quantity_column:
-        raise ValueError("BOM quantity column needs to be specified.")
+        raise ValueError(
+            "BOM quantity column needs to be specified.  Please set bom_quantity_column."
+        )
 
     search_strategy = SearchStrategy(args.search_strategy)
 

From 3c672f601244d672cf46c46338ade7d0813e11ab Mon Sep 17 00:00:00 2001
From: Jonathan Tran <jonnytran@gmail.com>
Date: Fri, 24 May 2024 14:35:40 -0400
Subject: [PATCH 25/25] Move code inside module

---
 cofactr_cogs/cli.py | 198 ++++++++++++++++++++++++++++++++++++++++++++
 entrypoint.py       | 198 +-------------------------------------------
 2 files changed, 199 insertions(+), 197 deletions(-)
 create mode 100644 cofactr_cogs/cli.py

diff --git a/cofactr_cogs/cli.py b/cofactr_cogs/cli.py
new file mode 100644
index 0000000..eccf0e0
--- /dev/null
+++ b/cofactr_cogs/cli.py
@@ -0,0 +1,198 @@
+# Compute the Cost of Goods Sold for a BOM.
+#
+# This script doesn't depend on py-allspice, but it requires a BOM CSV file to
+# run. You can use https://github.com/AllSpiceIO/generate-bom to generate a BOM
+# CSV.
+from argparse import ArgumentParser
+from contextlib import ExitStack
+import csv
+import sys
+
+from cofactr_cogs.api import fetch_price_for_part, PartPrices, SearchStrategy
+
+
+def main() -> None:
+    parser = ArgumentParser()
+
+    parser.add_argument(
+        "bom_file",
+        help="The path to the BOM file.",
+    )
+    parser.add_argument(
+        "--quantities",
+        help=(
+            "A comma-separated list of quantities of PCBs to compute the COGS "
+            + "for. Defaults to '%(default)s'."
+        ),
+        default="1,10,100,1000",
+    )
+    parser.add_argument(
+        "--bom-part-number-column",
+        help="The name of the part number column in the BOM file.  You must provide this.",
+        default="",
+    )
+    parser.add_argument(
+        "--bom-manufacturer-column",
+        help="The name of the manufacturer column in the BOM file.  Defaults to '%(default)s'.  If "
+        + "you use a search strategy that uses manufacturer, you must provide this.",
+        default="",
+    )
+    parser.add_argument(
+        "--bom-quantity-column",
+        help="The name of the quantity column in the BOM file.  You must provide this.",
+        default="",
+    )
+    parser.add_argument(
+        "--search-strategy",
+        help="The Cofactr search strategy. Can be: mpn_sku_mfr or fuzzy (uses mpn). "
+        + "Defaults to '%(default)s'.  The API also supports mpn_exact and mpn_exact_mfr, "
+        + "but they are not recommended.",
+        default="mpn_sku_mfr",
+    )
+    parser.add_argument(
+        "--output-file",
+        help="The path to the output file. Defaults to stdout, i.e. printing to the console.",
+    )
+    parser.add_argument(
+        "--log-level",
+        help="The log level to use.  Defaults to '%(default)s'.",
+        default="info",
+    )
+
+    args = parser.parse_args()
+
+    quantities = [int(quantity) for quantity in args.quantities.split(",")]
+
+    part_number_column = args.bom_part_number_column
+    if not part_number_column:
+        raise ValueError(
+            "BOM part number column needs to be specified.  Please set bom_part_number_column."
+        )
+
+    manufacturer_column = args.bom_manufacturer_column
+    quantity_column = args.bom_quantity_column
+    if not quantity_column:
+        raise ValueError(
+            "BOM quantity column needs to be specified.  Please set bom_quantity_column."
+        )
+
+    search_strategy = SearchStrategy(args.search_strategy)
+
+    with open(args.bom_file, "r") as bom_file:
+        bom_csv = csv.DictReader(bom_file)
+
+        parts = [
+            part for part in bom_csv if part[part_number_column] and part[part_number_column] != ""
+        ]
+
+    print(f"Computing COGS for {len(parts)} parts", file=sys.stderr)
+    print(f"Fetching prices for {len(parts)} parts", file=sys.stderr)
+
+    prices_for_parts = {}
+
+    use_mfr = bool(manufacturer_column)
+    if not use_mfr and search_strategy.query_needs_manufacturer():
+        raise ValueError(
+            "Search strategy requires manufacturer, but no BOM manufacturer column was provided.  Please set bom_manufacturer_column."
+        )
+
+    if args.log_level.lower() == "debug":
+        print(f"Using part number column: {part_number_column!r}", file=sys.stderr)
+        print(f"Using manufacturer column: {manufacturer_column!r}", file=sys.stderr)
+        print(f"Using use_mfr: {use_mfr!r}", file=sys.stderr)
+        print(f"Using quantity column: {quantity_column!r}", file=sys.stderr)
+        print(f"Using search strategy: {search_strategy!r}", file=sys.stderr)
+
+    for part in parts:
+        part_number = part[part_number_column]
+        manufacturer = part[manufacturer_column] if use_mfr else ""
+        part_prices = fetch_price_for_part(part_number, manufacturer, search_strategy)
+        if part_prices is not None and len(part_prices.prices) > 0:
+            prices_for_parts[(part_number, manufacturer)] = part_prices
+
+    print(f"Found prices for {len(prices_for_parts)} parts", file=sys.stderr)
+
+    if len(prices_for_parts) == 0:
+        print("No prices found for any parts", file=sys.stderr)
+        sys.exit(1)
+
+    # The number of columns that the output should have.
+    expected_columns = (4 if use_mfr else 3) + 2 * len(quantities)
+
+    headers = ["Part Number"]
+    if use_mfr:
+        headers.append("Manufacturer")
+    headers.append("Cofactr ID")
+    headers.append("Quantity")
+
+    for quantity in quantities:
+        headers.append(f"Per Unit at {quantity}")
+        headers.append(f"Total at {quantity}")
+
+    assert len(headers) == expected_columns
+
+    rows = []
+    totals: dict[int, float] = {quantity: 0 for quantity in quantities}
+
+    for part in parts:
+        part_number = part[part_number_column]
+        manufacturer = part[manufacturer_column] if use_mfr else ""
+        part_quantity = int(part[quantity_column])
+
+        part_prices = prices_for_parts.get((part_number, manufacturer))
+        cofactr_id = part_prices.cofactr_id if part_prices else None
+
+        current_row = [part_number]
+        if use_mfr:
+            current_row.append(manufacturer)
+        current_row.append(cofactr_id)
+        current_row.append(part_quantity)
+
+        for quantity in quantities:
+            breakpoints = price_breakpoints(part_prices, quantity)
+            if part_prices and breakpoints:
+                largest_breakpoint_less_than_qty = max(breakpoints)
+                price_at_breakpoint = part_prices.prices[largest_breakpoint_less_than_qty]
+                current_row.append(price_at_breakpoint)
+                total_for_part_at_quantity = price_at_breakpoint * part_quantity
+                current_row.append(total_for_part_at_quantity)
+                totals[quantity] += total_for_part_at_quantity
+            else:
+                current_row.append(None)
+                current_row.append(None)
+
+        assert len(current_row) == expected_columns
+        rows.append(current_row)
+
+    with ExitStack() as stack:
+        if args.output_file:
+            file = stack.enter_context(open(args.output_file, "w"))
+            writer = csv.writer(file)
+        else:
+            writer = csv.writer(sys.stdout)
+
+        writer.writerow(headers)
+        writer.writerows(rows)
+
+        totals_row = ["Totals", None, None]
+        if use_mfr:
+            totals_row.append(None)
+        for quantity in quantities:
+            totals_row.append(None)
+            totals_row.append(str(totals[quantity]))
+
+        assert len(totals_row) == expected_columns
+        writer.writerow(totals_row)
+
+    print("Computed COGS", file=sys.stderr)
+
+
+def price_breakpoints(part_prices: PartPrices | None, quantity: int) -> list[int] | None:
+    if part_prices is None:
+        return None
+    breakpoints = [breakpoint for breakpoint in part_prices.prices.keys() if breakpoint <= quantity]
+    return breakpoints if len(breakpoints) > 0 else None
+
+
+if __name__ == "__main__":
+    main()
diff --git a/entrypoint.py b/entrypoint.py
index 8786782..e099f9d 100755
--- a/entrypoint.py
+++ b/entrypoint.py
@@ -1,201 +1,5 @@
 #! /usr/bin/env python3
-
-# Compute the Cost of Goods Sold for a BOM.
-#
-# This script doesn't depend on py-allspice, but it requires a BOM CSV file to
-# run. You can use https://github.com/AllSpiceIO/generate-bom to generate a BOM
-# CSV.
-
-from argparse import ArgumentParser
-from contextlib import ExitStack
-import csv
-import sys
-
-from cofactr_cogs.api import fetch_price_for_part, PartPrices, SearchStrategy
-
-
-def main() -> None:
-    parser = ArgumentParser()
-
-    parser.add_argument(
-        "bom_file",
-        help="The path to the BOM file.",
-    )
-    parser.add_argument(
-        "--quantities",
-        help=(
-            "A comma-separated list of quantities of PCBs to compute the COGS "
-            + "for. Defaults to '%(default)s'."
-        ),
-        default="1,10,100,1000",
-    )
-    parser.add_argument(
-        "--bom-part-number-column",
-        help="The name of the part number column in the BOM file.  You must provide this.",
-        default="",
-    )
-    parser.add_argument(
-        "--bom-manufacturer-column",
-        help="The name of the manufacturer column in the BOM file.  Defaults to '%(default)s'.  If "
-        + "you use a search strategy that uses manufacturer, you must provide this.",
-        default="",
-    )
-    parser.add_argument(
-        "--bom-quantity-column",
-        help="The name of the quantity column in the BOM file.  You must provide this.",
-        default="",
-    )
-    parser.add_argument(
-        "--search-strategy",
-        help="The Cofactr search strategy. Can be: mpn_sku_mfr or fuzzy (uses mpn). "
-        + "Defaults to '%(default)s'.  The API also supports mpn_exact and mpn_exact_mfr, "
-        + "but they are not recommended.",
-        default="mpn_sku_mfr",
-    )
-    parser.add_argument(
-        "--output-file",
-        help="The path to the output file. Defaults to stdout, i.e. printing to the console.",
-    )
-    parser.add_argument(
-        "--log-level",
-        help="The log level to use.  Defaults to '%(default)s'.",
-        default="info",
-    )
-
-    args = parser.parse_args()
-
-    quantities = [int(quantity) for quantity in args.quantities.split(",")]
-
-    part_number_column = args.bom_part_number_column
-    if not part_number_column:
-        raise ValueError(
-            "BOM part number column needs to be specified.  Please set bom_part_number_column."
-        )
-
-    manufacturer_column = args.bom_manufacturer_column
-    quantity_column = args.bom_quantity_column
-    if not quantity_column:
-        raise ValueError(
-            "BOM quantity column needs to be specified.  Please set bom_quantity_column."
-        )
-
-    search_strategy = SearchStrategy(args.search_strategy)
-
-    with open(args.bom_file, "r") as bom_file:
-        bom_csv = csv.DictReader(bom_file)
-
-        parts = [
-            part for part in bom_csv if part[part_number_column] and part[part_number_column] != ""
-        ]
-
-    print(f"Computing COGS for {len(parts)} parts", file=sys.stderr)
-    print(f"Fetching prices for {len(parts)} parts", file=sys.stderr)
-
-    prices_for_parts = {}
-
-    use_mfr = bool(manufacturer_column)
-    if not use_mfr and search_strategy.query_needs_manufacturer():
-        raise ValueError(
-            "Search strategy requires manufacturer, but no BOM manufacturer column was provided.  Please set bom_manufacturer_column."
-        )
-
-    if args.log_level.lower() == "debug":
-        print(f"Using part number column: {part_number_column!r}", file=sys.stderr)
-        print(f"Using manufacturer column: {manufacturer_column!r}", file=sys.stderr)
-        print(f"Using use_mfr: {use_mfr!r}", file=sys.stderr)
-        print(f"Using quantity column: {quantity_column!r}", file=sys.stderr)
-        print(f"Using search strategy: {search_strategy!r}", file=sys.stderr)
-
-    for part in parts:
-        part_number = part[part_number_column]
-        manufacturer = part[manufacturer_column] if use_mfr else ""
-        part_prices = fetch_price_for_part(part_number, manufacturer, search_strategy)
-        if part_prices is not None and len(part_prices.prices) > 0:
-            prices_for_parts[(part_number, manufacturer)] = part_prices
-
-    print(f"Found prices for {len(prices_for_parts)} parts", file=sys.stderr)
-
-    if len(prices_for_parts) == 0:
-        print("No prices found for any parts", file=sys.stderr)
-        sys.exit(1)
-
-    # The number of columns that the output should have.
-    expected_columns = (4 if use_mfr else 3) + 2 * len(quantities)
-
-    headers = ["Part Number"]
-    if use_mfr:
-        headers.append("Manufacturer")
-    headers.append("Cofactr ID")
-    headers.append("Quantity")
-
-    for quantity in quantities:
-        headers.append(f"Per Unit at {quantity}")
-        headers.append(f"Total at {quantity}")
-
-    assert len(headers) == expected_columns
-
-    rows = []
-    totals: dict[int, float] = {quantity: 0 for quantity in quantities}
-
-    for part in parts:
-        part_number = part[part_number_column]
-        manufacturer = part[manufacturer_column] if use_mfr else ""
-        part_quantity = int(part[quantity_column])
-
-        part_prices = prices_for_parts.get((part_number, manufacturer))
-        cofactr_id = part_prices.cofactr_id if part_prices else None
-
-        current_row = [part_number]
-        if use_mfr:
-            current_row.append(manufacturer)
-        current_row.append(cofactr_id)
-        current_row.append(part_quantity)
-
-        for quantity in quantities:
-            breakpoints = price_breakpoints(part_prices, quantity)
-            if part_prices and breakpoints:
-                largest_breakpoint_less_than_qty = max(breakpoints)
-                price_at_breakpoint = part_prices.prices[largest_breakpoint_less_than_qty]
-                current_row.append(price_at_breakpoint)
-                total_for_part_at_quantity = price_at_breakpoint * part_quantity
-                current_row.append(total_for_part_at_quantity)
-                totals[quantity] += total_for_part_at_quantity
-            else:
-                current_row.append(None)
-                current_row.append(None)
-
-        assert len(current_row) == expected_columns
-        rows.append(current_row)
-
-    with ExitStack() as stack:
-        if args.output_file:
-            file = stack.enter_context(open(args.output_file, "w"))
-            writer = csv.writer(file)
-        else:
-            writer = csv.writer(sys.stdout)
-
-        writer.writerow(headers)
-        writer.writerows(rows)
-
-        totals_row = ["Totals", None, None]
-        if use_mfr:
-            totals_row.append(None)
-        for quantity in quantities:
-            totals_row.append(None)
-            totals_row.append(str(totals[quantity]))
-
-        assert len(totals_row) == expected_columns
-        writer.writerow(totals_row)
-
-    print("Computed COGS", file=sys.stderr)
-
-
-def price_breakpoints(part_prices: PartPrices | None, quantity: int) -> list[int] | None:
-    if part_prices is None:
-        return None
-    breakpoints = [breakpoint for breakpoint in part_prices.prices.keys() if breakpoint <= quantity]
-    return breakpoints if len(breakpoints) > 0 else None
-
+from cofactr_cogs.cli import main
 
 if __name__ == "__main__":
     main()