Skip to content

Add raw output format to the API - #17

Closed
roed-math wants to merge 5 commits into
mainfrom
ai/t17-api-raw-format
Closed

Add raw output format to the API#17
roed-math wants to merge 5 commits into
mainfrom
ai/t17-api-raw-format

Conversation

@roed-math

@roed-math roed-math commented Jul 19, 2026

Copy link
Copy Markdown
Owner

This adds _format=raw to the API (alongside html/json/yaml), returning only the contents of the columns requested via _fields, as requested by Bill Allombert for consumption from PARI/GP scripts.

Raw output is newline-delimited JSON without the usual response envelope or implicit id. With one requested field, each line is that field's JSON value. With several fields, each line is an ordered JSON array containing those field values. _delim is used only to parse parameters such as _fields and _sort; it does not delimit output records or fields, so a record stays unambiguous even when a value contains the delimiter or a newline.

Records are rendered from the database rows rather than from Json.prep's output, so a Postgres numeric is a JSON number carrying every stored digit: ec_curvedata.faltings_height for 11a1 comes out as -0.30800984111840306468901426146, not as the __RealLiteral__ object that records how to rebuild the Sage real. The decimal is copied verbatim, never through a float. Values with no plain JSON rendering (rationals, number field elements, dates) keep psycodict's extended encoding.

For fields whose emitted JSON notation is also valid GP syntax, notably integers and nested arrays of integers, the response can be read directly with PARI/GP readvec (verified in gp). Other values should be parsed as JSON; a JSON boolean in particular is not a GP boolean.

Raw requests remain limited to 100 records and omit pagination metadata, so _offset is advanced to retrieve subsequent pages; requesting raw output without a nonempty _fields gives a 400 error. The format is documented on the API index page, table pages link to it whenever _fields is nonempty, and test_api.py gains exact-output tests covering numerics, booleans, nulls, integer arrays, multi-field records, a delimiter inside a value and the error case. Addresses LMFDB#1010.

🤖 Generated with Claude Code

roed314 and others added 2 commits July 19, 2026 01:45
_format=raw returns just the contents of the columns requested via
_fields, one record per line with no ids or metadata wrapper, so that
scripts (in particular PARI/GP, the original request) can consume query
results directly.  Values are JSON-encoded, making each single-field
line a valid GP expression readable with readvec; multiple fields are
joined by _delim.  Requesting raw without _fields gives a 400 error.
Documented on the API index page and linked from table pages when
_fields is present.

Verified with a new test_api_raw in lmfdb/api/test_api.py (all 7 API
tests pass against devmirror) and by reading saved raw responses back
into sage --gp with readvec/readstr.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…DB#1010)

The raw format previously joined individually-JSON-encoded fields with the
query delimiter, producing lines like `[0,-1,1,-10,-20],11` where commas
appear both inside and between fields: not valid JSON, not a single GP
expression, and unparseable when a string value contains the delimiter.

Multiple fields are now emitted as one JSON array per line (JSON Lines), so
every record is self-delimiting and round-trips through json.loads even when a
value contains the delimiter. Single-field output is unchanged (one JSON value
per line), preserving the PARI/GP readvec round-trip; the docs now scope the
GP-compatibility claim to the single-field case. Tests add json.loads
round-trips and a delimiter-in-value case; the single-field GP round-trip was
re-verified in sage --gp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roed-math

Copy link
Copy Markdown
Owner Author

Addressed the external review's P2 finding (multi-field raw records not parseable): commit fa953ee makes multi-field raw output emit one JSON array per line (JSON Lines), so each record is self-delimiting regardless of delimiters inside values; single-field output is unchanged. _delim now only splits the _fields/_sort inputs, never joins output. The API docs bullet specifies the format exactly and scopes the PARI/GP readvec claim to the single-field case (re-verified in sage --gp). Tests now round-trip records via json.loads, including a delimiter-inside-value case.

Stayed within this PR's hunks — no overlap with #18/#22/#40 regions.

roed314 and others added 2 commits August 4, 2026 17:23
Raw records were rendered from Json.prep's output, so a Postgres numeric
arrived as the extended-JSON object that records how to rebuild the Sage
real, e.g. {"__RealLiteral__": 0, "data": "-0.308...", "prec": 97} for
ec_curvedata.faltings_height. That is the wrong rendering for a format
that promises the contents of the requested columns, and it is not what a
PARI/GP client expects.

