Executable specifications in flang From Python, Go, and shell: working clients, not declarations
0%

From Python, Go, and shell: working clients, not declarations

From Python, Go, and shell: working clients, not declarations

A flang model is not tied to Node.js. The boundary between it and the rest of the code is not a library but a process: any language that can start a command and read its output can compute a model function. Below are three working clients: Python, Go and shell. One contract, one result. All three live in examples/spec/clients/ and actually run.

Minimal example

The model is the discount calculation from static/spec/models/order-discount.flang, the same one the HTTP service uses in the Node.js chapter. For a command-line call it has a function with two scalar arguments:

тотальная функция «Скидка по сумме»
  принимает сумма: неотрицательное, постоянный: признак
  возвращает число
  пример «тот же расчёт без записи на входе»
    дано сумма равно 20000
    дано постоянный равно да
    ожидается 3000
  «Рассчитать скидку» от (запись «Покупка» с «сумма» равным сумма и «постоянный клиент» равным постоянный)

It builds a «Покупка» record and calls «Рассчитать скидку». The call:

$ flang run static/spec/models/order-discount.flang --function "Скидка по сумме" --args '{"сумма": 20000, "постоянный": true}' --trust
на веру: доказанность не считалась — запуск по ключу --trust
3000

What the compiler does

flang run computes one function itself: neither Node nor a C compiler is needed. Before computing it checks the arguments against the declared types and gives a verdict on what is proved.

The verdict matters here. Three of the model’s four promises rest only on examples: the flang check --proof report calls them «сетка N» — checked on N values from the examples, which is not a proof. Without explicit consent flang run does not compute an unproved program and exits with code 3:

$ flang run static/spec/models/order-discount.flang --function "Скидка по сумме" --args '{"сумма": 20000, "постоянный": true}'
не доказано: утверждений 4: доказано 1, сетка 3, на веру 0 — запуск только по явному согласию: --на-веру

The --trust key gives consent for one run. The unproven key in .flangrc gives it for all runs. The clients below pass --trust explicitly, so the consent is visible in the code.

The boundary through JSON

The flang run contract is simple:

  • arguments are --args, a flat JSON of scalars: a number, a string, yes/no. Records and lists cannot be passed here, which is why the model has «Скидка по сумме»;
  • the value goes to stdout, on one line;
  • the verdict on proofs and errors go to stderr, an error with a FLANG_… code;
  • the exit code: 0 — computed, 1 — an error in the input or in the program, 2 — the call itself could not be parsed, 3 — the program is not proved and the run without consent is refused.

The shape of the model is also available to a machine without a flang parser: flang ast prints the parsed program as JSON — objects with fields and types, functions with parameters and examples. The HTTP service builds its contract from it, and the form builds its schema.

Install flang:

curl -fsSL https://raw.githubusercontent.com/digitable-lol/flang/main/install.sh | sh

The clients take the path to the binary from the FLANG variable, by default flang from PATH.

The Python client

examples/spec/clients/python/calculate_discount.py uses only subprocess and json from the standard library:

def calculate_discount(amount: int, loyal: bool, model: Path = DEFAULT_MODEL) -> float:
    """Вызывает функцию модели «Скидка по сумме» и возвращает число."""
    command = [
        os.environ.get("FLANG", "flang"), "run", str(model),
        "--function", FUNCTION,
        "--args", json.dumps({"сумма": amount, "постоянный": loyal}, ensure_ascii=False),
        "--trust",
    ]
    completed = subprocess.run(command, capture_output=True, text=True, env={**os.environ, "LC_ALL": "C.UTF-8"})
    if completed.returncode != 0:
        raise RuntimeError(describe_failure(completed))
    return float(completed.stdout.strip())

The error is not swallowed. describe_failure looks in stderr for the first line with a FLANG_… code — the reason for the refusal in the compiler’s words — and shows it together with the exit code.

Run and output:

$ python3 calculate_discount.py
сумма=20000 постоянный_клиент=False -> скидка=2000

$ python3 calculate_discount.py --сумма 20000 --постоянный-клиент
сумма=20000 постоянный_клиент=True -> скидка=3000

$ python3 calculate_discount.py --сумма -5
ошибка: flang завершился с кодом 1: FLANG_TYPE: вызов функции «Скидка по сумме»: аргумент «сумма»: -5 вне неотрицательное

The Go client

examples/spec/clients/go/main.go uses only os/exec and encoding/json:

