Материал FTS from Python, Go, and shell: working clients, not declarations
0%

FTS from Python, Go, and shell: working clients, not declarations

FTS from Python, Go and shell: working clients, not declarations

FTS is not tied to Node.js. The compiler is written in TypeScript, but the boundary between it and the rest of the code is not a library, but a process and JSON: any language that can run a command and parse stdout can execute the FTS utility. Further — three working clients: Python, Go, and shell, the same contract, the same result. All three are in examples/fts/clients/ and indeed run, not just read.

Minimal example

The model around which the entire module is built is calculating a discount on a purchase. It is also used by the HTTP service in examples/fts/discount-api and the clients below: a single source of truth for the rule, three ways to invoke it.

категория «Продажи»

  объект Покупка
    сумма является деньгами
    «постоянный клиент» является признаком

  утилита «Рассчитать скидку»
    принимает Покупка
    возвращает деньги
    начинает с 0

    правило «Большая покупка»
      если сумма не меньше 10000
      и сумма не больше 100000
      то добавить 10 процентов от поля сумма

    правило «Постоянный клиент»
      если «постоянный клиент» равен да
      и сумма больше 0
      и сумма не больше 100000
      то добавить 5 процентов от поля сумма

    правило «Очень крупная покупка»
      если сумма больше 100000
      то добавить 15000

    свойство «Скидка ограничена»
      результат не больше 15000

    пример «Обычная покупка»
      дано сумма равна 5000
      дано «постоянный клиент» равен нет
      ожидается результат равен 0

    пример «Постоянный клиент на пять тысяч»
      дано сумма равна 5000
      дано «постоянный клиент» равен да
      ожидается результат равен 250

    пример «Большая покупка постоянного клиента»
      дано сумма равна 20000
      дано «постоянный клиент» равен да
      ожидается результат равен 3000

    пример «Покупка на потолок скидки»
      дано сумма равна 100000
      дано «постоянный клиент» равен да
      ожидается результат равен 15000

    пример «Очень крупная покупка»
      дано сумма равна 200000
      дано «постоянный клиент» равен нет
      ожидается результат равен 15000

What the compiler does

fts compile turns the text above into one JSON object: category, structures with fields and types, utilities with rules and examples. fts test runs the examples and compares expected with actual. fts run --utility ... --input purchase.json executes a specific utility on specific data and prints the result. None of this requires TypeScript on the caller’s side — only the ability to read JSON.

Boundary through JSON

Canonical document — public offer, not internal detail. In upstream there is its JSON Schema (schema/document.schema.json): it requires fields category, structures, functors, proposition, ts_compat and allows utilities. This is how the model above looks after fts compile (shortened):

{
  "category": "Продажи",
  "structures": [
    { "name": "Покупка", "fields": [
      { "name": "сумма", "type": "Деньги" },
      { "name": "постоянный клиент", "type": "Признак" }
    ] }
  ],
  "functors": [],
  "proposition": null,
  "ts_compat": {},
  "utilities": [
    { "name": "Рассчитать скидку", "input": "Покупка", "output": "Деньги",
      "initial": 0, "rules": [ /*  */ ], "properties": [ /*  */ ],
      "examples": [ /*  */ ] }
  ]
}

Any language can validate this document with a schema without having an FTS parser — a regular JSON Schema validator is sufficient (jsonschema in Python, ajv in Node, any equivalent in Go). Next to it lies schema/proof-certificate.schema.json for proof certificates — the same idea: the certificate structure is public and is verified independently of who issued it.

The CLI contract is simple and the same for all teams:

  • success — JSON result in stdout, exit code 0;
  • error — {"error": "...", "diagnostics": [...]} in stderr, exit code not equal to 0 (1 — domain or validation error, 2 — arguments error).

The real fts is built from the @digitable-lol/fts package; the source code is open (github.com/digitable-lol/flang), but it is not yet published as an npm package. Therefore, the examples below use examples/fts/clients/fts-cli.mjs — a thin bridge over the same vendor build that powers the sandbox, replicating the compile / check / test / run contract. Replacing the bridge with the real fts does not change any client — only the team name in one line.

Client in Python

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

def calculate_discount(amount, loyal, cli="bridge", model=DEFAULT_MODEL):
    purchase = {"сумма": amount, "постоянный клиент": loyal}
    input_path = write_temp_json(purchase)
    command = build_cli_command(cli, model) + ["--utility", UTILITY, "--input", str(input_path)]
    completed = subprocess.run(command, capture_output=True, text=True)
    if completed.returncode != 0:
        raise RuntimeError(describe_failure(completed))
    return json.loads(completed.stdout)["result"]

The utility data is passed by file: the CLI contract requires --input путь.json, not stdin, so a temporary file is not an extra detail, but a boundary requirement. The error is not swallowed: describe_failure parses stderr as JSON and extracts error and diagnostic codes, and if stderr is not JSON at all (CLI not found) — displays what is there instead of a silent failure.