Records are now rendered from the database values, by a small serializer
that copies a real number's stored decimal verbatim (never through a
float, so nothing is rounded) once it has checked the literal against the
JSON number syntax; everything else is JSON-encoded as before, and values
with no plain JSON rendering keep psycodict's extended encoding. Lists
recurse, so a numeric[] column is exact too. A raw request also projects
exactly the requested fields rather than prepending id, which the body
never showed; a record whose projected columns are all NULL is therefore
an empty dictionary, so the by-id route now tests lucky's result against
None rather than for emptiness. Absent keys are still read with .get:
most tables set include_nones off, so psycodict omits a NULL column
rather than storing None, and an unknown field is rejected before the
query runs.

The docs were stale in two ways: the PR description still described
delimiter-joined multi-field output, and the code comments, test
docstring and API page claimed PARI/GP readvec could consume any
single-field response. readvec evaluates lines as GP code, so `true` and
`false` read as symbolic variables rather than booleans (checked in
sage --gp); the claim is now scoped to integers and nested arrays of
integers, which do round-trip. The API page also notes that raw bodies
carry no next entry and that _offset advances the page, and _delim is
described as parsing request parameters only. The Raw link on table pages
now requires a nonempty _fields, since _fields= gives a 400.

Verified: sage -python -m pytest lmfdb/api/test_api.py -> 7 passed;
pyflakes and ruff clean; test_api_raw gains exact-numeric, boolean and
NULL cases; raw numeric, boolean, string, integer-array, numeric-array,
dotted-path, by-id and multi-field responses inspected by hand, every
line parsing as JSON; readvec re-checked in sage --gp on 100 ainvs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roed-math

Copy link
Copy Markdown
Owner Author

Pushed 182d10b addressing the review.

1. Exact numerics. Raw records are now rendered from the database rows, before Json.prep, by a small serializer (raw_json_dumps in lmfdb/api/api.py). A real number's stored decimal is copied verbatim once it has been checked against the JSON number syntax, never through a float, so ?label=11a1&_format=raw&_fields=faltings_height returns

-0.30800984111840306468901426146

instead of the __RealLiteral__ object. Integers, strings, booleans and nulls are JSON as before; lists recurse, so a numeric[] column is exact too, and values with no plain JSON rendering keep psycodict's extended encoding. NaN and the infinities have no JSON number syntax, so they fall back to the extended encoding rather than emit a line no JSON reader accepts.

2. Docs and the GP claim. The code comments, test_api_raw docstring, API index page and PR description now describe the actual format, along the lines the review suggested: newline-delimited JSON, no envelope and no implicit id, _delim used only to parse request parameters, readvec scoped to fields whose emitted JSON is also GP syntax, and raw bodies carrying no next entry so that _offset advances the page. Checked in sage --gp: readvec on 100 lines of ainvs still gives a t_VEC of indexable t_VECs, while a file of true/false reads as t_POL, i.e. symbolic variables rather than booleans, which is exactly what the old blanket claim would have promised.

3. Raw link condition. collection.html now tests url_args.get('_fields'), so _fields= no longer offers a Raw link to a guaranteed 400.

One deviation, on point 5. rec[col] cannot be used: 161 of the 192 tables (including ec_curvedata) are declared with include_nones off, and psycodict then omits a NULL column from the result dictionary rather than storing None, so rec[col] would turn every NULL into a 500. An absent key is unambiguous here in any case, because a field that is not a column of the table is rejected when the query is built (the existing 404 path), so after a successful search the only way a key can be missing is a NULL. The code keeps .get with that reasoning in a comment, and test_api_raw pins the behaviour: _fields=squarefree_disc gives null, _fields=squarefree_disc,conductor gives [null, 11].

Projecting exactly the requested fields (point 6) exposed a related edge case: a record whose projected columns are all NULL comes back from lucky as {}, which the by-id route read as "no such document" and turned into a 404. It now tests that result against None instead of for emptiness.

Verification. sage -python -m pytest lmfdb/api/test_api.py gives 7 passed against devmirror; pyflakes and ruff check --preview --select=E722 are clean. test_api_raw gains the exact-numeric and boolean cases from the review, plus NULL cases and a numeric inside a multi-field record, and keeps every earlier case. Raw numeric, boolean, string, integer-array, numeric[], dotted-path, by-id, multi-field and multi-record responses were also inspected by hand, with every emitted line re-parsed as JSON using parse_float=Decimal.