func calculateDiscount(sum int, loyal bool, model string) (float64, error) {
	arguments, err := json.Marshal(map[string]any{"сумма": sum, "постоянный": loyal})
	if err != nil {
		return 0, err
	}

	bin := os.Getenv("FLANG")
	if bin == "" {
		bin = "flang"
	}
	cmd := exec.Command(bin, "run", model, "--function", functionName, "--args", string(arguments), "--trust")
	cmd.Env = append(os.Environ(), "LC_ALL=C.UTF-8")
	var out, errOut bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &errOut

	if err := cmd.Run(); err != nil {
		return 0, fmt.Errorf("flang: %v: %s", err, reason(errOut.String()))
	}
	return strconv.ParseFloat(strings.TrimSpace(out.String()), 64)
}

stdout and stderr go into separate buffers: the reason for a refusal is not lost even if the process ended with an error. Output:

$ go run main.go
сумма=20000 постоянный_клиент=false -> скидка=2000

$ go run main.go -sum 20000 -loyal
сумма=20000 постоянный_клиент=true -> скидка=3000

The shell client

examples/spec/clients/shell/discount.sh is bash plus jq. jq builds the argument JSON: gluing it from strings in bash breaks easily on quotes and on the Unicode in сумма.

ARGS="$(jq -cn --argjson sum "$SUM" --argjson loyal "$LOYAL" '{"сумма": $sum, "постоянный": $loyal}')"

set +e
LC_ALL=C.UTF-8 "$FLANG" run "$MODEL" --function "Скидка по сумме" --args "$ARGS" --trust >"$STDOUT_FILE" 2>"$STDERR_FILE"
STATUS=$?
set -e

if [[ $STATUS -ne 0 ]]; then
  # Первая строка с кодом FLANG_… — причина отказа словами компилятора.
  REASON="$(grep -m1 'FLANG_' "$STDERR_FILE" || head -n1 "$STDERR_FILE")"
  echo "ошибка (код $STATUS): $REASON" >&2
  exit "$STATUS"
fi

set -e is lifted for the call on purpose: otherwise the script would exit before reading stderr and showing the reason. Output:

$ ./discount.sh
сумма=20000 постоянный_клиент=false -> скидка=2000

$ ./discount.sh 20000 true
сумма=20000 постоянный_клиент=true -> скидка=3000

$ ./discount.sh -5 true
ошибка (код 1): FLANG_TYPE: вызов функции «Скидка по сумме»: аргумент «сумма»: -5 вне неотрицательное

Over HTTP instead of the CLI

If starting a process per call is expensive, there is a long-lived HTTP service. examples/spec/discount-api works like that: the model’s examples run once at start, the model is printed to JavaScript, and after that Node.js only routes requests.

npm run spec:api
curl -s localhost:8788/discount -d '{"сумма":20000,"постоянный клиент":true}'
# {"discount": 3000}

A bad input comes back as 400 with the reason:

{
  "error": "вход не подходит к «Покупке»: «сумма»: ждали неотрицательное целое",
  "code": "INPUT"
}

Any client above can be rewritten to use requests, net/http or curl instead of starting a process. The choice between the CLI and HTTP is a choice between simplicity (one process per call, nothing kept alive) and throughput (the model is prepared once, then only the network).

A third way: print the model into your language

Both ways above keep something next to your code: the flang binary or a Node service. Sometimes that is not allowed. Then the model is printed into the target language ahead of time with flang emit. There are ten targets:

$ flang emit static/spec/models/order-discount.flang --target zzz
flang emit: цели «zzz» у этой сборки flang нет — целей здесь ДЕСЯТЬ — «c», «cpp», «go», «rust», «java», «js», «ts», «elixir», «python», «csharp».

Printing to Python:

$ flang emit static/spec/models/order-discount.flang --target python --out printed
…
напечатано файлов 4, байт 109125, в printed
…
$ ls printed
Makefile  flang_cli.py  flang_runtime.py  prodazhi.py

The prodazhi.py module runs without dependencies. The model’s functions in it are ordinary Python functions, with a role prefix in the name:

import prodazhi, flang_runtime as rt

ctx = prodazhi.new_context()
p = prodazhi.rec_pokupka(rt.number(20000), rt.flag(True))
print(prodazhi.fn_rasschitat_skidku(ctx, p))
# <flang число: 3000>

The role prefix (fn_ for a function, rec_ for a record constructor) keeps an object and a function with the same name from clashing after transliteration.

Next to it lies the runner flang_cli.py. A request is one JSON line, and so is the answer:

$ echo '{"fn":"Рассчитать скидку","args":[{"r":[["сумма",{"n":"20000"}],["постоянный клиент",true]]}]}' | python3 flang_cli.py prodazhi
{"ok":true,"value":{"n":"3000"}}

Values in the protocol are tagged: a number travels as a string ({"n":"20000"}) so that -0, NaN and infinity are not lost; a string is {"s":"…"}; a record is {"r":[["поле", …]]}; a flag is true/false. The runner checks the input against the model’s types before the call:

$ echo '{"fn":"Рассчитать скидку","args":[{"r":[["сумма",{"n":"-1"}],["постоянный клиент",true]]}]}' | python3 flang_cli.py prodazhi
{"ok":false,"code":"FLANG_TYPE","message":"вызов функции «Рассчитать скидку»: аргумент «покупка».сумма: -1 вне неотрицательное"}

The protocol is the same for every target. The same model printed to Java:

$ flang emit static/spec/models/order-discount.flang --target java --out printed-java
…
напечатано файлов 8, байт 163319, в printed-java
…
$ cd printed-java && make build
javac -encoding UTF-8 -Xlint:all -Werror -d . *.java
$ printf '{"fn":"Рассчитать скидку","args":[{"r":[["сумма",{"n":"20000"}],["постоянный клиент",true]]}]}\n' | java -cp . FlangCli Prodazhi
{"ok":true,"value":{"n":"3000"}}

Three thousand is exactly what the example «большая покупка постоянного клиента» in the model itself says. The runner is needed for a call through a pipe. In your own Java program the functions are called directly:

Ctx ctx = Prodazhi.newContext();
Value покупка = Prodazhi.rec_pokupka(Value.number(20000), Value.flag(true));
System.out.println(Prodazhi.fn_rasschitat_skidku(ctx, покупка));   // <flang число: 3000>

The printed code checks the model’s unproved promises on every call and throws FLANG_PROPERTY if a promise is broken. Proved promises do not go into the code.

What to choose

There are three ways, and the choice comes down to one question — what you are ready to keep next to your code.

Way What lives next to it When to use it
flang run in a process the flang binary and the model rare calls, scripts, CI
HTTP service a service with the model, one for everyone high frequency, a short SLA
printing into the target language nothing — only your code flang cannot be installed, or a process per call is not acceptable

The third way has a price. Printed code is a snapshot of the model at the moment of printing: change the model, and you must print again. This is handled with a --check step in CI that compares the printed code with the model (chapter «Generation and CI»). One more thing: flang emit does not run examples, so flang test is needed before printing.

Practice in the sandbox

Find the three promises in the proof report that rest only on examples. They are why flang run asks for --trust. Change the amount in the example «тот же расчёт без записи на входе» and see which example stops matching.

Common errors

Below are real answers of flang run on the model from this chapter.

FLANG_TYPE, a value of the wrong type (code 1):

FLANG_TYPE: вызов функции «Скидка по сумме»: аргумент «сумма» не соответствует типу неотрицательное

FLANG_TYPE, a missing argument (code 1, the example omits постоянный):

FLANG_TYPE: вызов функции «Скидка по сумме» функции «Скидка по сумме» не задаёт аргумент «постоянный»

FLANG_UNKNOWN_NAME, an extra argument (code 1). flang does not let an extra key through silently: most often it means the client and the model have drifted apart.

FLANG_UNKNOWN_NAME: вызов функции «Скидка по сумме» функции «Скидка по сумме» задаёт неизвестный аргумент «вес»

FLANG_UNKNOWN_NAME, a typo in the function name (code 1):

FLANG_UNKNOWN_NAME: не найдена функция «Расчитать скидку»

A record in --args (code 2): --args accepts only a flat object of scalars.

flang run: «--args» разобрать не удалось — ждался плоский объект скаляров, вроде '{"н":10}'

Code 3 and no value printed — the program is not proved, and --trust was not passed.

The general rule for a client in any language: branch on the FLANG_… code and the exit code, not on the message text. The text is written for a human.

Checklist

  • The client reads stdout as the value only when the exit code is 0; otherwise it takes the reason from stderr.
  • Arguments are a flat JSON of scalars in --args; for a record the model has a function with scalar arguments.
  • Consent to unproved code (--trust) is passed explicitly, and the code shows that it is there.
  • Branching on an error uses the FLANG_… code, not the text.
  • For rare calls — a process, for high frequency — an HTTP service, without flang on the machine — a printed module and a --check step in CI.

Catalog cases on this topic

Next: generation and CI

Spotted a mistake? Select a fragment of the text — a bug icon will appear next to it.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Next