Skip to content

Implement v2 API support - #194

Merged
amarcozzi merged 73 commits into
mainfrom
176-implement-v2-api-support
Aug 4, 2026
Merged

Implement v2 API support#194
amarcozzi merged 73 commits into
mainfrom
176-implement-v2-api-support

Conversation

@amarcozzi

Copy link
Copy Markdown
Contributor

Part of #176

What changed

  • Adds the complete FastFuels v2 beta package under fastfuels_sdk.v2, including the generated client and high-level Domain, Grid, Feature, Inventory, Export, and PointCloud APIs.
  • Adds shared authentication, job polling, upload, typed error, quota, data-access, modification, treatment, calibration, and grid-composition support.
  • Includes the post-regeneration API coverage completed in v2 post-regen backlog: 19 unwrapped operations + quota error path #192 / Complete v2 post-regen SDK backlog #193.
  • Packages v2 and its runtime dependencies while keeping the top-level fastfuels_sdk namespace on v1.
  • Adds versioned v1/v2 documentation and separate v1/v2 CI jobs.
  • Integrates current main, including the latest v1 documentation corrections.

Why

This is the integration branch for the v2 SDK preview. It brings the generated client, supported high-level interface, live tests, packaging, and user documentation together without changing the existing default v1 API.

User and developer impact

Existing import fastfuels_sdk users continue to receive the v1 interface. V2 is additive and explicitly selected with:

import fastfuels_sdk.v2 as ff

The release adds runtime dependencies used by v2: attrs, httpx, python-dateutil, and xarray.

Validation

  • uv run pre-commit run --all-files: passed
  • v1 strict documentation build: passed
  • v2 strict documentation build: passed
  • uv run pytest tests/v1 -q: 398 passed, 3 skipped
  • uv run pytest tests/v2 -q -rxX: 320 passed, 1 skipped
  • No recent sdk-test domains remained after the live suites
  • Current main merged cleanly after resolving the versioned mkdocs.yml configuration

Remaining release work

Issue #176 remains open for the stable v2 API domain decision and the 0.22 preview release.

amarcozzi added 30 commits June 5, 2026 12:25
Generated from the live v2 API spec (2.0.0-beta, OpenAPI 3.1) with
openapi-python-client 0.29.0 — attrs models + httpx, relative imports,
sync and async variants of every endpoint. Generator selection rationale
is recorded in fastfuels_sdk/v2/COMPARISON.md (following commit).

generate_clients.sh is the reproducible regeneration recipe. It patches
the spec before generation: the spec contains two schemas titled
'Feature' (the FastFuels feature resource and geojson-pydantic's GeoJSON
Feature), and openapi-python-client names classes from titles and
refuses duplicates — without the patch it silently drops every domain
endpoint. The proper fix is re-titling the model in FastFuels-API-v2.

The generated tree is excluded from pre-commit hooks like v1's, and from
distributions via the fastfuels_sdk/v2 build exclude (PR #181) until the
preview ships with its runtime deps (#176).

Part of #176
The draft modules are the seeds for the real v2 wrappers (#177-#180):
Domain at full v1 parity (from_id/from_geojson/from_geodataframe/
from_file/get/update/delete/list, to_geodataframe/to_json), and
job-based Grid (LANDFIRE FBFM40, TreeMap/PIM, 3DEP topography),
Feature (OSM road/water), and Inventory (PIM grid -> tree inventory)
with a shared get/wait_until_completed/delete/list lifecycle that
should be factored into a common base when promoted (#176).

COMPARISON.md records the openapi-generator vs openapi-python-client
evaluation that selected the latter: both generators were run against
the same spec and the same wrapper surface was written twice across all
four resources. Key findings: the spec is anyOf-saturated (38% of
schemas, structural to FastAPI/pydantic), openapi-generator 7.22.0
emits broken code for the GeoJSON coordinate schemas (cannot
deserialize any Domain) and wrapper-object ergonomics elsewhere, while
~68% of the wrapper code is generator-independent.

Part of #176
generate_clients.sh (plural) was named for the generator comparison era
when it produced both candidate clients. It is now the canonical
regeneration tool for fastfuels_sdk/v2/client_library/, so: singular
name, openapi-python-client pinned to 0.29.0 (regen diffs reflect API
changes only; bump the pin deliberately), and a header documenting the
regenerate-and-commit workflow.

Verified: running the renamed script reproduces the committed
client_library byte-for-byte (zero diff).
generate_client.sh now writes client_library/base_url.py so the script is
the single source of truth for the deployment URL (openapi-python-client
embeds no server URL and the spec has no servers entry). The regen also
picked up a new endpoint added to the spec since the last generation:
apply_modifications on tree inventories.
- exceptions.py: ApiException hierarchy (BadRequest/Unauthorized/
  Forbidden/NotFound/UnprocessableEntity/Service) with expect() and
  raise_for_response() translating sync_detailed() responses at a single
  choke point; tolerant HTTPValidationError parsing for the API's
  string-detail 422s
- api.py: set_api_key/get_client/ensure_client around one cached
  AuthenticatedClient (raise_on_unexpected_status=False so undocumented
  statuses reach the wrapper translation); base URL from the generated
  client_library.base_url with FASTFUELS_API_V2_URL override
- remaining drafts repointed at the shared client (the draft-era
  _checked helper moves into draft_grids_opc until #178-#180)
Full v1-parity surface (from_id/from_geojson/from_geodataframe/from_file,
get, update, delete, to_json, to_geodataframe, list_domains) built on the
shared api/exceptions infrastructure, plus the v2-only endpoints:
Domain.preview, Domain.get_lattice, and reproject_geojson. from_geojson
accepts a single Feature and wraps it in a FeatureCollection;
from_geodataframe forwards the GeoDataFrame CRS. Exported from
fastfuels_sdk.v2.

tests/v2/test_domains.py: 31 tests against the live v2 API (all passing),
covering creation across formats and CRSs, the read/update/delete
lifecycle, preview/reproject/lattice, and the typed exception paths
(404 -> NotFoundException, 422 -> UnprocessableEntityException).
- mike powers a version selector in the header (mkdocs-material's
  extra.version.provider): v1 (current, aliased latest) and v2 (preview)
  deploy as independent site snapshots, so existing /v1/... deep links
  keep working
- per-version configs (mkdocs.yml for v1, mkdocs-v2.yml for v2) inherit
  shared settings from mkdocs-base.yml; site_url set so switching
  versions keeps the reader on the equivalent page
- docs.yml deploys both versions via mike on release (plus manual
  workflow_dispatch); first run should follow a one-time gh-pages wipe
  to drop the stale pre-restructure root-level pages
- v2 docs: home, 'Migrating from v1' guide (per-resource what-changed
  sections seeded for grids/features/inventories), domains how-to guide,
  and an mkdocstrings reference for domains/api/exceptions
- styled the version selector as an explicit pill control (extra.css)
- mike added to the docs dependency group
One config now builds either version: DOCS_DIR selects the docs tree
(default docs/v1; docs/v2 for v2 via mkdocs' !ENV substitution) and each
tree carries its own nav in a SUMMARY.md (mkdocs-literate-nav). Replaces
the mkdocs-base.yml/mkdocs-v2.yml INHERIT split.

literate-nav is pinned to 0.6.2: 0.6.3 co-installs the properdocs
mkdocs-fork and warns when run under mkdocs proper; revisit when the
fork situation settles. check-yaml runs with --unsafe so it can parse
mkdocs' !ENV tag.
…ervice

mkdocs.yml, per the official mkdocs-material setup guides: automatic
light/dark palette toggle, repo link, navigation features sized for a
small site (sections/top/tracking/footer, toc.follow; instant nav
deliberately skipped — it conflicts with mike's cross-version redirect),
search suggest/highlight/share, code annotations, content tabs, and the
curated baseline markdown extensions. mkdocs-material pinned to <10.

Alignment with the FastFuels-Web documentation service
(docs.fastfuels.silvxlabs.com), which both properties' Diataxis rules now
codify in CLAUDE.md: version labels mirror the platform docs (v1 /
'v2 (Beta)', replacing 'preview'), the v2 pages cross-link to the
platform docs for concept explanations instead of duplicating them, the
domains guide opens with a Prerequisites section, and the migration
guide's before/after snippets use v1/v2 content tabs.

CLAUDE.md: documentation principles as shared context — the two-property
division of responsibility, the four Diataxis kinds with their authoring
rules, docstrings-are-user-facing-reference policy, and the versioned
docs conventions (DOCS_DIR/SUMMARY.md/mike, duplicated extra.css,
live-verified examples, frozen docs/v1).
Rewrite the draft on the domains.py pattern: every call goes
sync_detailed() + exceptions.expect() (201 creates, 204 delete -- the
draft fired the delete request and ignored the response entirely).

New surface beyond the draft: create_layerset and
create_layerset_from_geodataframe (projected-CRS fuelbed polygons,
synchronous upload), partitioned data access (get_data_metadata,
get_data_partition, get_data, to_geodataframe), and list_features grows
an optional domain_id -- omitting it routes to the cross-domain
endpoint (GET /domains/-/features) -- plus the type/product/tag
filters.

The job polling loop lands in _jobs.py as a plain shared helper
(Grid/Inventory will delegate to the same function), not a mixin;
wrappers keep their own small model-copy helpers.
34 tests against the live v2 API: OSM road/water creation, layerset
upload (incl. single-Feature wrapping, GeoDataFrame path, and the
geographic-CRS rejection), the wait/get/update/delete lifecycle,
partitioned data access, and list filters incl. the cross-domain route.

test_sorting is xfailed: every sort_by combination on the feature list
endpoints returns 500 from the live API (missing composite indexes);
the equivalent domain sorting works.

create_default_layerset_geojson() builds the test layerset from
blue_mtn_5070 (already projected) with the required fuelbed columns.
Features how-to guide (create from OSM, wait, layerset upload with the
projected-CRS warning, partitioned data access, list filters incl.
cross-domain, delete, error handling) with outputs verified against the
live API; nav and reference entries; migration guide's Features section
replaces the in-development placeholder with what-changed bullets and
before/after tabs.
The v2 resources form a dependency chain (domain -> features; grids and
inventories will hang off the same domain and can reference features as
spatial masks), so expensive job resources should be built once per
session, not once per module. conftest.py now owns test_domain,
road_feature (completed), and layerset_feature at session scope; the
grids/inventories modules will request them and add their own (e.g. a
completed PIM grid).

Convention: shared fixtures are READ-ONLY. The update test classes,
which previously mutated the shared resources (safe only by in-file
ordering), now mutate class-scoped throwaways.

Test domains carry an sdk-test fingerprint tag, and a session-start
sweeper deletes tagged domains older than two hours -- leftovers from
crashed or interrupted runs, where teardown never fires. The age gate
keeps concurrent runs (local + CI) from sweeping each other.
- The leak sweep is a plain function (tests/v2/utils.py
  sweep_leftover_domains), called in plain sight at the top of the
  test_domain fixture -- no more side-effect-as-fixture dependency
  injection, no isinstance paranoia around fields the live API always
  returns.
- road_feature becomes completed_road_feature: fixtures that wait on a
  job carry a completed_ prefix (layersets are born complete, so
  layerset_feature stays unprefixed).
- Tests that only need a feature to exist (from_id, get, to_json,
  cross-domain list) now use the cheap layerset_feature;
  completed_road_feature remains only where completion or road-ness is
  the point: the job-lifecycle test, the data-access tests, and the
  list-filter tests that need a road in the listing.
One entry point for both workflows:

  ./scripts/docs.sh          build v1 + v2 snapshots and serve the full
                             site (version selector included) locally
  ./scripts/docs.sh deploy   publish both versions to gh-pages

The docs workflow now calls the script, so versions, titles, and
aliases live in exactly one place.
The version selector was wired to the wrong axis. mike's model is
frozen snapshots of one docs tree, one per package release; pydantic
(which ships pydantic.v1 inside 2.x -- exactly our subpackage
situation) keys its selector to releases (dev, 2.13..2.0, 1.10) and
documents the legacy surface via a migration guide plus the frozen
old-release snapshot. Selector entries keyed to API surfaces exist only
in REST API portals (Stripe) -- the axis the platform docs correctly
use for the two deployed HTTP services, but wrong for one package that
ships both interfaces.

Now: one docs/ tree -- v1 content at the root (restoring the original
site URLs) and v2 as a 'v2 (Beta)' nav section -- with the nav inline
in mkdocs.yml, and the workflow deploys <major.minor> + latest with
mike on each GitHub release. Docs ship with the release that ships the
code. Deletes scripts/docs.sh, the DOCS_DIR/!ENV switch, literate-nav
and both SUMMARY.md navs, and the duplicate stylesheet.

Also fixes the v1 create_tree_grid docstring (SAVR -> savr), which
blocked --strict now that one build covers the v1 reference.
Seeing it rendered settled the question the other way: the SDK docs
keep the v1/v2 version dropdown, deliberately mirroring the platform
docs UX. The dual-tree mike setup is the accepted cost of that
dropdown. Keeps the create_tree_grid docstring fix (SAVR -> savr) from
the reverted commit.
)

Implements the settled hybrid v2 API: creation is module-level functions
(create_*_grid_from_*), and operations on a held resource are methods.

grids.py (new, #178):
- Grid record (subclasses the generated model) with refresh/wait/update/
  delete and the transform methods resample, lookup_fuel_model_values,
  and export.
- Creators: 3dep/landfire topography, landfire canopy, meta/naip CHM,
  landfire fbfm40/fccs, treemap (create_pim_grid_from_treemap), geotiff/
  netcdf upload, and uniform.
- Top-level list_grids/get_grid and check_3dep_coverage; shared
  _build_alignment helper translating output_resolution_m/align_to/align/
  resampling into the alignment union.

Wait model (_jobs.py): wait/wait_all polling refresh(), JobFailedError.

features.py/domains.py: move creation to module-level functions; uniform
lifecycle (get->refresh in place, wait, in-place update); Domain.delete
gains force= for cascade.

__init__.py: expose the grids/features submodules plus a flat top-level
surface (Grid, list_grids, get_grid, wait_all, JobFailedError, ...).

Remove the superseded drafts and COMPARISON.md. Add live tests in
test_grids.py and update the feature/domain tests and shared fixtures for
the function-based creators and the rename. v2_api_design.md captures the
working design notes.

Live-verified against the prod API: tests/v2/test_grids.py 50 passed,
2 skipped (deferred geotiff/netcdf upload paths).
Rename create_canopy_height_grid_from_naip ->
create_canopy_height_grid_from_naip_chm (function, __all__, test class +
call site, design-doc row) to match the NAIP-CHM product name.

Rewrite the docstring from the upstream NAIP-CHM model docs: it produces a
single continuous chm band (height in meters) at 0.6 m over CONUS, and is a
surface model (nDSM) that retains buildings and infrastructure as well as
vegetation — so vegetative-fuel-only workflows must mask built structures
via modifications=. Add a References section citing the publication
(Morford et al. 2025) and the dataset/model repository.
Build a GridModification (feature spatial condition + replace action) for a
creator's modifications= list — the v2 replacement for v1 feature_masks.
Lives in fastfuels_sdk/v2/modifications.py, exported as ff.mask; supports a
single band or a list, a Feature or bare id, and the operator/buffer_m/target
options. Covered by unit payload tests plus a live FBFM40-with-mask test.
Add workflow-first how-to guides for grids, split into Creating grids (by
source, with an aligned-grid-set worked example) and Working with grids
(lifecycle, inspection, resample, export). All examples are verified against
the live API with real output — band catalogs and units, georeference,
wait_all output, and the export record.

Rewrite features.md for the functional creators and the wait/refresh/update
lifecycle; fix the stale get()/in_place examples in domains.md; fill in the
grids and features sections of migration.md; and add grids, modifications,
and _jobs to the API reference plus a Grids group to the nav.
Inventory record with the uniform v2 lifecycle plus duplicate,
apply_modifications, voxelize (3D tree fuel grid), export, and
partitioned data access assembling to a pandas DataFrame. Creators:
create_tree_inventory_from_pim_grid / _from_chm_grid / _from_file
(signed-URL upload; the PUT must echo the signed
x-goog-content-length-range header). list_inventories routes to the
cross-domain endpoint when no domain is given.

Live tests: 29 passed, 1 xfail — in-place apply_modifications fails
server-side (FastFuels-API-v2#333).
New inventories how-to guide with live-captured examples; the
creating-grids voxelization section gains real inventory.voxelize code;
the migration guide's Inventories section is filled in (v1 exposed tree
data only through exports); inventories added to the Reference and nav.
Export record with the uniform v2 lifecycle plus to_file (streams the
signed URL; a directory target uses the export's default filename).
create_quicfire_export bundles fuel grids into the QUIC-Fire archive,
taking each role as a (grid, band) pair and the fire grid as
horizontal/vertical resolution or align_to. list_exports/get_export
address exports by id alone. grid.export and inventory.export now
return the wrapped Export, so create -> wait -> to_file chains.

Live tests: 21 passed, including the QUIC-Fire zip roundtrip.
New exports how-to guide with live-captured download and QUIC-Fire
bundle examples; the grid and inventory export sections now show the
working wait().to_file() flow; the migration guide gains an Exports
section (export_roi becomes explicit creation plus the server-side
bundle) and drops the stale grids-module note; exports added to the
Reference and nav.
Promote httpx/attrs/python-dateutil from the dev group to runtime
dependencies and replace the whole-tree fastfuels_sdk/v2 build exclude with
fine-grained excludes, so the v2 package ships in the wheel as a beta
preview. Drop the httpx<0.29.0 upper cap (inherited from the client
generator) to avoid resolution conflicts for installers.

Split the test workflow into test-v1 and test-v2 jobs. v1 and v2 are
separate deployments with separate keys, so each job authenticates its own
deployment via a per-job secret mapped to the shared FASTFUELS_API_KEY
variable. test-v2 is intended as a non-required check while v2 is beta.

api.py records the settled decision that both versions read FASTFUELS_API_KEY.

Part of #176.
amarcozzi added 27 commits June 13, 2026 13:57
The regen added Band.summary, a continuous/categorical union the server
populates per band when a grid completes. It was reachable through the raw
grid.bands[i].summary, but looking a band up by key was clunky.

Grid.band_summary(band) returns the summary for a named band (validating the
key like to_numpy), normalizing the not-yet-computed case to None. The returned
object is the generated ContinuousBandSummary (count/nodata_count/min_/max_/
mean/std) or CategoricalBandSummary (count/nodata_count/unique_count),
discriminated by type_.

Factored the band-key lookup + "no band" error out of to_numpy into a shared
Grid._band helper (used by both), so the validation can't drift.

Docs: a "Summarize a band without downloading it" section in working-with-grids.

Tests (TDD): pure unit tests for the accessor (returns the summary, None when
unset, raises on unknown band) plus a live continuous-summary check; the
existing to_numpy tests guard the _band refactor. Verified the field is None
while pending and populated after wait(). 3 unit + 3 live passed.
Consolidate the inventory modify/treat workflow into one focused How-To
(guides/modify-treat-inventories.md): modifications (conditions + actions),
treatments (ff.basal_area_treatment / ff.diameter_treatment), applying both at
creation and to a held inventory (apply_modifications / apply_treatments), and
the duplicate-to-branch pattern.

Trim the overlap in inventories.md per the Diataxis split: the detailed
"Modify trees at creation" / "Branch a scenario" sections become a short
"Reshape the trees" pointer plus a "Duplicate an inventory" lifecycle section,
both cross-linking the new guide. Add the guide to SUMMARY nav and retarget the
two stale inventories.md#modify-trees-at-creation links (creating-grids,
export tutorial) to it.

The guide notes the one gap it surfaces: inventory modifications are still
hand-built models while treatments/masks have builders (tracked as a follow-up
for an ff.modify vocab).

Validated with `mkdocs build --strict` (no broken links / nav errors).
Bring the scratch design note current after the client re-sync: update the
module layout (point_clouds.py, treatments.py, _uploads.py, _jobs.py; drop the
stale [later] tags on inventories/exports; add the new Grid methods) and add a
"Post-regen additions (2026-06-12)" section recording the new resources/
endpoints and the design calls behind them (GDAM + fbfm40-lookup are functions
not methods; treatments have builders but inventory modifications don't yet;
the shared put_upload header fix). Also reframed the modifications/treatments
vocab open item to match what shipped.

Docs/exports/__init__ exports and reference+SUMMARY nav for the new modules
were already updated as part of #14-#21; this closes out the housekeeping.
Close the vocab asymmetry the modify/treat guide surfaced: inventory
modifications were hand-built generated models while grids (ff.mask) and
inventory treatments (ff.basal_area_treatment) had builders.

Broaden modifications.py from grid-only to all modification primitives and add
four tree-scoped builders (exported top-level, string-coercing like mask):
- tree_attribute(attr, operator, value) -> InventoryModificationCondition;
  operator accepts symbolic ("<") or named ("lt") forms.
- tree_within(feature, buffer_m=, operator=) -> InventoryFeatureSpatialCondition
  (which treatments' conditions= also accept).
- remove_trees(*conditions) -> InventoryModification with RemoveAction.
- modify_trees(attr, modifier, value, *conditions) -> InventoryModification with
  an attribute action.
Both assemblers require >=1 condition (the API 422s on an empty rule).

The modify/treat guide now uses the builders (dropped the hand-built example
and the "no ff.modify builder yet" note); design note updated to mark the vocab
settled.

Tests (TDD): 14 pure unit tests (coercion, condition ANDing, RemoveAction vs
modifier, >=1-condition guard) red->green. Live: a create-time modification
(remove_trees(tree_attribute("dbh","<",10)) at seed 42) completes with strictly
fewer trees than the unmodified fixture — verified via the create path because
in-place apply_modifications is #333-blocked. 14 unit + 1 live passed.
@amarcozzi amarcozzi linked an issue Aug 4, 2026 that may be closed by this pull request
17 tasks
@amarcozzi
amarcozzi marked this pull request as ready for review August 4, 2026 23:04
@amarcozzi
amarcozzi merged commit a079acc into main Aug 4, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement v2 API support

1 participant