The remaining checklist item I cannot tick off is the Actions run: this fork's queue is currently around 85 runs deep and the run for 182d10b has not started yet.

raw_json_dumps special-cased RealNumber, but psycodict does not hand back
a numeric that is exactly zero as one: numeric_converter returns an
LmfdbDecimalZero, an integer wrapper keeping the literal in .literal.
Such a value fell through to Json.prep, which sees a Sage integer and
emits 0, dropping the stored scale and, for -0.000, the sign, whether the
zero stood alone or sat inside a numeric[], a list or a dictionary. That
contradicted the format's promise that a numeric is the decimal Postgres
sent, copied verbatim.

The literal lookup moves into exact_decimal(), which handles the integer
wrapper alongside RealLiteral and RealNumber and validates the literal
against the JSON number syntax once, so nothing is rebuilt from a float
or an int. A numeric with no decimal point is still a Sage integer and is
unaffected; NaN and the infinities still fall back to psycodict's
extended encoding rather than emit an unparseable token.

test_api.py gains test_raw_json_dumps, calling the serializer directly so
the behaviour does not depend on finding a zero-valued row: 0.000, -0.000
and 1.250 come back verbatim, as do zeros nested in an array and in an
object, and each result is re-parsed as JSON. The exact strings are the
point, since Decimal("-0.000") == Decimal("0.000").

Verified: sage -python -m pytest lmfdb/api/test_api.py -> 8 passed;
pyflakes and ruff clean; /api/lfunc_lfunctions/?_format=raw&_fields=
mu_imag,analytic_normalization now gives [[0.0], 0] rather than [[0], 0].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roed-math

Copy link
Copy Markdown
Owner Author

Pushed 30b24a7 for the decimal-zero case. You are right, and I had made exactly the wrong call on it: I saw LmfdbDecimalZero while writing the serializer, reasoned that an all-zero decimal is an exactly integral numeric and so belonged under "exact integral numeric values become JSON integers", and let it fall through to Json.prep. That reading loses the stored scale and the sign of -0.000, and it contradicts what the rest of the format promises.

The literal lookup now lives in exact_decimal(value), which handles the integer wrapper alongside RealLiteral and RealNumber and validates against JSON_NUMBER_RE once, so there is no duplicated regex check and nothing is rebuilt from a float or an int:

>>> raw_json_dumps(numeric_converter("0.000"))
'0.000'
>>> raw_json_dumps(numeric_converter("-0.000"))
'-0.000'
>>> raw_json_dumps([numeric_converter("1.250"), numeric_converter("0.000")])
'[1.250, 0.000]'

A numeric stored without a decimal point is still a Sage integer and is unaffected, and NaN and the infinities still fall back to the extended encoding rather than emit a token no JSON reader accepts. The effect is visible on a real numeric[] column: /api/lfunc_lfunctions/?_format=raw&_fields=mu_imag,analytic_normalization gave [[0], 0] before and gives [[0.0], 0] now, the second field being a numeric with no decimal point.

test_api.py gains test_raw_json_dumps, which calls the serializer directly as suggested, so the behaviour does not depend on finding a zero-valued row: 0.000, -0.000, 1.250 and the 29-digit faltings_height literal all come back verbatim, zeros nested in an array and in an object keep their literal, and every result is re-parsed as JSON. The exact strings carry the test, since Decimal("-0.000") == Decimal("0.000"). Every earlier API-level case is retained.

sage -python -m pytest lmfdb/api/test_api.py gives 8 passed against devmirror; pyflakes and ruff check --preview --select=E722 are clean on both files.

On the last checklist item: the Actions runs for 182d10b never started, and the runs for this head are queued behind the same backlog (the run pushed one minute before 182d10b waited about three and a half hours for a runner, then passed). I am watching this head and will report the conclusion here.

@roed314

roed314 commented Aug 5, 2026

Copy link
Copy Markdown

GPT signed off.

@roed-math

Copy link
Copy Markdown
Owner Author

Superseded by LMFDB#7153, opened upstream from this same branch. Closing here; review continues upstream.

@roed-math roed-math closed this Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants