#!/usr/bin/env python3 """Submit Altium 365 **Part Requests** with Selenium, looping over a manifest — the skill's end task when the central library is a managed Altium 365 Workspace and the operator is not an admin. It runs on the OPERATOR'S machine and **attaches to their already-signed-in Chrome** via the remote-debugging port, so it reuses the existing Altium 365 session — no credentials are ever handled here. It then fills and submits the "Add new request" web form for each component. Selectors were captured from the live form at `/partrequestsmanagement/Tasks/Add` (Altium's "afs" design system + Selectize.js dropdowns): - Manufacturer : input#Manufacturer - Manufacturer Part Numbers: textarea#ManufacturerPartNumbers - Description : textarea#Description - Component Type / Assign to: Selectize dropdowns (click control after the label, click the option div by text) - Parameters : an afs-table under the "Parameters" label; each "Add" click adds a row of two inputs (name, value) - Attachments : input#fileupload (Selenium send_keys the local file path) - Save : button.afs-btn_primary with text "Save" -------------------------------------------------------------------------------------------- SETUP (once, on the operator's machine) 1. pip install selenium (Selenium 4+ auto-manages chromedriver; or pass --chromedriver) 2. Fully close Chrome, then start it with a debugging port on the operator's own profile so the Altium 365 login is reused, e.g. on Windows: chrome.exe --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\\Google\\Chrome\\User Data" Sign in to the Workspace in that Chrome if not already. 3. Run: python altium365_part_request.py --manifest part_requests.json \ --base https://vecmocon-technologies-pvt-ltd.365.altium.com Add --no-submit to fill every form but NOT click Save (dry run), or --review-first to pause after filling the first request so you can eyeball it before the batch proceeds. MANIFEST (part_requests.json) — one entry per component: {"requests":[ {"manufacturer":"Taiyo Yuden","mpn":"JMK105BJ105KV-F", "description":"CHIP_CAP_1uF_6.3v_±10%_0402_x5r","component_type":"Capacitors", "assignee":"", // optional: exact name as shown in the Assign-to list "parameters":{"Value":"1u","Voltage(V)":"6.3","Tolerance":"±10%"}, "files":["C:\\\\lib\\\\JMK105BJ105KV-F.SchLib","C:\\\\lib\\\\.PcbLib","C:\\\\lib\\\\.pdf"]} ]} Component Type must be one of the Workspace's list (captured live): Test Points, Clock&Timing, Memory, Wireless, Transformers, Mechanicals, Capacitors, Transistors, Drivers, Optoelectronics, Power Supply, Audio, Fuses, Switches, Integrated Circuits, Logic, Diodes, Interface, Miscellaneous, Radio&RF, Sensors, Processors, Mechanical, Inductors, LED, Amplifiers, Resistors, Data Converters, Relays, Batteries, Connectors, Crystals & Oscillators """ import argparse, json, os, sys, time from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def make_driver(debugger_address, chromedriver): opts = Options() opts.add_experimental_option("debuggerAddress", debugger_address) return webdriver.Chrome(service=Service(chromedriver), options=opts) if chromedriver \ else webdriver.Chrome(options=opts) def _wait(driver, cond, t=25): return WebDriverWait(driver, t).until(cond) def _set_text(driver, by, sel, value): el = _wait(driver, EC.presence_of_element_located((by, sel))) driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el) el.clear() el.send_keys(value or "") def _select_dropdown(driver, label, value): """Selectize: click the control after `label`, then the visible option whose text == value.""" control = _wait(driver, EC.element_to_be_clickable((By.XPATH, f"//*[normalize-space(text())='{label}']/following::div[contains(@class,'selectize-input')][1]"))) driver.execute_script("arguments[0].scrollIntoView({block:'center'});", control) control.click() opt = _wait(driver, EC.element_to_be_clickable((By.XPATH, "//div[contains(@class,'selectize-dropdown') and not(contains(@style,'display: none'))]" f"//div[contains(@class,'option') and normalize-space()=\"{value}\"]"))) opt.click() def _add_parameters(driver, params): """Click the Parameters 'Add' link once per parameter, then fill each row's (name,value).""" if not params: return add_link_xpath = ("//*[normalize-space(text())='Parameters']/descendant-or-self::*" "/a[contains(@class,'afs-link')] | " "//*[normalize-space(text())='Parameters']/following::a[contains(@class,'afs-link')][1]") for _ in params: link = _wait(driver, EC.element_to_be_clickable((By.XPATH, add_link_xpath))) driver.execute_script("arguments[0].click();", link) time.sleep(0.25) # the Parameters afs-table tbody that sits under the "Parameters" label tbody = _wait(driver, EC.presence_of_element_located((By.XPATH, "//*[normalize-space(text())='Parameters']/following::div[contains(@class,'afs-table__tbody')][1]"))) rows = tbody.find_elements(By.XPATH, ".//div[contains(@class,'afs-table__tr')]") for row, (name, val) in zip(rows, params.items()): cells = row.find_elements(By.XPATH, ".//input[contains(@class,'afs-input__control')]") if len(cells) >= 2: cells[0].clear(); cells[0].send_keys(str(name)) cells[1].clear(); cells[1].send_keys(str(val)) def _attach_files(driver, files): files = [f for f in (files or []) if f and os.path.exists(f)] if not files: return [], [f for f in (files or []) if f and not os.path.exists(f)] inp = _wait(driver, EC.presence_of_element_located((By.ID, "fileupload"))) inp.send_keys("\n".join(os.path.abspath(f) for f in files)) # multi-file input time.sleep(1.0) return files, [] def submit_request(driver, base, req, no_submit=False): driver.get(f"{base}/partrequestsmanagement/Tasks/Add") _wait(driver, EC.presence_of_element_located((By.ID, "Manufacturer"))) _set_text(driver, By.ID, "Manufacturer", req.get("manufacturer", "")) _set_text(driver, By.ID, "ManufacturerPartNumbers", req.get("mpn", "")) _set_text(driver, By.ID, "Description", req.get("description", "")) if req.get("component_type"): _select_dropdown(driver, "Component Type", req["component_type"]) if req.get("assignee"): _select_dropdown(driver, "Assign to", req["assignee"]) _add_parameters(driver, req.get("parameters") or {}) attached, missing = _attach_files(driver, req.get("files")) if no_submit: return {"mpn": req.get("mpn"), "status": "filled_not_submitted", "attached": attached, "missing_files": missing} save = _wait(driver, EC.element_to_be_clickable((By.XPATH, "//button[contains(@class,'afs-btn_primary') and normalize-space()='Save']"))) driver.execute_script("arguments[0].click();", save) time.sleep(2.5) # let the SPA post and return to the list return {"mpn": req.get("mpn"), "status": "submitted", "attached": attached, "missing_files": missing} def main(): ap = argparse.ArgumentParser(description="Submit Altium 365 Part Requests via Selenium.") ap.add_argument("--manifest", required=True, help="part_requests.json") ap.add_argument("--base", required=True, help="Workspace base URL, e.g. https://.365.altium.com") ap.add_argument("--debugger-address", default="127.0.0.1:9222") ap.add_argument("--chromedriver", default=None, help="path to chromedriver (optional in Selenium 4+)") ap.add_argument("--no-submit", action="store_true", help="fill every form but do not click Save") ap.add_argument("--review-first", action="store_true", help="after filling the first request, pause for Enter before submitting the rest") ap.add_argument("--out", default="part_requests_result.json") a = ap.parse_args() data = json.load(open(a.manifest, encoding="utf-8")) requests = data.get("requests", []) driver = make_driver(a.debugger_address, a.chromedriver) results = [] try: for i, req in enumerate(requests): first = (i == 0) no_submit = a.no_submit or (a.review_first and first) try: r = submit_request(driver, a.base.rstrip("/"), req, no_submit=no_submit) except Exception as e: r = {"mpn": req.get("mpn"), "status": "error", "error": str(e)[:300]} results.append(r) print(f"[{i+1}/{len(requests)}] {req.get('mpn')}: {r['status']}" + (f" MISSING {r['missing_files']}" if r.get("missing_files") else "")) if a.review_first and first and not a.no_submit: input("Review the first request in the browser, then press Enter to submit the rest...") # re-submit the first for real now that it's been reviewed results[-1] = submit_request(driver, a.base.rstrip("/"), req, no_submit=False) finally: json.dump({"results": results}, open(a.out, "w"), indent=2, ensure_ascii=False) print(f"\nwrote {a.out} ({sum(1 for r in results if r['status']=='submitted')} submitted, " f"{sum(1 for r in results if r['status']=='error')} errors)") # NOTE: attaching to your Chrome — the script does not close your browser. if __name__ == "__main__": main()