Skip to content

Python API

Generated from the installed laterite wheel. New here? the Learn path walks the same surface in order, and the Cookbook is the task-indexed recipe set. Cross-references below are clickable.

Reading & validating

read

read(
    source: Any = None,
    *,
    path: str | PathLike[str] | None = None,
    text: str | None = None,
    data: bytes | bytearray | memoryview | None = None,
    index: str | PathLike[str] | None = None,
    encoding: str | None = None,
    backend: Backend = "polars",
    xn: XnMode = "string",
    keys: bool = False,
    content_hash: bool = False,
) -> Ags4File

Read AGS4 — from a path, a file-like, raw bytes, or in-memory text — into an Ags4File over an in-memory DuckDB engine. This is the front door to the read surface: the inverse of Ags4File.save, and the source of the handle every later verb (ags[code] / Ags4File.validate / Ags4File.certify / sql / at) hangs off. To build AGS4 from your own data rather than parse it, reach for build_ags4 instead.

One positional source is auto-detected (path / file-like / bytes / AGS4 text); when an input is ambiguous, name it with the keyword-only path= / text= / data= doors to skip the sniff. encoding (a WHATWG label, default UTF-8) governs how bytes / path input is decoded — text= is already a string and is untouched. backend picks the default frame type handed back by ags[code]"polars" (default) or "pandas" — both pyarrow-free bridges off the DuckDB engine.

xn controls AGS XN-typed columns (numeric values that may carry a non-numeric qualifier — NP / <5 / >100). "string" (default) keeps them byte-faithful as text; "numeric" casts them to Float64 across the whole handle (ags[code] / sql / at), with non-numeric tokens becoming null. This is read-side only — Ags4File.save and the .text / .bytes doors stay byte-faithful regardless of the setting. (A fuller bidirectional XN treatment is future work.)

content_hash adds a _content_hash column: a typed, blank-insensitive fingerprint of a row's whole value, as against _id, which fingerprints its identity. Two deliveries of borehole BH01 with a corrected LOCA_GL share an _id and differ in _content_hash — so DISTINCT ON (_content_hash) collapses rows that are genuinely identical while keeping a revised one. Cells are canonicalised through the same parse_value that merge's revision report trusts, so a formatting-only re-emit (10.010.00) is not a difference; a blank cell hashes as absent, so two deliveries with different heading sets still dedup on the columns they share.

Two limits worth knowing before you rely on it. The hash is computed from one file using that file's own TYPE row, so two deliveries that disagree on a column's TYPE across the typed/free-text boundary (2DP vs X) will not dedup identical bytes — reconcile those with merge instead. And it tells you that a row changed, never which cell — that is merge's revision report and diff.

index is the explicit path to this file's .ags.idx certificate (minted by Ags4File.certify). It is strictly opt-in — there is no autodiscovery — because naming it asserts the cert belongs to this file. When given, the cert is loaded and freshness-checked against the source bytes (format version + size + SHA-256): a fresh cert is carried so a later default Ags4File.validate can skip the rule engine, while a stale one fails fast with StaleCertError.

Args: source: The AGS4 to read, auto-detected as a path, a file-like, raw bytes, or in-memory AGS4 text. Leave as None and use one of the keyword doors below to be explicit. path: Explicit on-disk path to an AGS4 file (keyword-only). text: Explicit already-decoded AGS4 text (keyword-only); not subject to encoding. data: Explicit raw AGS4 bytes (keyword-only). index: Path to this file's .ags.idx certificate (keyword-only). Opt-in, no autodiscovery; a fresh cert is carried to let a later Ags4File.validate skip the rule engine. encoding: WHATWG encoding label for bytes / path input (keyword-only); defaults to UTF-8. Ignored for text=. backend: Default frame type for ags[code]"polars" (default) or "pandas" (keyword-only); both pyarrow-free. xn: Read-side handling of XN columns — "string" (default, byte-faithful) or "numeric" (cast to Float64, non-numeric tokens to null) (keyword-only). keys: Whether ags[code] / ags.table(code) frames INCLUDE the two synthetic content-addressed key columns _id / _parent_id (keyword-only). Default False — frames carry AGS columns only. The relational .sql() layer always exposes the keys regardless, so cross-group joins (s._parent_id = l._id) work either way; a frame can opt in per call with ags.table(code, keys=True).

Returns: Ags4File: A handle over an in-memory DuckDB engine, carrying a fresh certificate when a matching index= was supplied.

Raises: StaleCertError: If index= points at a certificate whose size / SHA-256 do not match the bytes just read — the source changed under the cert; rebuild it with read(...).validate().certify().

validate

validate(
    source: Any = None,
    *,
    text: str | None = None,
    dict_version: Edition | None = None,
    warnings: bool = True,
    fyi: bool = False,
    check_files: bool = False,
    encoding: str | None = None,
    dictionary: str | Path | bytes | None = None,
    dict_replace: bool = False,
) -> Report

Run the AGS4.1 numbered-rules engine over a file and return its verdict as a Report. This is the validate door: it answers is this a conformant AGS4 file, and where does it break the rules? — distinct from read (which loads the data into an Ags4File over DuckDB) and build_ags4 (which emits AGS4 from your own data).

The distinction the door draws is un-validatable input vs rule violations. Input that can't even be assessed — a missing path, bytes that aren't AGS4 or aren't UTF-8, a recognised-but-unsupported edition, an unknown dict_versionraises, because there is no meaningful verdict to give. Genuine violations of a parseable AGS4 file never raise: they come back as findings in the Report (a clean file is a Report with count == 0).

source is auto-detected the same way read does it: a single positional argument is sniffed as a path (when it exists on disk — the unambiguous case), a file-like (.read()), raw bytes, or in-memory AGS4 text. Pass text= to be explicit for an ambiguous string (e.g. AGS4 content whose first line could be read as a filename).

Severity tiers track importance, and the defaults are tuned for a human reading a delivery: errors and WARNINGs surface by default (warnings=True). Pass warnings=False for an errors-only verdict, and fyi=True to add the low-signal FYI tier on top. The tiers are also carried on each row of Report.findings (severity), so a single validate(warnings=True, fyi=True) run can be split back apart downstream. (The laterite.compat python-ags4 shim keeps its own faithful defaults — check_files and the FYI tier on — rather than these.)

Args: source: The AGS4 to validate, auto-detected — a path, a file-like, raw bytes, or in-memory AGS4 text. Mutually exclusive with text. text: In-memory AGS4 text, given explicitly to bypass the source sniff for an ambiguous string. dict_version: The AGS edition whose dictionary the rules are projected from (e.g. "4.1"); None resolves the edition from the file (see Report.resolution). An unknown value raises BadDictError. warnings: Include WARNING-tier findings alongside errors (default True); False gives an errors-only Report. fyi: Also include the low-signal FYI tier (default False). check_files: Run Rule 20 FILE-attachment checks against files on disk (default False). encoding: WHATWG encoding label for path / bytes input (default UTF-8; ignored for text=). Set it for legacy cp1252 / latin1 deliveries so extended-ASCII cells decode correctly rather than surfacing as Rule 1 findings. dictionary: A custom AGS4 dictionary to overlay (#568) — a path or the raw .ags/JSON bytes of one — so a bespoke group (or a re-parented or overridden standard heading) validates as first-class instead of being flagged unknown. The base edition is detected from the dictionary itself unless dict_version forces it or dict_replace drops it. Overrides of standard headings are honoured and reported as warnings. dict_replace: Treat dictionary as a full replacement — drop the bundled base entirely rather than overlaying it (default False). Cannot be combined with dict_version.

Returns: A Reportcount / is_valid for the verdict at a glance, and findings (a polars frame, one row per violation) for the detail.

Raises: FileNotFoundError: source is a path that doesn't exist, or an IO error occurred reading it. NotAgs4Error: The input is not parseable AGS4 (no GROUP rows) or is not valid UTF-8. UnsupportedEditionError: The input is a recognised but unsupported edition (e.g. AGS3). BadDictError: dict_version is not a known edition, or dictionary is malformed / contradicts dict_replace.

Ags4File

Ags4File(
    parsed: dict,
    backend: Backend = "polars",
    *,
    xn: XnMode = "string",
    keys: bool = False,
    content_hash: bool = False,
    _src: tuple[str | None, str | None, bytes | None]
    | None = None,
)

A parsed AGS4 file over a Python-owned in-memory DuckDB engine.

Each group becomes a born-typed DuckDB table (a 2DP heading is Float64, an ID str, a non-conforming numeric cell is null). Access:

  • ags["LOCA"] → one group materialised to the handle's backend (polars by default, pandas if read(..., backend="pandas")); both pyarrow-free. Groups load into the engine on first touch.
  • ags.sql("SELECT … WHERE …") → a DuckDB relation — cross-group joins + filter pushdown; finish with .df() / pl.from_arrow(rel).
  • ags.connection → the raw duckdb connection (every engine feature).

UNIT/TYPE/HEADING are side metadata, not pseudo-rows (use compat for the python-ags4 HEADING-column shape). save round-trips byte-faithfully from the retained Rust parse, independent of which groups were touched.

groups property

groups: list[str]

The 4-letter group codes present, in source order — the read order of the original file, which text / save preserve.

backend property

backend: str

The frame type a materialising call (ags["LOCA"], .frame()) hands back — "polars" (default) or "pandas", as fixed at read time.

tran_ags property

tran_ags: str | None

The file's declared AGS edition — its TRAN_AGS stamp (e.g. "4.1"), or None if the file declares no edition. This is what resolves the dictionary a bare validate (no dict_version) projects the rules from.

connection property

connection: DuckDBPyConnection

The raw duckdb connection — every engine feature (parquet export, the relational API, Arrow via .arrow(), …). Seeded with all of this file's groups under their clean names on first access.

text property

text: str

Spec-correct AGS4 as text (CRLF, every field quoted, """) for every group in file order — byte-faithful to the source DATA values, re-emitted Rust-side from the retained parse (no per-cell rows cross the boundary on read). Memoised (the emit is O(size)).

bytes property

bytes: bytes

text encoded UTF-8 — the on-disk / wire form save writes. AGS4 is a text format, so bytes is just text.encode("utf-8"). Memoised.

report property

report: Report

The Report from the most recent validate (raises if validate() has not been called on this handle yet).

fix_report property

fix_report: FixResult | None

The FixResult from the fix that produced this handle — what was applied and the findings that could not be mechanically fixed — or None for a handle not produced by fix.

headings

headings(code: str) -> list[str]

The ordered HEADING names of group code (raises KeyError if the group is not in the file). Pure metadata — no engine spin-up.

units

units(code: str) -> list[str]

The UNIT row of group code, one entry per heading (raises KeyError if the group is not in the file). Pure metadata — no engine spin-up.

types

types(code: str) -> list[str]

The AGS data TYPE row of group code (ID, X, 2DP, PA, …), one entry per heading (raises KeyError if the group is not in the file). Pure metadata — no engine spin-up.

line_numbers

line_numbers(code: str) -> list[int]

The source line number of each DATA row of group code — what validator findings point back at (raises KeyError if the group is not in the file). Pure metadata — no engine spin-up.

table

table(code: str, *, keys: bool | None = None) -> Any

One group, materialised to the handle's backend (born-typed). By default the synthetic _id/_parent_id key columns are dropped; pass keys=True (or read(..., keys=True) for the handle default) to keep them. The relational .sql() layer carries them regardless.

sql

sql(query: str) -> DuckDBPyRelation

Run SQL over the file's groups by their clean names — e.g. ags.sql("SELECT * FROM LOCA JOIN SAMP USING (LOCA_ID) WHERE ...") — returning a DuckDB relation. The WHERE/SELECT push into the engine (filter a big file down before materialising); finish with .df() / pl.from_arrow(rel) or chain more SQL. A query may reference any group, so this registers them all.

at

at(group: str, values: Iterable[object]) -> AgsQuery

Filter to a parent entity's records — ags.at("LOCA", ["BH01", "BH02"]) returns an AgsQuery whose sub[code] yields only the rows of each group whose {group}_ID (e.g. LOCA_ID) is in values, materialising only the matching rows (explore a huge file without a huge frame). Chain to narrow further (.at("SAMP", […])); sub.groups is the related groups and sub.frames() pulls them all at once. Groups carrying none of the keys pass through unfiltered. For a richer predicate, .query("SELECT …").filter("…").

query

query(sql: str) -> AgsQuery

Start a lazy, chainable query over the file's groups by clean name — the fluent counterpart to sql. Where sql() hands back a raw DuckDB relation (ending the chain), query() returns an AgsQuery you keep building (.filter(), .select(), .at()) and cash out with a terminal (.frame() / .to_polars() / .to_pandas() / .relation()).

pipe

pipe(
    fn: Callable[..., Any], /, *args: Any, **kwargs: Any
) -> Any

Apply fn(self, *args, **kwargs) and return its result — a functional escape hatch to slot a custom step into a chain (read(p).pipe(my_transform).save(out)) without leaving the fluent flow.

register

register(name: str, frame: Any) -> None

Register YOUR frame (polars / pandas / pyarrow / arro3) into the engine as name, so sql() can join it against the AGS groups.

close

close() -> None

Close the in-memory DuckDB engine if one was created. Idempotent. NOTE: relations from sql() become invalid once closed — materialise (.df() / pl.from_arrow) before closing.

validate

validate(
    *,
    dict_version: Edition | None = None,
    warnings: bool = True,
    fyi: bool = False,
    check_files: bool = False,
    encoding: str | None = None,
    dictionary: str | Path | bytes | None = None,
    dict_replace: bool = False,
) -> Self

Validate this file against the AGS4 rules and return self (chainable — read(p).validate().query(...)); the outcome lands on report. Same engine as the module-level validate, run on the source this handle was read from (so line numbers match the original file). A handle built without a retained source validates its spec-correct re-emit instead.

Severity tiers track importance, like a compiler: errors and WARNINGs show by default (warnings=True); pass warnings=False to drop to errors-only, and fyi=True to add the low-signal FYI tier. (The compat shim keeps its own python-ags4-faithful defaults, unaffected by this.)

Certificate short-circuit: if this handle carries an index= certificate (from read), it is offered to the engine, which decides whether it can answer this question — and skips the rules only if it can answer it completely. A cert vouches for a tier only when it actually measured that tier and found it empty, so an errors-only check on a clean file skips, a warnings=True check skips only if the cert measured zero warnings, and a cert from a different rule engine is never trusted at all.

The decision is not made here. It is made once, in the shared trust model, for every surface. This method used to make it itself, with its own conjunction of predicates — and so did the CLI, the Node package, and the browser, each slightly differently.

check_files=True is never answered from a certificate: Rule 20's on-disk FILE/ tree can be deleted without changing a byte of the .ags, so no statement about the file's bytes can speak for it. It runs live, every time — and on a bytes/str handle, which has no directory to look in, it raises WorldCheckRequiresSourceError rather than reporting Rule 20 clean.

dictionary= overlays a custom AGS4 dictionary (#568) — a path or the raw .ags/JSON bytes of one — so a bespoke group hung off a standard one validates as first-class rather than being flagged unknown. The base edition is detected from the dictionary itself; dict_version= forces it, or dict_replace=True drops it for a full replacement (the two cannot be combined). Re-parenting or overriding a standard heading is honoured and reported as a warning.

certify

certify(
    path: str | Path | None = None,
    *,
    dict_version: Edition | None = None,
    dictionary: str | Path | bytes | None = None,
    dict_replace: bool = False,
) -> Path

Mint this file's .ags.idx validity certificate — an error-clean validation plus a byte-offset index — and write it beside the file.

certify runs the validation itself, with every severity tier on, and records what the rules actually returned. It used to require a prior validate and then vouch for whatever that had found — which meant the certificate's contents were an assertion by the caller, and the caller got them wrong: the mint took warnings=0, fyi=0 as default arguments and nothing ever passed them, so every certificate this library produced claimed to have measured zero warnings without having looked.

It refuses a file with errors. Warnings and FYI findings are recorded, not fatal — a delivery may legitimately carry them, and a certificate that counted them honestly can still answer an errors-only question.

Pass dict_version= to certify against a forced edition; by default it uses the edition of the last validate on this handle (or auto-resolves from TRAN_AGS). dictionary= / dict_replace= certify against a custom --dict overlay (#568) and stamp its identity into the cert, so a later read(index=...) that names a different dictionary revalidates rather than inheriting a stale verdict; both default to the last validate on this handle. A later read(..., index=...) consumes the cert.

path is the certificate's output location and defaults to <source>.idx (delivery.agsdelivery.ags.idx); a handle read from text/bytes has no source path, so pass path= explicitly. It is not a file to certify — certify refuses to overwrite the source file or any existing non-certificate file, so certify(p) reusing your .ags path can't destroy it. Returns the written Path. The certificate indexes the original source bytes, which must be UTF-8 (the byte index rejects other encodings). For the certificate bytes in memory (no file), use certify_bytes.

certify_bytes

certify_bytes(
    *,
    dict_version: Edition | None = None,
    dictionary: str | Path | bytes | None = None,
    dict_replace: bool = False,
) -> bytes

Mint this file's .ags.idx validity certificate and return its bytes in memory — the filesystem-free twin of certify. Same behaviour (it runs the validation itself, refuses a file with errors, and records the warning/FYI counts it measured) and the same output, so the bytes interop with read(index=...), the CLI --index, and the browser cert.

Ideal for a web backend that wants to hand the certificate straight to an upload or object store without a temp-file round-trip — the certify analog of transport.lock_bytes. Returns the certificate JSON as bytes.

Raises: Ags4Error: If the file has error-severity findings (it cannot be certified).

save

save(path: str | PathLike[str]) -> Path

Write spec-correct AGS4 to path (UTF-8 — bytes); returns the Path. The inverse of read.

to_excel

to_excel(
    path: str | PathLike[str] | None = None,
    *,
    groups: list[str] | None = None,
) -> dict | bytes

Write this file to an XLSX workbook — one sheet per group.

With path given, writes the .xlsx there and returns the Rust writer's stats ({"sheets_written", "rows_written", "warnings"}); with path=None returns the .xlsx bytes in memory (no filesystem) — so an uploaded/in-memory AGS4 needn't hit disk.

Rust-backed via laterite_excel (rust_xlsxwriter); openpyxl and pyarrow never enter the dep graph. Sheets carry the AGS HEADING / UNIT / TYPE / DATA layout. groups optionally fixes the sheet order (a subset or re-ordering of groups); default is source order. The workbook is written from this handle's spec-correct bytes, so it round-trips through from_excel regardless of how the handle was read.

to_duckdb

to_duckdb(
    path: str | PathLike[str],
    *,
    groups: list[str] | None = None,
) -> dict

Persist this file's groups to a DuckDB database at path — one born-typed table per group under its clean 4-letter code, each carrying the content-addressed _id / _parent_id key columns, so the store is join-ready and version-diffable by _id. The DuckDB counterpart to save (AGS4 text) and to_excel (XLSX).

Returns {"path", "tables_written", "rows_written"} (path is the written Path). groups optionally restricts / re-orders the tables written (a subset of groups); default is every group, in source order.

The tables are always keyed — unlike a table frame, which drops the ids for display: the joinable, diffable keys are the whole point of the relational store, and a persisted _id is the cross-version contract the read_ags DuckDB extension diffs on.

Refuses to overwrite an existing path — a database file is not clobbered silently the way save rewrites an .ags; remove it first to replace it.

fix

fix(
    *,
    risky: bool = False,
    only: list[FixableRule] | None = None,
    exclude: list[FixableRule] | None = None,
    dict_version: Edition | None = None,
    encoding: str | None = None,
) -> Ags4File

Repair this file and return a new, repaired Ags4File — the fluent transform, so read(path).fix().validate().save(out) reads as one chain. The safe mechanical fixes (CRLF / BOM / embedded-CR / short-row pad / numeric reformat / TRAN delimiter+concatenator rows) are applied; risky=True also applies the intent-guessing ones (duplicate-heading rename, datetime canonicalisation, typography). only / exclude restrict which rules' fixes are applied — rule labels from fixable_rules (the risk gate still applies, so a rule whose only fix is risky needs risky=True even when named in only). dict_version / encoding override the edition / source encoding — encoding defaults to the one this handle was read with, so a cp1252 file is re-read as cp1252. The same engine the browser fix UI uses.

Non-destructive — the source on disk is untouched; persist the repaired handle with save. The FixResult — what was applied and the residual findings — rides on the returned handle's fix_report. The new handle inherits this one's backend / xn. For the report itself (and the in_place= / out= write options) instead of a handle, call the free fix.

diff

diff(
    other: Any,
    *,
    dict_version: Edition | None = None,
    encoding: str | None = None,
) -> dict

Compare this file (the baseline) against other (the revision — a path, AGS4 text, bytes, or another Ags4File); returns the diff RevisionDelta dict. Rows are matched by the group's KEY headings, cells compared through the typed value. encoding defaults to the one this handle was read with.

Report

Report(r: dict)

The verdict the validate door hands back — is this a conformant AGS4 file, and where does it break the rules?

A Report is what validate returns once the AGS4.1 numbered-rules engine has run over a file. If an index= certificate was able to answer the question completely, the rule engine is skipped and certified is True — but the report is otherwise the same, and any world check (Rule 20's on-disk half) still ran for real. Either way it is an immutable read-out, not a live handle: it carries the answer, you don't act through it.

Read the headline off is_valid / count (conformant when the finding count is 0), with exit_code mirroring what the lat binary would return. file and dict_version say what was judged and against which AGS dictionary edition, and resolution records how that edition was chosen — "exact" / "guessed" / "fallback" / "forced". (It used to read "certified" when a cert had been used, which overloaded one field with two facts; certified carries that one now.)

The detail comes three ways, all over the same findings, so you reach for the shape that fits your tool. findings is a flat polars frame, one row per finding (rule / line / group / desc / severity / target and the pinned location columns) — ideal for filtering and slicing the warning/fyi tiers in a dataframe. by_rule regroups those same findings under their spec rule ({"AGS Format Rule N": [...]}, sorted like the Rust BTreeMap, carrying the editor-oriented char_span). to_json and to_ndjson are the serialised forms, byte-identical to lat validate --json / --ndjson — for handing the verdict to another process unchanged.

Attributes: file: The file label that was validated (path, "<bytes>", or "<text>"). dict_version: The AGS dictionary edition the rules were resolved against. resolution: How that edition was resolved — "exact" / "guessed" / "fallback" / "forced". certified: True when an index= certificate answered the content half and the rule engine was skipped. count: Number of findings (0 ⇒ conformant). is_valid: True when count is 0. exit_code: Process exit code mirroring the lat binary. findings: Flat polars frame, one row per finding (rule, line, group, desc, severity, target, heading, field_index, data_row).

file property

file: str

dict_version property

dict_version: str

resolution property

resolution: str

How the edition was resolved — "exact" / "fallback" / "forced" from the engine, or the sentinel "certified" when this verdict came from a fresh index= certificate (the rule engine was skipped).

count property

count: int

certified property

certified: bool

Did an index= certificate stand in for the rule engine?

This is not "the file was not checked". A certificate can only ever remove the CONTENT half of a validation — the part that is a pure function of the file's bytes. Anything that reads the world outside those bytes (today: Rule 20's on-disk FILE/ tree, via check_files=True) is re-run every time, certificate or not, because a directory can change without the file changing.

resolution used to carry a "certified" sentinel instead of this flag, which meant one field had to answer two questions — which dictionary judged the file and did we skip the engine — and could only answer one. Now it answers the first, and this answers the second.

revalidate_reason property

revalidate_reason: str | None

Why a proffered index= certificate did not stand in for the engine, as a stable token ("content_changed", "dictionary_changed", "edition_differs", "encoding_differs", "tier_not_measured_warnings", …), or None.

None in the two ordinary cases: no certificate was offered, or one was offered and vouched (then certified is True). A non-None value means a cert was offered but couldn't answer — the token says which guard fired, so you can see why you paid for a full validation instead of a fast one. This is the terse twin of the lat binary's human sentence for the same reasons.

is_valid property

is_valid: bool

exit_code property

exit_code: int

findings property

findings: DataFrame

Polars frame, one row per finding. Columns:

  • rule"AGS Format Rule N" (or an "FYI …" key for FYI findings).
  • line — nullable Int64 source line.
  • group / desc — the AGS group code and the human-readable message.
  • severity"error" (default), "warning" or "fyi"; lets a validate(warnings=True, fyi=True) run separate the tiers straight from the frame.
  • target — what the finding points at: "line" (default), "heading", "cell" or "group".
  • heading / field_index / data_row — the offending heading name, its 0-based column index, and the 0-based data-row index, when the finding pins them (else null).

The within-line character span (char_span) is editor-oriented and lives on by_rule / to_json / to_ndjson, not this flat frame.

by_rule

by_rule() -> dict[str, list[dict]]

{"AGS Format Rule N": [{line, group, desc, severity, ...}, ...]} — the spec-rule grouping (sorted, like the Rust BTreeMap). Each finding carries line / group / desc plus severity (always present — "error" by default) and whatever location fields the finding pins (target, heading, field_index, data_row, char_span).

to_json

to_json() -> str

{file, findings:{"AGS Format Rule N":[{line,group,desc}]}} — byte-identical to lat validate --json.

to_ndjson

to_ndjson() -> str

One flat {rule,line,group,desc} per line — byte-identical to lat validate --ndjson.

Producing & repairing

build_ags4

build_ags4(
    groups: Mapping[str, Any] | list[tuple[str, Any]] | Any,
    *,
    dict_version: Edition | None = None,
    mode: BuildMode = "autofix",
    units: Mapping[str, Mapping[str, str]] | None = None,
    types: Mapping[str, Mapping[str, str]] | None = None,
    synthesise_metadata: bool = False,
    tran: TranStamp | None = None,
) -> BuildResult

Build AGS4 from your own per-group data — the data→AGS4 door.

Where read loads an existing file, build_ags4 constructs a new one (and autofixes + validates it); persist the result with BuildResult.save.

groups arrives in one of two shapes — the same two laterite-node's buildAgs4 accepts:

  • a typed-graph root — a PROJ instance with its children attached (PROJ(...); proj.locas.append(LOCA(...))), walked depth-first via the registry's parent→child links. A typed graph emits only its PROJ-rooted subtree: the root-metadata groups (TRAN/UNIT/TYPE/ABBR/DICT) aren't children of PROJ, so reach for the (code, frame) form if you need to carry those — or pass synthesise_metadata=True to have the derivable ones (UNIT/TYPE from your data, and ABBR when PA picklist codes are used) minted for you; pass tran=TranStamp(...) to stamp a TRAN as well. That is opt-in: by default a typed-graph build reports Rule 14/15/16/17 rather than quietly filling the gaps, so you can see what is missing. PROJ (real project identity) and DICT (your schema extension) are never synthesised at all.
  • a mapping or list of (code, frame) pairs, where each frame (pandas or polars) has column names that are the AGS headings (e.g. LOCA_ID, LOCA_GL).

UNIT/TYPE are filled from the chosen dictionary edition and each cell is formatted to its canonical AGS4 string. Order is preserved — pass an ordered mapping or a list of (code, frame) pairs, and put PROJ first.

Each frame crosses into Rust zero-copy via the Arrow C-stream — pyarrow-free for polars (so this stays a base feature, no [compat] needed) and for pandas ≥ 2.2; an older pandas with no capsule routes through DuckDB instead, but pandas only ships via the [compat] extra, which carries those deps, so that fallback never burdens a base polars user.

Args: groups: The source data. Either a typed-graph PROJ root (any compiled #[pyclass] group or a laterite.dynamic passthrough node with its children attached), a mapping of {code: frame}, or a list of (code, frame) pairs. Each frame is a polars or pandas DataFrame whose column names are the AGS headings. dict_version: The dictionary edition to fill UNIT/TYPE and validate against — one of "4.0.3" | "4.0.4" | "4.1" | "4.1.1" | "4.2". Defaults to "4.1.1" when None. mode: How findings are handled. "autofix" (default) builds, then applies the safe mechanical fixes to what you wrote (pad decimals, normalise, …); anything left unfixable stays in BuildResult.findings. "report" builds unchanged and returns the findings for you to act on. "strict" raises if the output violates any error-severity rule. units: Per-heading UNIT overrides, keyed {code: {heading: unit}} — e.g. {"LOCA": {"LOCA_XTRA": "kPa"}}. Name only the headings you want to set; every other heading fills from the dictionary as usual. Handy for giving a custom heading (one the standard dictionary doesn't know) a real unit. Raises ValueError if a code or heading isn't in the data. types: Per-heading AGS data-TYPE overrides, same {code: {heading: type}} shape (e.g. {"LOCA": {"LOCA_XTRA": "3DP"}}). synthesise_metadata: Mint the mandatory metadata catalogs your data doesn't carry — UNIT and TYPE (derived from the data), and ABBR when PA picklist codes are used. "autofix" mode only. A TRAN is minted only if you also supply tran=TranStamp(...) (see below).

    **Off by default, deliberately.** Synthesis adds whole *groups* you
    never wrote, and that should be something you ask for rather than
    discover. Left off, a data-only build reports Rule 14/15/17 instead —
    which tells you exactly what is missing. Turn it on when you want the
    derived catalogs filled in for you::

        res = build_ags4(frames, synthesise_metadata=True)

    Only *derivable* metadata is ever minted. ``PROJ`` (your project
    identity), ``DICT`` (your schema extension) and ``TRAN`` (the
    transmission — see below) are never synthesised from nothing: those
    are facts only you know, and a guessed ``DICT`` parent would turn a
    visible Rule 18 error into a silent false statement about your data
    model that the relational checks then trust.
tran: The transmission this file represents, as a
    [`TranStamp`][laterite.TranStamp]. All five of its required fields
    are REQUIRED headings, so the dataclass demands them together — a
    half-stamp is a ``TypeError`` at your call site rather than a Rule
    10b finding in the output.

    Pass one (with ``synthesise_metadata=True``) and the build stamps a
    TRAN describing *your* transmission::

        res = build_ags4(
            frames,
            synthesise_metadata=True,
            tran=TranStamp(
                issue="1",
                date="2026-07-30",
                producer="Acme Ground Engineering",
                recipient="Client Ltd",
                status="FINAL",
            ),
        )

    **Leave it out and no TRAN is written at all** — Rule 14 then reports
    the gap. That is deliberate. Until 0.8.2 this minted a stub reading
    ``TRAN_DATE="1900-01-01"`` with ``"TBC"`` for producer/recipient/status,
    and that combination *satisfies* Rule 14: the file asserted a
    transmission that never happened, passed validation, and gave a
    recipient no way to tell it from a real transmission record. A missing
    TRAN that reports honestly is strictly better than a present one that
    lies. Same five arguments [`merge`][laterite.merge] takes.

Returns: A BuildResult carrying the AGS4 bytes, the validator findings on those bytes (post-fix in "autofix" mode), and fixes_applied — the count of safe fixes made. .text decodes the bytes; .save(path) writes them.

Raises: TypeError: If a typed-graph node isn't a known AGS group instance, or its headings can't be determined. RuntimeError: In mode="strict", if the emitted output violates an error-severity rule ("strict mode rejected …"); also for an unknown dict_version or mode.

TranStamp dataclass

TranStamp(
    issue: str,
    date: str,
    producer: str,
    recipient: str,
    status: str,
    description: str | None = None,
    remarks: str | None = None,
)

The transmission a file represents — the caller's half of a synthesised TRAN.

The five fields are required together because all five are REQUIRED headings in the dictionary. A partial stamp doesn't merely look thin: it emits a TRAN that fails Rule 10b on every cell it leaves blank. Making them positional-or-keyword fields with no defaults means a half-stamp is a TypeError where you wrote it, not a finding buried in the output.

TRAN_AGS, TRAN_DLIM and TRAN_RCON are deliberately absent. They describe the syntax of the file the emitter is writing, so it fills them — a value you passed could only contradict the bytes.

FILE_FSET is also absent: it references an associated file set (Rule 20), and offering it without the FILE group machinery would let you mint a reference to nothing.

Example::

laterite.build_ags4(
    frames,
    synthesise_metadata=True,
    tran=laterite.TranStamp(
        issue="1",
        date="2026-07-30",
        producer="Acme Ground Engineering",
        recipient="Client Ltd",
        status="FINAL",
    ),
)

issue instance-attribute

issue: str

TRAN_ISNO — the issue sequence reference.

date instance-attribute

date: str

TRAN_DATEyyyy-mm-dd.

producer instance-attribute

producer: str

TRAN_PROD — who produced the file.

recipient instance-attribute

recipient: str

TRAN_RECV — who it is for.

status instance-attribute

status: str

TRAN_STAT — e.g. "FINAL".

description class-attribute instance-attribute

description: str | None = None

TRAN_DESC — what was transferred. Optional.

remarks class-attribute instance-attribute

remarks: str | None = None

TRAN_REM — free remarks. Optional. On a merge these are appended to the provenance note rather than replacing it.

BuildResult

BuildResult(
    data: bytes, findings: list[dict], applied: list[dict]
)

What build_ags4 hands back: a finished AGS4 file plus the verdict on it.

Where read opens a file someone else wrote and validate judges one, build_ags4 constructs a fresh AGS4 file from your own per-group data — and this is what it returns. The whole point of a result object is that building and judging happen together: the same call that emits the bytes also runs them back through the validator, so you never hold output you haven't checked.

The file lives in bytes (the canonical form — UTF-8, byte-faithful AGS4). Reach for text when you want it as a str for display or diffing, and save when you want it on disk; save writes the bytes verbatim and returns the ~pathlib.Path it wrote, so it composes in a pipeline.

findings and fixes_applied are the verdict, and what they hold depends on the mode you built under. In the default "autofix" mode the emitter applies the safe mechanical repairs first — padding decimals, normalising — counts them in fixes_applied, and leaves only what it couldn't safely fix (a missing required heading, say) in findings; so a clean autofix build comes back with empty findings and a non-zero fix count. In "report" mode nothing is touched, fixes_applied is 0, and every finding the validator raised is yours to act on. ("strict" mode never yields a result with error-severity findings — it raises instead.) Each entry in findings is a dict carrying its rule alongside the validator's per-finding detail.

Attributes: bytes: The emitted AGS4 file as canonical UTF-8 bytes (byte-faithful). findings: The validator findings on those bytes — post-fix in "autofix" mode, the full set in "report" mode. Each is a dict with a rule key plus the validator's per-finding fields. applied: The ledger of safe mechanical fixes made during the build — each a {kind, label, rule, line, risk} dict, the same shape FixResult.applied carries (empty outside "autofix" mode). fixes_applied: Count of safe mechanical fixes applied during the build — just len(applied) (0 outside "autofix" mode). text: The bytes decoded as a UTF-8 str (read-only property).

bytes instance-attribute

bytes = data

findings instance-attribute

findings = findings

applied instance-attribute

applied = applied

fixes_applied instance-attribute

fixes_applied = len(applied)

text property

text: str

save

save(path: str | PathLike[str]) -> Path

fix

fix(
    source: Any = None,
    *,
    path: str | PathLike[str] | None = None,
    text: str | None = None,
    data: bytes | bytearray | memoryview | None = None,
    dict_version: Edition | None = None,
    encoding: str | None = None,
    risky: bool = False,
    only: list[FixableRule] | None = None,
    exclude: list[FixableRule] | None = None,
    in_place: bool = False,
    out: str | PathLike[str] | None = None,
) -> FixResult

Mechanically repair an existing AGS4 file and return a FixResult.

The same fix engine the browser uses, run headless: source is anything read accepts (path / file-like / bytes / AGS4 text), or name the input explicitly with one of the path / text / data doors. The safe fixes (CRLF / BOM / embedded-CR / short-row pad / numeric reformat / the TRAN delimiter+concatenator rows) are always applied; risky=True also applies the intent-guessing ones (duplicate-heading rename, dd/mm datetime canonicalisation, smart-quote→ASCII typography). The repaired bytes are re-validated, so FixResult.findings is what could not be mechanically fixed.

Non-destructive by default — the fixed bytes come back on the result and are written only if you ask: in_place=True overwrites the source file (which requires a path source), or out=<path> writes there; the two are mutually exclusive. Otherwise call FixResult.save. The output is always UTF-8 with no BOM, so fixing a non-UTF-8 file also normalises its encoding.

Args: source: The AGS4 input, given positionally — a path, file-like, raw bytes, or AGS4 text (anything read accepts). Leave unset to name the input via the path / text / data keywords instead. path: Explicit filesystem path to the source file, as an alternative to passing it positionally. text: Explicit AGS4 source text, as an alternative to passing it positionally. data: Explicit raw source bytes, as an alternative to passing them positionally. dict_version: AGS dictionary version (edition) to validate the repaired file against. None derives it from the file's TRAN_AGS. encoding: Override the source's text encoding. None auto-detects. risky: When True, also apply the intent-guessing fixes (duplicate-heading rename, datetime canonicalisation, typography) on top of the always-on safe set. Defaults to False. only: If given, apply only the fixes for these AGS Format Rule labels (the labels from fixable_rules); fixes for other rules are left for you to handle. exclude: AGS Format Rule labels whose fixes to skip. Combines with only (only narrows the set, then exclude removes from it). The risk gate still applies, so a rule whose only fix is risky needs risky=True. in_place: When True, write the repaired bytes back over the source file. Requires a path source and is mutually exclusive with out. Defaults to False. out: Destination path to write the repaired bytes to. Mutually exclusive with in_place. None leaves the result unwritten.

Returns: FixResult: The repaired UTF-8 bytes, the residual findings that the fixer could not mechanically resolve, the applied list of fixes made, and the resolved dict_version.

Raises: TypeError: If both in_place=True and out are given. ValueError: If only / exclude name a rule that is not fixable. Ags4Error: If in_place=True but the source is not a path (so there is nothing to overwrite) — use out=<path> or FixResult.save instead.

FixResult

FixResult(
    data: bytes,
    findings: list[dict],
    applied: list[dict],
    dict_version: str,
    risky_available: int = 0,
)

The product of fix — and the same object carried on Ags4File.fix_report after a handle is repaired.

Where Report tells you what is wrong with a file, FixResult is the answer after the fixer has had its turn: the mechanically repaired AGS4 document plus an honest account of what it could and could not put right. The headline payload is bytes — the rewritten file, always UTF-8 with no BOM, so a single fix run that targets a CRLF or encoding fault also normalises the file's line endings and encoding as a side effect. text decodes those bytes for you, and save writes them to a path (returning the ~pathlib.Path it wrote), for the common case where fix was called without in_place / out and you decide where the output lands.

The result is deliberately two-sided about success. applied is the ledger of every repair that was made — each a {kind, label, rule, line, risk} record, and fixes_applied is just its length — so you can show or audit exactly what changed. findings is the complement: the fixer re-validates its own output, so these are the issues that survived the repair and still need a human (each finding carries its rule alongside the usual per-rule fields). A run that leaves findings empty fixed everything; a run with entries did what it mechanically could and is telling you what it couldn't guess. dict_version records the AGS dictionary edition the repaired bytes were validated against, whether you pinned it or it was derived from the file's TRAN_AGS.

Attributes: bytes (bytes): The repaired AGS4 document, always UTF-8 with no BOM. findings (list[dict]): The issues that remain after fixing — what could not be mechanically resolved; each carries its rule plus the per-rule fields. applied (list[dict]): The fixes that were made, each a {kind, label, rule, line, risk} record. dict_version (str): The AGS dictionary edition the repaired bytes were validated against. fixes_applied (int): Count of applied fixes — len(applied). risky_available (int): How many further fixes risky=True would apply (intent-guessing fixes withheld from the safe set); 0 when risky was passed. A discoverability signal — non-zero means more is repairable without your having to guess that an opt-in tier exists. text (str): The repaired bytes decoded as UTF-8.

bytes instance-attribute

bytes = data

findings instance-attribute

findings = findings

applied instance-attribute

applied = applied

dict_version instance-attribute

dict_version = dict_version

risky_available instance-attribute

risky_available = risky_available

fixes_applied property

fixes_applied: int

text property

text: str

save

save(path: str | PathLike[str]) -> Path

diff

diff(
    a: Any,
    b: Any,
    *,
    dict_version: Edition | None = None,
    encoding: str | None = None,
) -> dict

Compare two AGS4 documents and return their revision diff.

a (the baseline) and b (the revision) are each anything read accepts — a path, AGS4 text, raw bytes, a file-like, or an Ags4File. The door answers the question "what actually changed between this submission and the last one" in AGS terms, not text terms.

Two design choices make the diff meaningful rather than noisy. Rows are matched by the group's dictionary KEY headings, not by line order — so a file whose boreholes have been re-sorted still pairs each LOCA against its prior self, and the result reports genuine row churn (groups_added / groups_removed) instead of a wholesale rewrite. Cells are compared through the typed value, so a formatting-only edit ("1.0""1.00", a re-padded coordinate) is not a diff; only a change in the parsed quantity counts. The dictionary edition used to locate the KEY headings is the revision's TRAN_AGS by default; pass dict_version to pin it when the file's own stamp is wrong or absent.

Returns a RevisionDelta dict — groups (a per-group list of row/heading deltas) plus groups_added / groups_removed and the total_added / total_removed / total_changed counts. This is the same engine the browser's revision-diff tool and lat diff <a> <b> use.

Args: a: The baseline document — a path, AGS4 text, raw bytes, a file-like, or an Ags4File (anything read accepts). b: The revision document, in any of the same forms as a. dict_version: Dictionary edition (e.g. "4.1") used to resolve each group's KEY headings. Defaults to None, which takes the edition from the revision's TRAN_AGS stamp. encoding: Source text encoding for both documents. Defaults to None (the native parser sniffs it).

Returns: A RevisionDelta dict: groups (per-group row/heading deltas), groups_added / groups_removed, and the total_added / total_removed / total_changed counts.

merge

merge(
    *sources: Any,
    on_type_clash: TypeClashMode = "error",
    dict_version: Edition | None = None,
    encoding: str | None = None,
    tran: TranStamp | None = None,
) -> MergeResult

Reconcile two or more AGS4 deliveries of one project into a single file.

Each of sources is anything read accepts — a path, AGS4 text, raw bytes, a file-like, or an Ags4File. They are merged in argument order, and that order is the authority: when two files carry the same row, the later argument wins. Rows are identified by their dictionary KEY headings, not line order, so a re-sorted borehole list still merges each LOCA onto its prior self instead of duplicating it. The merge is a union — a row present in one file and absent in another is kept, because silence is not deletion.

Columns are reconciled per heading. When two files declare the same heading with different TYPEs, on_type_clash decides — and the default ("error") refuses to guess, raising MergeConflictError:

  • "widen" falls back to X (text), keeping every raw value byte-for-byte. Lossless on the bytes, but it throws the column's TYPE away.
  • "promote" keeps the column numeric when every clashing code is in the nDP family: it takes the greatest precision (2DP + 5DP5DP) and zero-pads the coarser file's values (10.0010.00000), so the merged file satisfies Rule 8. No digit is ever changed — merge never rounds, never demotes, and a value it cannot pad losslessly is kept verbatim and warned about. nSF / nSCI and cross-family clashes fall back to "widen", because padding significant figures would overstate the precision the instrument actually measured.

"promote" is what lets a merged file still value-dedup against its own inputs: _content_hash canonicalises 10.00 as a number under 2DP but as a string under X, so a widened column no longer matches its typed source while a promoted one does.

A typed column widening or promoting around an unchanged value is not a revision — zero-padding changes the bytes but not the value.

A conflicting UNIT is fatal in every mode: TYPE has a universal absorber (X), UNIT has none, and silently picking one would mislabel the other file's values.

The merged file genuinely is a new transmission, so pass a tran to stamp it with a synthesised TRAN row, which also records the input issues/dates in TRAN_REM for provenance. Omit it and the inputs' own TRAN rows are reconciled like any other group, with a warning that no merge-transmission stamp was supplied.

Returns a MergeResult — the merged bytes plus the warnings and per-row revisions audit. This is the same engine lat merge uses.

Args: sources: Two or more documents to merge, each a path, AGS4 text, raw bytes, a file-like, or an Ags4File. Order is authority — a later argument wins a KEY conflict. on_type_clash: How to settle a heading two files typed differently — "error" (default, refuse), "widen" (fall back to X) or "promote" (keep the greatest nDP precision, zero-padding the coarser values). dict_version: Dictionary edition used to resolve each group's KEY headings. Defaults to None (taken from the newest file's TRAN_AGS). encoding: Source text encoding for every input. Defaults to None (sniffed). tran: The transmission the merged file represents, as a TranStamp. Omit it and TRAN is reconciled like any other group (newest wins), with a warning noting no merge-transmission stamp was supplied. remarks is appended* to merge's own provenance note ("Merged from N deliveries: …") rather than replacing it — both are true of the merged file.

Returns: A MergeResult: the merged bytes and the warnings / revisions audit.

Raises: MergeConflictError: A heading was typed differently by two files and on_type_clash="error" (the default) refused to settle it; two files declared conflicting UNITs (fatal in every mode); or the merged output failed to emit. ValueError: Fewer than two sources were given.

MergeResult

MergeResult(
    data: bytes, warnings: list[dict], revisions: list[dict]
)

The product of merge — the reconciled AGS4 document plus an honest audit of what merging had to resolve.

The headline payload is bytes — the single merged file, spec-correct UTF-8 with no BOM. text decodes it and save writes it to a path (returning the ~pathlib.Path written), for the common case where you let merge build the document and then decide where it lands.

The two report sides make the merge auditable rather than a black box. warnings is the advisory ledger — each a {kind, group, heading, message} record for something merge resolved without failing: a recency contradiction (a file stamped older carried the winning row), a non-X type widen, or a missing merge-TRAN stamp. revisions is the per-row change log — each a {group, key, changed, winner_file} record naming a KEY-matched row whose values a later file changed (compared through the typed value, so a formatting-only edit is not reported) and which input (winner_file, an argument index) supplied the winning content. An empty revisions means every shared row agreed; entries are exactly the rows a human should eyeball.

Attributes: bytes (bytes): The merged AGS4 document, spec-correct UTF-8 with no BOM. warnings (list[dict]): Advisory notes, each {kind, group, heading, message} — recency contradictions, non-X type widens, a missing merge-TRAN stamp. revisions (list[dict]): Per-row content revisions, each {group, key, changed, winner_file} — a later file changed a KEY-matched row. text (str): The merged bytes decoded as UTF-8.

bytes instance-attribute

bytes = data

warnings instance-attribute

warnings = warnings

revisions instance-attribute

revisions = revisions

text property

text: str

The merged bytes decoded as UTF-8.

save

save(path: Any) -> Any

Write the merged bytes to path; return the ~pathlib.Path written.

Rules

list_rules

list_rules() -> list[dict]

The rule catalogue the engine enforces — one dict per AGS4 rule with rule (e.g. "10c"), title, checks (a plain-English summary), severity ("error" / "fyi" / "mixed"), fixable (whether fix can repair it), and observations (the cited O-N divergence notes). Read-only and file-independent — sourced from the engine's gated rule metadata, so it always matches the rules validate actually runs.

fixable_rules

fixable_rules() -> list[dict]

The AGS Format Rules whose findings fix can repair — the subset of list_rules with fixable=True (each a rule / title / severity dict). The rule labels are exactly the valid values for fix(only=…, exclude=…) and the FixableRule type, so you can discover what's fixable without memorising the list. Sourced from the same gated rule metadata as list_rules (which the engine gates against the fix engine's own FIXABLE_RULE_LABELS).

FixableRule module-attribute

FixableRule = Literal[
    "1", "2a", "4", "6", "7", "8", "11a", "11b"
]

Querying

AgsQuery

AgsQuery(
    parent: Ags4File,
    *,
    filters: list[tuple[str, list]] | None = None,
    base: str | None = None,
    predicates: list[str] | None = None,
    projection: list[str] | None = None,
)

A lazy, chainable view over an Ags4File's DuckDB engine — the single query type returned by Ags4File.at and Ags4File.query. Nothing runs until a terminal. Two modes share the type:

Multi-group fan-out (from .at()) — key-filter several related groups at once. q[code] materialises one group with every applicable .at() filter, q.frames() pulls all related groups, q.groups lists them. .at() filters accumulate (AND); a group not carrying a filter's key column passes through unfiltered. Filters are parameterised (no SQL injection on the value lists).

Single result (from .query()) — one relation out. .filter(pred) adds a SQL WHERE fragment, .select(*cols) projects, and any .at() filters narrow it; finish with .frame() (handle backend), .to_polars(), .to_pandas(), or .relation() (the raw DuckDB relation, to chain more SQL).

.filter() / .select() / .query() build the single-result relation and don't apply to the multi-group accessors — mixing them raises rather than silently dropping a filter. Every chaining method returns a NEW AgsQuery (immutable builder); the parent handle's backend is inherited.

Future: .filter() today takes a SQL predicate string (use .at() for parameterised value lists); a later release may also accept typed column=value keywords or column-expression objects for an injection-safe, discoverable form — see the reliquary / future-work register.

groups property

groups: list[str]

The related groups — those carrying at least one .at() filter's key.

table class-attribute instance-attribute

table = __getitem__

at

at(group: str, values: Iterable[object]) -> AgsQuery

Add a parent-entity key filter — .at("LOCA", ["BH01", "BH02"]) keeps only rows whose {group}_ID (e.g. LOCA_ID) is in values. Filters accumulate with AND, and a group that doesn't carry a given filter's key column passes through unfiltered, so one .at() chain narrows a whole related record set at once. The value list is bound as a parameterised IN (no SQL injection on the values), unlike the SQL-fragment .filter().

Args: group: The parent group code whose {group}_ID key column is filtered (e.g. "LOCA"). values: The key values to keep — any iterable; an empty selection matches no rows.

Returns: AgsQuery: A new query with the added filter (the builder is immutable).

filter

filter(predicate: str) -> AgsQuery

Add a SQL WHERE fragment to the single-result relation (e.g. .filter("LOCA_GL > 100 AND LOCA_TYPE = 'CP'")), AND-ed with any others. Finish with a terminal (.frame() / …). For a parameterised value list use .at(); you compose the SQL fragment yourself, so don't interpolate untrusted values into it.

select

select(*columns: str) -> AgsQuery

Project the single-result relation to columns.

query

query(sql: str) -> AgsQuery

Set (or replace) the base SQL the single-result relation reads from.

pipe

pipe(
    fn: Callable[..., Any], /, *args: Any, **kwargs: Any
) -> Any

Apply fn(self, *args, **kwargs) and return its result — a functional escape hatch to slot a custom step into a query chain without leaving the fluent flow.

Args: fn: A callable taking this AgsQuery as its first argument. args: Extra positional arguments forwarded to fn. *kwargs: Extra keyword arguments forwarded to fn.

Returns: Whatever fn returns.

frames

frames() -> dict

{group: frame} for every related group, each filtered — pull a location's whole related record set in one call.

relation

relation() -> DuckDBPyRelation

The built DuckDB relation (lazy — .df() / pl.from_arrow to materialise, or chain more SQL). Requires a base set via Ags4File.query / query; .at() filters and .filter() predicates that apply to the base's columns narrow it, and .select() projects.

frame

frame() -> Any

Materialise the single-result relation to the handle's backend (polars / pandas).

to_polars

to_polars() -> DataFrame

Materialise the single-result relation to a polars frame (pyarrow-free), regardless of the handle's default backend.

to_pandas

to_pandas() -> Any

Materialise the single-result relation to a pandas frame (DuckDB's NumPy .df(), pyarrow-free), regardless of the handle's default backend.

Excel

from_excel

from_excel(
    source: str
    | PathLike[str]
    | bytes
    | bytearray
    | memoryview,
    output: str | PathLike[str] | None = None,
    *,
    format_numeric_columns: bool = True,
    backend: Backend = "polars",
    xn: XnMode = "string",
) -> dict | Ags4File

Convert an AGS4-shaped XLSX workbook to AGS4.

Rust-backed via laterite_excel (calamine). Each worksheet with a HEADING column becomes one group; columns not matching Rule 19's heading pattern are dropped. source is the .xlsx as a path or raw workbook bytes — so an uploaded .xlsx needn't hit disk. With output given, writes an AGS4 file and returns the Rust converter's stats; with output=None (default), returns a parsed Ags4File read straight from the conversion. format_numeric_columns (default True) re-formats DATA cells to their column's TYPE precision so floats from XLSX keep trailing zeros; backend / xn apply only to the returned-handle form.

Errors

Ags4Error

Ags4Error(message: str, *, exit_code: int | None = None)

Bases: Exception

Base for every laterite error. Carries the validator exit code.

exit_code class-attribute instance-attribute

exit_code: int = 1

BadDictError

BadDictError(message: str, *, exit_code: int | None = None)

Bases: Ags4Error

Bad --dict-version / unimplemented external --dict (O-28).

exit_code class-attribute instance-attribute

exit_code = 5

MergeConflictError

MergeConflictError(
    message: str, *, exit_code: int | None = None
)

Bases: Ags4Error

A merge could not be reconciled. Either two files typed the same heading differently and on_type_clash="error" (the default) refused to guess — pass "promote" to keep the greatest nDP precision, or "widen" to fall back to X text; or two files declared conflicting UNITs, which is fatal in every mode (no mode can absorb it — see below); or the merged output failed the emitter's own re-validation.

exit_code class-attribute instance-attribute

exit_code = 6

NotAgs4Error

NotAgs4Error(message: str, *, exit_code: int | None = None)

Bases: Ags4Error

Input has no GROUP rows — not a parseable AGS4 file.

exit_code class-attribute instance-attribute

exit_code = 4

StaleCertError

StaleCertError(
    message: str, *, exit_code: int | None = None
)

Bases: Ags4Error

A passed index= certificate (.ags.idx) does not match the file it was read for — its size/SHA-256 differ, so its byte offsets and clean verdict are now lies. Raised at read time (fail-fast): an explicit index= asserts "this cert is for this file", so a mismatch is an error, never a silent fall-back to re-validation. Rebuild it (read(p).validate().certify()).

exit_code class-attribute instance-attribute

exit_code = 4

UnsupportedEditionError

UnsupportedEditionError(
    message: str, *, exit_code: int | None = None
)

Bases: Ags4Error

A recognised but unsupported edition (AGS3). Clean-room refuses rather than silently validating it against an AGS4 schema (O-30).

exit_code class-attribute instance-attribute

exit_code = 4

WorldCheckRequiresSourceError

WorldCheckRequiresSourceError(
    message: str, *, exit_code: int | None = None
)

Bases: Ags4Error

check_files=True was asked of an input with no path — read of bytes or str, where there is no directory for the sibling FILE/ tree to be in.

Rule 20's on-disk half is the one check that reads state the AGS4 bytes do not contain, so it is the one check that cannot be answered from content alone. The engine used to answer it anyway — by dropping the request and reporting Rule 20 clean. Passing a path (read("delivery.ags")) makes the question answerable; dropping check_files makes it unasked. Both are honest; a silent clean was not.

exit_code class-attribute instance-attribute

exit_code = 5

Type aliases

The enumerated string choices accepted by the API, as Literal types — so editors autocomplete the valid values and type-checkers reject typos. Each is gated in the test suite against its source of truth.

Edition module-attribute

Edition = Literal['4.0.3', '4.0.4', '4.1', '4.1.1', '4.2']

Backend module-attribute

Backend = Literal['polars', 'pandas']

XnMode module-attribute

XnMode = Literal['string', 'numeric']

BuildMode module-attribute

BuildMode = Literal['autofix', 'report', 'strict']