sailing Foudra MRP

Python

Worked examples with Requests (pip install requests). Each one is complete: set FOUDRA_URL and FOUDRA_API_KEY as in API keys and run it. Every endpoint is listed in the reference.

If you'd rather have no dependencies at all, foudra.py in the tools does the same things with Python's standard library.

A session

Start every script with a session: it keeps the connection open between requests and sends the key with each one. Always give a timeout.

import os
import requests

BASE = os.environ["FOUDRA_URL"].rstrip("/")
api = requests.Session()
api.headers["Authorization"] = "Bearer " + os.environ["FOUDRA_API_KEY"]


def call(method, path, **kw):
    """One request; Foudra's error message, if it refuses, as the exception."""
    r = api.request(method, BASE + path, timeout=60, **kw)
    if not r.ok:
        try:
            message = r.json()["error"]
        except ValueError:
            message = r.text[:200]
        raise RuntimeError(f"{method} {path}: {r.status_code} {message}")
    return r.json() if r.content and r.headers.get("Content-Type", "").startswith("application/json") else r

The examples below use call.

Reading

part = call("GET", "/api/parts/1203")["data"]
print(part["part_number"], part["name"], part["on_hand"])

Lists come a page at a time. This generator walks them all:

def every(path, **params):
    params.setdefault("per", 100)
    page = 1
    while True:
        out = call("GET", path, params={**params, "page": page})
        yield from out["data"]
        if page >= out.get("pages", 1):
            return
        page += 1


for p in every("/api/parts", category="504"):
    print(p["part_number"], p["name"])

?q= searches part numbers, names and descriptions. A part number search can match more than one part, so compare:

def find_part(number):
    for p in call("GET", "/api/parts", params={"q": number, "per": 10})["data"]:
        if p["part_number"] == number:
            return p
    return None

Quantities and money

They arrive as strings, so nothing is lost on the way. Keep them exact with Decimal, and send them back as strings:

from decimal import Decimal

on_hand = Decimal(part["on_hand"])
if on_hand < Decimal(part["minimum_stock"]):
    print("below minimum:", part["part_number"])

call("PUT", f"/api/bom/lines/{line_id}", json={"quantity": str(Decimal("0.0319"))})

Creating

bracket = call("POST", "/api/parts", json={
    "category_code": "504",
    "name": "Bow node mount",
    "part_type": "sub_assembly",
    "description": "3D printed, PETG",
    "custom": {"source": "my-script"},  # yours to use; Foudra keeps it and never reads it
})["data"]
print("made", bracket["part_number"])

The stock number is given out by Foudra in the category; the answer is the new part.

Uploading files

Images and documents are multipart uploads: the file in the field file, and for a document its type and, if you like, a title and revision.

with open("bow_node-mount_COREONE.bgcode", "rb") as f:
    doc = call("POST", f"/api/parts/{bracket['id']}/documents",
               files={"file": f}, data={"type": "gcode", "revision": "B"})["data"]
print(doc["id"], doc["printer_model"])  # "COREONE", read from the file

with open("render.png", "rb") as f:
    call("POST", f"/api/parts/{bracket['id']}/images", files={"file": f})

Foudra decides what a file is from its contents, so it must really be what its name says. The documents page lists the formats and sizes.

Downloading files

A document or image in the lists has a url; fetch it with the same key. Stream it to disk, since a file can be up to 50 MB, and compare its SHA-256 if you keep copies, so you only download what has changed:

import hashlib


def download(doc, path):
    with api.get(BASE + doc["url"], stream=True, timeout=300) as r:
        r.raise_for_status()
        h = hashlib.sha256()
        with open(path, "wb") as f:
            for chunk in r.iter_content(1 << 16):
                f.write(chunk)
                h.update(chunk)
    assert h.hexdigest() == doc["sha256"]


docs = call("GET", f"/api/parts/{bracket['id']}/documents")["data"]
gcode = [d for d in docs if d["type"] == "gcode" and d["printer_model"] == "COREONE"]
if gcode:
    newest = max(gcode, key=lambda d: d["created_at"])
    download(newest, "job.bgcode")

That is how a print manager picks the file for the printer it's about to use: a part can hold G-code for several printers, and each says which it was sliced for.

Doing things safely

Actions such as raising a work order take two extra headers and a query parameter:

  • Idempotency-Key: send a unique value per intended action. If the network fails and you send it again, Foudra returns the first answer, with Idempotent-Replay: true, instead of doing it twice.
  • ?dry_run=1: do everything, report what happened, then undo it. Use it to show a person what's about to happen.
  • X-On-Behalf-Of: act as another user (see API keys).
import datetime

order = call("POST", "/api/work-orders",
             headers={"Idempotency-Key": f"service:147:{datetime.date.today()}"},
             json={"part_number": "950-000001-00", "location": "BHILL",
                   "quantity": "1", "external_ref": "vessel_id:147"})
print(order["summary"])        # a sentence fit to show a person
print(order["data"]["number"])

Retrying

Retry when the network fails, or on 502, 503 and 504, with a pause that grows each time. Never retry a 4xx: the request was refused and will be again. A POST that creates something is only safe to retry if it carries an Idempotency-Key; the rest (GET, PUT, DELETE) are safe as they are.

from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

api.mount(BASE, HTTPAdapter(max_retries=Retry(
    total=5, backoff_factor=1, status_forcelist=[502, 503, 504],
    allowed_methods=["GET", "PUT", "DELETE"])))