Execution and actual output:

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

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

Client in Go

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

func calculateDiscount(sum float64, loyal bool, cli, model string) (float64, error) {
	purchase := map[string]any{"сумма": sum, "постоянный клиент": loyal}
	inputFile, err := writeTempInput(purchase)
	// ...
	cmd := exec.Command(command[0], command[1:]...)
	// stdout и stderr пишутся в отдельные буферы —
	// diagnostics не должны потеряться, даже если процесс упал
	runErr := cmd.Run()
	if runErr != nil {
		return 0, fmt.Errorf("%s", describeFailure(stderrBuf, stdoutBuf, runErr))
	}
	var result runResult
	json.Unmarshal(stdout, &result)
	return result.Result, nil
}

Structures runResult and errorResult in the code type both possible outcomes of the JSON contract — success and error — so that encoding/json parses them without interface{} and type casts. Built and checked locally (go1.26.3):

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

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

Client in shell

examples/fts/clients/shell/discount.sh — bash plus jq. To write a proper JSON parser in pure bash without jq won’t work: unicode and quote escaping in сумма/«постоянный клиент» — exactly the case where a homemade grep/sed silently breaks on the first non-standard value. jq is present in the system almost always, and its absence the client checks and explicitly reports about.

jq -n --argjson sum "$SUM" --argjson loyal "$LOYAL" \
  '{"сумма": $sum, "постоянный клиент": $loyal}' > "$INPUT_FILE"

"${CMD[@]}" >"$STDOUT_FILE" 2>"$STDERR_FILE"
STATUS=$?
if [[ $STATUS -ne 0 ]]; then
  ERROR_MESSAGE="$(jq -r '.error' "$STDERR_FILE" 2>/dev/null || true)"
  echo "ошибка: $ERROR_MESSAGE" >&2
  exit "$STATUS"
fi

set -e is disabled during the CLI call intentionally: without this, the return code would be lost before the script has a chance to read stderr and show diagnostics to the user. Actual output:

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

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

Through HTTP instead of CLI

If launching the process on each call is expensive (high frequency, short SLA), the alternative is not CLI, but a long-running HTTP service. examples/fts/discount-api already does this: the model is compiled and tested once at startup, then Node.js only routes requests, while FTS makes the decision.

node examples/fts/discount-api/server.mjs
curl -s localhost:8788/discount -d '{"сумма":20000,"постоянный клиент":true}'
# {"discount": 3000}

Login error is returned with the same body as in CLI — error and diagnostics — only with code 400 instead of a non-zero exit code:

{
  "error": "поле «сумма» не соответствует типу «Деньги»",
  "diagnostics": [{ "code": "FTS_UTILITY_INPUT_TYPE", "message": "…", "severity": "error" }]
}

Any client from the sections above can be rewritten on requests/net/http/curl instead of subprocess/os/exec/CLI-bridge — the response parsing does not change at all, because the body is the same as what CLI prints. The choice between CLI and HTTP is the choice between simplicity (one process per call, nothing to keep alive) and throughput (the model is compiled once, then only the network).

Third way: not to call the compiler, but to print the model into your language

Both approaches above have a common feature: a Node process lives next to your code and counts. Sometimes this is not possible — there is no Node on the target machine, the service cannot be started, or it is not possible to pay for running the process on invocation. Then the third approach works, and it appeared recently.

FTS has grown into a full-fledged language — flang, — for which the .fts model is a valid program (module «FTS toolchain»). The language has code generation, and it compiles to eight targets:

$ node flang/bin/flang.mjs emit examples/utilities/discount.fts --target zzz
{"error":"неизвестная цель «zzz»; доступны: c, csharp, elixir, go, java, js, python, rust", …}

The input is the same file .fts that you have checked throughout the course using fts check. Let’s take Java — that case when “lifting Node nearby” is usually not even discussed:

$ node flang/bin/flang.mjs emit examples/utilities/discount.fts --target java --out out-java
{"target":"java","module":"Продажи","files":[
  {"path":"Value.java","bytes":21992}, {"path":"Field.java","bytes":1619},
  {"path":"FlangError.java","bytes":4920}, {"path":"Ctx.java","bytes":5676},
  {"path":"Flang.java","bytes":35537}, {"path":"Prodazhi.java","bytes":5927},
  {"path":"FlangCli.java","bytes":21963}, {"path":"Makefile","bytes":1077}]}

$ 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 — exactly what fts run gives on the same data and what is recorded in the “Big purchase by a regular customer” example in the model itself. Note what is not present in this chain: Node on the calling side.

Runner FlangCli in the example above — only so that the result is visible in the console; it is not needed in your program itself. In Prodazhi.java are ordinary static methods, and the utility model is one of them:

Ctx ctx = Prodazhi.newContext();
Value покупка = Prodazhi.rec_pokupka(Value.number(20000), Value.flag(true));
Value скидка = Prodazhi.fn_rasschitat_skidku(ctx, покупка);   // 3000

Role is part of every name (fn_ for the function, rec_ for the record constructor) not for beauty: a Java class is one namespace, while in the FTS model the same name can easily be shared by both an object and a utility. If they collided after transliteration — the class would simply fail to build, and the backend treats such a collision as a printing error, not a reason to silently rename someone.

Matching here is not “usually converging”, but a requirement that is checked. For flang code generation, one common rule applies to all eight backends: the printed code must give the same value and the same error — code and text, as the interpreter gives. From this rule grow decisions that would otherwise look like nitpicks: numbers are IEEE-754 everywhere (in the C# backend decimal is rejected precisely for this reason — in it 0.1 плюс 0.2 would give exactly 0.3), percentages are printed as (процент / 100) * значение in this order, diagnostic texts are copied literally down to the curly quotes.

What This Means for the Choice of Boundary

There are now three approaches, and one question determines the choice between them — what you are willing to keep alongside your code.

Method What Lives Nearby When to Take
CLI-process Node and model rare invocations, scripts, CI
HTTP-service Node and model, but once for all high frequency, short SLA
printing to target language nothing — only your code Node is unavailable or a process per invocation is unacceptable

The third approach has a cost, and it should be mentioned. Printed code is a snapshot of the model at the time of printing: changed .fts — must print again, otherwise you will diverge. Exactly the same problem as with generated TypeScript, and it is solved exactly the same way — a --check step in CI, which compares the printed code with the model (module «Generation and CI»). The first two approaches do not have this cost: there, the model is read on every run.

And the same was done with the compiler itself

This is no longer about integration, but it’s worth mentioning because it shows how general the approach is.

The FTS core — lexer, parser, utility calculator, and canonical JSON printer — rewritten from TypeScript to flang: four files, 300 functions, all in a total class. And since this is a program in flang, it can be printed in C:

$ node flang/bin/flang.mjs emit flang/core/parser.flang --target c --out core-c
{"target":"c","module":"Парсер FTS","files":[…, {"path":"parser_fts.c","bytes":914838}, …]}

$ cd core-c && make
cc -std=c99 -Wall -Wextra -Werror -pedantic -O2 -o flang_cli flang_cli.o flang_runtime.o parser_fts.o -lm

The resulting binary is the FTS compiler without Node. We fed it the source code of the model from this module and compared the result with what the TypeScript core gives: the canonical JSON matched byte-for-byte, 1195 bytes in 1195. The same document that you’ve been getting all semester with the fts compile command is now also available as a native binary.

The criterion by which this core is considered correct should be taken independently of FTS: not «pass own tests», but «on all repository models the old core and the new one produce byte-for-byte identical JSON, including codes and texts of diagnostics». The private test base checks what the author thought of; verification against a working predecessor checks everything the predecessor can do — including behavior that no one remembers anymore.

Practice in the sandbox

Change the amount or the loyal customer flag and see which JSON goes to the CLI and which returns — the same utility that the clients above execute.

Common Fallacies

Below are real diagnostic codes reproduced on the model from this module.

FTS_UTILITY_INPUT_TYPE — field is not of the type:

{"сумма": "много", "постоянный клиент": true}
{"error": "поле «сумма» не соответствует типу «Деньги»", "diagnostics": [{"code": "FTS_UTILITY_INPUT_TYPE", "severity": "error"}]}

FTS_UTILITY_INPUT — a field is missing in the input data (in the example above «постоянный клиент» is missing); the same code is used if the utility itself is not found in the input structure.

FTS_UTILITY_INPUT_FIELD — an extra field is present in the input data that does not exist in the Покупка structure. FTS does not silently ignore extra keys: this is usually an indication that the client and the model have diverged in their contract.

FTS_UNKNOWN_UTILITY — typo in the utility name (--utility "Расчитать скидку" instead of «Рассчитать скидку»).

FTS_NO_UTILITY_EXAMPLESfts test invoked on utility without a single пример «…»: nothing to test, and the compiler states this explicitly, rather than silently returning an empty report.

General rule for the client in any language: do not parse error text manually. Branching should be done by diagnostics[].code — it is stable between versions, while the message wording is not.

Checklist

  • The client calls the CLI and parses stdout as JSON only at exit code 0; at nonzero — reads stderr as {error, diagnostics}.
  • Utility input data is passed via a file (--input path.json), not as a string in arguments: this makes the path visible in diagnostics on failure.
  • Branching on error — through diagnostics[].code, not through the text error.
  • For infrequent calls — a CLI process; for high frequency — an HTTP service with a model compiled once at startup.
  • The canonical model, not just the utility result, can be validated with an external JSON Schema — this works even without CLI, on an already saved document.

Cases in the catalog on this topic

Next: generation and CI

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

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

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

Доска запросов
Дальше