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.0 →
10.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_version — raises, 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 Report — count / 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 ifread(..., 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
¶
backend
property
¶
The frame type a materialising call (ags["LOCA"], .frame()) hands
back — "polars" (default) or "pandas", as fixed at read time.
tran_ags
property
¶
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
¶
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
¶
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
¶
headings
¶
The ordered HEADING names of group code (raises KeyError if the group
is not in the file). Pure metadata — no engine spin-up.
units
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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 YOUR frame (polars / pandas / pyarrow / arro3) into the
engine as name, so sql() can join it against the AGS groups.
close
¶
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.ags → delivery.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
¶
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
¶
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.
Report
¶
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).
resolution
property
¶
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).
certified
property
¶
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
¶
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.
findings
property
¶
Polars frame, one row per finding. Columns:
rule—"AGS Format Rule N"(or an"FYI …"key for FYI findings).line— nullableInt64source line.group/desc— the AGS group code and the human-readable message.severity—"error"(default),"warning"or"fyi"; lets avalidate(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
¶
{"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
¶
{file, findings:{"AGS Format Rule N":[{line,group,desc}]}} —
byte-identical to lat validate --json.
to_ndjson
¶
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
PROJinstance 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 ofPROJ, so reach for the(code, frame)form if you need to carry those — or passsynthesise_metadata=Trueto have the derivable ones (UNIT/TYPE from your data, and ABBR when PA picklist codes are used) minted for you; passtran=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",
),
)
BuildResult
¶
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).
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.
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 toX(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 thenDPfamily: it takes the greatest precision (2DP+5DP→5DP) and zero-pads the coarser file's values (10.00→10.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/nSCIand 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
¶
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.
Rules¶
list_rules
¶
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
¶
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).
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
¶
The related groups — those carrying at least one .at() filter's key.
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.
query
¶
query(sql: str) -> AgsQuery
Set (or replace) the base SQL the single-result relation reads from.
pipe
¶
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
¶
{group: frame} for every related group, each filtered — pull a
location's whole related record set in one call.
relation
¶
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
¶
Materialise the single-result relation to the handle's backend (polars / pandas).
to_polars
¶
Materialise the single-result relation to a polars frame (pyarrow-free), regardless of the handle's default backend.
to_pandas
¶
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
¶
Bases: Exception
Base for every laterite error. Carries the validator exit code.
BadDictError
¶
MergeConflictError
¶
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.
NotAgs4Error
¶
StaleCertError
¶
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()).
UnsupportedEditionError
¶
WorldCheckRequiresSourceError
¶
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.
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.