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()