Changelog¶
All notable changes to restgdf are documented here. This project follows Semantic Versioning.
Unreleased¶
[3.2.0] - 2026-07-24¶
Added¶
FeatureLayer.from_config/Directory.from_config— opt-in classmethods that build a prepared instance from an explicitrestgdf.Config, applyingconfig.auth.tokenas the request token before delegating tofrom_url(W5-14 / CONFIG-02). They are deliberately explicit:configis a required argument, so nothing implicitly reads the process-globalget_config()at construction, and no globalConfigis threaded into the request path (CONFIG-03). The forwarded token rides the standardtoken=path (POST-forced by AUTH-01, never URL-serialized); for header-transport secured services prefer building anArcGISTokenSession.from_config(...)and passing it assession=.
Changed¶
Multi-page offset/count pagination now sends a deterministic
orderByFields. When a layer is traversed with explicitresultOffset/resultRecordCountpages (theget_gdf/get_df/stream_*common path), each batch now defaultsorderByFieldsto the layer’s resolved OID field unless the caller already supplied one — Esri’s documented remedy for reliableresultOffsetpaging, which otherwise leaves row order server-dependent and can silently duplicate or drop features across page boundaries (W4-2 / PAGINATION-02). A caller-suppliedorderByFields(any case) is never overridden, and a layer whose OID cannot be resolved degrades gracefully to today’s un-sorted batches. Wire-payload change (semver-relevant): explicit-pagination request bodies now carry anorderByFieldsmember. The OID-chunked WHERE-fallback andon_truncation='split'paths are unchanged.The GeoDataFrame path now raises on truncated responses instead of silently dropping rows.
get_gdfandstream_gdf_chunks(viachunk_generator→get_sub_gdf) now inspect each page’s parsed JSON forexceededTransferLimit=trueand raiserestgdf.errors.PaginationError(mirroring the raw-feature engine), rather than returning a GeoDataFrame that is silently missing rows when ArcGIS hits its byte/geometry transfer cap (W4-1 / PAGINATION-01, the audit’s flagship silent-data-loss fix). The flag is read from the response JSON directly because pyogrio/read_filediscards the top-level member. Behavior change: code that previously received a short-but-successful GeoDataFrame on a capped layer now gets aPaginationError; page the layer (smallerresultRecordCount, a tighterwhere, or theiter_pagesengine withon_truncation='split') to read it completely.The default
User-Agenton ArcGIS REST data requests is now sourced fromget_config().transport.user_agent(defaultrestgdf/<version>, e.g.restgdf/3.1.0) instead of the hardcoded"Mozilla/5.0"(restgdf.utils._http.default_headers, W2-10 / CONFIG-01 / AUTH-03). Wire-visible behavior change: some Esri deployments sniff theUser-Agent, so a WAF or usage policy keyed on"Mozilla/5.0"may now seerestgdf/<version>. Override it explicitly per request (headers={"User-Agent": ...}, still wins) or globally viaRESTGDF_TRANSPORT_USER_AGENT/TransportConfig.user_agent. The exportedDEFAULT_METADATA_HEADERSconstant keeps its historical value for back-compat but no longer determines the wireUser-Agent.Setting a currently-inert
RESTGDF_*knob now warns.Config.from_env(henceget_config()) emits onerestgdf._config.InertConfigWarning(aUserWarning) naming any set-but-unwiredRESTGDF_RETRY_*/RESTGDF_LIMITER_*/RESTGDF_AUTH_REFRESH_THRESHOLD_Svariable — those validate intoConfigbut no live path consumes them (the resilience executor hardcodes its retry/limiter policy; token sessions never readAuthConfig.refresh_threshold_s). The knobs and their env aliases stay wired for back-compat; real executor/session wiring is deferred (warn-now, wire-later; W2-13 / TRANSPORT-01 / AUTH-04). Silence withwarnings.filterwarnings("ignore", category=restgdf._config.InertConfigWarning).nested_count/FeatureLayer.get_nested_countnow require exactly two fields. Passing any other arity raises a clearValueErrorinstead of anIndexErrordeep in post-processing (one field) or leaving a redundant*_countcolumn with an incomplete sort (three or more) (W5-3 / API-04).Schema-drift log records now carry the originating service context (in the message and a
drift_contextextra field) so the first, non-deduped occurrence is attributable to which service drifted. The dedupe key stays the context-free(model, field, kind, type)4-tuple, preserving the anti-spam guarantee for many-layer servers (W5-13 / TELEMETRY-02).
Added¶
ArcGISTokenSession.from_config(session, credentials, config=...)andTokenSessionConfig.from_auth_config(auth_config, credentials)— opt-in factories that thread anAuthConfignamespace (itstransport,header_name,referer,token_url, andrefresh_leeway_s/clock_skew_srefresh knobs) onto a validated token session (W3-3 / W2-11, CONFIG-02 / AUTH-04). This is the only sanctioned wayAuthConfigreaches a session: it is strictly opt-in and read only at call time.ArcGISTokenSessionconstruction remains unchanged —__post_init__never reads the process-globalget_config(), so a plainArcGISTokenSession(session, credentials)keeps its dataclass defaults (e.g.token_refresh_threshold=60rather than the AuthConfig-derived150). Whenconfigis omitted,from_configreadsget_config().auth.
Fixed¶
A 4xx from
/generateTokenno longer escapes as a rawaiohttp.ClientResponseError.ArcGISTokenSession.update_tokennow maps a true-HTTP400/401/403credential rejection torestgdf.errors.InvalidCredentialsErrorand any other non-2xx torestgdf.errors.RestgdfResponseError, both under theRestgdfErrorumbrella and chaining the originating aiohttp error as__cause__(W2-2 / AUTH-02 / ERRTAX-01). The error is raised deterministically (not retried). The HTTP-200{"error": {...}}bad-credentials envelope path is unchanged (stillRestgdfResponseErrorvia the strictTokenResponsetier). TheInvalidCredentialsErrordocstring now describes the 4xx contract, andTokenRequiredErroris documented as reserved/not-raised (a 499 surfaces asAuthNotAttachedError, the single live 499 raise site).The token refresh retry filter no longer swallows deterministic auth errors. restgdf’s own exceptions co-inherit
OSErrorviaPermissionError(AuthenticationError→PermissionError→OSError), so a None-credentialsAuthenticationError(and the new W2-2InvalidCredentialsError) were being caught by theOSErrorretry bucket, retried three times, and re-raised as the wrong class (TokenRefreshFailedError). Anexcept RestgdfError: raiseguard now sits before the retryable-error handler, so deterministic restgdf errors propagate immediately with zero backoff, distinguished by real exception instance (MRO) rather than by class name. Genuine transientOSError/ConnectionError/asyncio.TimeoutErrorstill hit the retry ladder (W2-3 / ERRTAX-02).ArcGISTokenSessionreactive token refresh is now single-flight under concurrent498 Invalid Tokenresponses: it snapshots the token before the request and, inside the refresh lock, only re-mints when the token is still unchanged. Previously N concurrent 498s issued N/generateTokencalls; now they collapse onto one, and the later requests retry with the freshly minted token (W2-4 / ASYNC-01).A referer-bound
ArcGISTokenSession(built fromAGOLUserPass(referer=...)/TokenSessionConfig.referer) now attaches a matchingRefererHTTP header to its data requests, not only to the/generateTokenmint — so aclient="referer"token is honoured end-to-end instead of being rejected (498/499) on the query. Aclient="requestip"(non-referer) session attaches noRefererheader (no referer leak). Closes the “planned follow-up” limitation noted in MIGRATION.md for the 3.1 referer feature (#175 review NOTE-1).ArcGISTokenSessionnow forwards its ownverify_sslflag to token-attached data requests (not just the/generateTokenPOST), viasetdefault("ssl", self.verify_ssl)in_call_with_auth_retry— so a session built withverify_ssl=False(self-signed ArcGIS Enterprise) no longer fails TLS verification on the actual query/metadata requests. A caller-suppliedssl=still wins (W2-10 / CONFIG-01 / AUTH-03).The library-owned session that
get_gdfbuilds when called withsession=Noneis now constructed with aTCPConnectorwhosesslpolicy comes fromget_config().transport.verify_ssl(defaultTrue), soRESTGDF_TRANSPORT_VERIFY_SSL=false/TransportConfig(verify_ssl=False)is finally honored on the flagship GeoDataFrame data path — previously the bareClientSession()used the default connector and silently kept TLS verification on. A caller-supplied session is passed through untouched (its own connector owns its TLS policy). This completes the three-seam verify_ssl wiring (config source W3-1, token/_httpW2-10, getgdf connector W4-5 / CONFIG-01 / AUTH-03).FeatureLayer.get_gdf/get_unique_values/get_value_counts/get_nested_countnow return an independent copy of the cached frame/list on every call (W5-1, ASYNC-02) instead of the shared cached object by reference. Previously, mutating a returnedGeoDataFrameorDataFramein place (e.g.df.rename(..., inplace=True)) silently corrupted what every later call on the same instance returned. Cache population is unaffected and remains non-atomic under concurrentasyncio.gatherawaiters — this fix protects reads only, not writes.get_fields(types=True),_field_rows, andget_fields_frameno longer raiseKeyError/AttributeErroron a permissive-tier field entry missingnameand/ortype(W5-4, ADAPTERS-01) — a real ArcGIS server can emit such entries andFieldSpecalready declares both as optional. A field with no resolvablenameis now silently dropped (documented in the affected docstrings); a missing/Nonetypedefaults to""instead of crashing onNone.replace(...).get_fields(types=False)gained the same nameless-field guard.The
_SpanContextFilterlog-correlation filter now always stampsrecord.trace_id/record.span_id(defaulting to""outside a valid OpenTelemetry span) instead of leaving them unset (W5-12, TELEMETRY-01). Previously, anyrestgdf.*log record emitted outside afeature_layer.streamspan — e.g. theauth.refresh.startDEBUG record or the paginationexceededTransferLimitwarning — had notrace_id/span_idattributes at all, so the documented%(trace_id)slog-correlation recipe raisedValueError: Formatting field not found in record: 'trace_id'(surfaced by stdlibloggingas a swallowed stderr “— Logging error —” dump).span_context_fields()is unaffected and still returns{}outside a span.restgdf.utils.token._auth_logger(therestgdf.authlogger backing token-refresh debug logging) is now created through the library’sget_logger("auth")factory instead of a rawlogging.getLogger(...)call, so it carries the documentedNullHandlerlike every otherrestgdf.*logger (it previously silently lacked one).restgdf.resilience._errors._parse_retry_afternow rejects non-finiteRetry-Afterheader values ("nan","inf","-inf","Infinity", etc.) instead of returning them as a poisonedfloat. Previously aNaN/+Infvalue passed the existing negative-value guard unmolested and could reach the 429 cooldown deadline computation and the publicRateLimitError.retry_afterattribute.The optional-dependency gate (
require_pandas/require_geopandas/require_pyogrioand friends) now catchesImportErrorinstead of onlyModuleNotFoundError, so a present-but-broken geo dependency (e.g. a native GDAL/shapely load failure) surfaces asOptionalDependencyErrornaming therestgdf[geo]hint instead of escaping as a raw, unwrappedImportError.Removed the false
.envfile-loading claims fromdocs/configuration.rst’s precedence list anddocs/authentication.rst’s credentials recipe.restgdfhas never read.envfiles or depended onpython-dotenv/pydantic-settings—Config.from_env()resolves only from the process environment (os.environ). The docs now show how to opt in explicitly withpython-dotenvyourself (W3-5, CONFIG-04).FeatureLayer.get_value_counts/get_nested_countnow send a conservative statistics body. The instance requestdata(carryingreturnGeometry=True/outFields="*"/returnCountOnly=False) previously clobbered the stats-only flags, so the server received a geometry+all-fields query instead of the grouped statistics one. The bodies now forward onlywhere+tokenfrom the caller data (matchingget_unique_values), so the stats flags win while the instancewherefilter is preserved (W5-2 / API-01).resolve_domains(used byFeatureLayer.get_df(resolve_domains=True)) is now robust to malformed-but-real domain metadata: a non-dictdomainis skipped instead of raisingAttributeError, and coded values are mapped only when they carry bothcodeandname. A name-less code passes through unchanged rather than being silently replaced withNaN(W5-6 / ADAPTERS-03).iter_pages/stream_*’son_truncation='split'path no longer re-fetches the OID list at every recursion node (each bisected half now reuses the parent’s already-materialized slice) and no longer emits an unboundedIN (...)literal for a single oversized half — a half exceeding a new 1000-element cap (the common ArcGIS backing-store IN-predicate limit) is bisected further before being fetched, instead of being sent as one arbitrarily large literal list (W4-3, PAGINATION-03). No caller-visible contract change (same pages yielded, sameon_truncationsemantics).
3.1.0 - 2026-07-24¶
Added¶
restgdf.utils.getinfo.build_spatial_filter_payload(geometry, *, in_sr=None, spatial_rel="esriSpatialRelIntersects")— pure helper that converts an ArcGIS-JSON geometry, a GeoJSON-style mapping, or any object exposing__geo_interface__(e.g. shapely geometries) into the ArcGIS RESTgeometry/geometryType/spatialRel(+ optionalinSR) query-payload fragment. Handles points, multipoints, polylines, polygons, envelopes, curve paths / curve rings, and 3D/ZM coordinates, and stampshasZ/hasMon array-based geometries when the coordinates carry Z/M ordinates. Re-exported throughgetinfo.__all__following thebuild_pagination_planpattern (not a top-levelrestgdfexport).
Fixed¶
Static type-checkers can now resolve
restgdf.FieldDoesNotExistError. It was already exported at runtime (__all__and the lazy-export table), but missing from theTYPE_CHECKINGimport block that backs static analysis, somypy/pyrightreported an unresolved attribute even though the name worked fine at runtime.
Changed¶
The
mypytype gate now runs against real dependency types. A dedicated CI job installs every extra plusmypyand enables thepydantic.mypyplugin (via[tool.mypy] plugins), so it type-checks against actualaiohttp/pydantictypes instead of reporting green over unresolved imports;pandas/geopandasare scope-silenced withignore_missing_imports(their integration boundary is intentionally untyped). The internal type errors the un-defanged gate surfaced were fixed — drift alias-choice narrowing, the metadata field-row annotation, bounded-retry exception typing, and wideningget_gdf’ssessionparameter to theAsyncHTTPSessiontransport protocol (a runtime contract thataiohttp.ClientSessionsatisfies at runtime but, under current aiohttp stubs, not as a static subtype).Raised the supported Python floor to 3.11 (3.9 is EOL 2025-10-31; 3.10 reaches EOL 2026-10-31); CI now tests 3.11–3.14.
AGOLUserPass(referer=...)is now honoured at token-mint time.ArcGISTokenSession.__post_init__threads the credential’srefererinto the auto-builtTokenSessionConfig, sotoken_request_payloadswitches the ArcGISclientfield from"requestip"to"referer"and adds"referer": <url>to the/generateTokenPOST body. Previously this was silently ignored — the credentials-only constructor built its config without a referer, soAGOLUserPass(..., referer=...)had no effect on the minted token. SeeMIGRATION.mdfor the request-timeReferer-header limitation this interacts with.Token-mint requests now send an explicit
expirationfield.token_request_payloademitsAGOLUserPass.expiration(default 60 minutes) and the deprecated synchronousget_tokenhelper sendsexpiration=60. Behaviourally equivalent to the ArcGIS server-side default of 60 minutes; the value is now explicit on the wire.The
devextra now composesrestgdf[doc]and addsbuild+twine, sopip install -e ".[dev]"alone is enough to run every gate inCONTRIBUTING.md’s gate suite (docs build, packaging metadata sanity) without installingdocseparately.
Security¶
AUTH-01: a caller-supplied token in the request body is no longer serialized into the URL query string. On the documented
FeatureLayer(token=...)/data={"token": ...}path, a short token-bearing request was routed viaGET, leaking the ArcGIS token into the URL — where it lands in server / proxy / WAF access logs andRefererheaders — on a plainaiohttp.ClientSessionor a default header-modeArcGISTokenSession._arcgis_requestnow forcesPOST(token in the request body) whenever the outgoing body carries atokenkey, regardless of session transport. This complements the existing session-transport guard; ArcGIS/queryand metadata roots already accept form-encodedPOSTbodies, so the wire contract is unchanged for servers. Tokenless requests keep the length-basedGET/POSTrouting.
3.0.0 - 2026-05-02¶
restgdf 3.0.0 is a major, backwards-incompatible rewrite. The package
splits into a light async core (aiohttp + pydantic v2) plus three
opt-in extras — restgdf[geo] (GeoPandas/pandas/pyogrio),
restgdf[resilience] (ResilientSession/ResilienceConfig retry and
rate-limiting via stamina/aiolimiter), and restgdf[telemetry]
(RestgdfInstrumentor OpenTelemetry instrumentation) — so a base
install stays dependency-light. A typed streaming surface
(iter_pages, stream_features, stream_feature_batches,
stream_rows, stream_gdf_chunks) replaces the legacy row/feature
helpers, with query-verb selection centralized in _choose_verb/
_arcgis_request (restgdf/utils/_http.py). restgdf.Config adds
layered, frozen pydantic sub-configs resolved from
RESTGDF_<CATEGORY>_<FIELD> env vars, and restgdf.errors grows a
wider exception taxonomy including a five-member AuthenticationError
hierarchy. See MIGRATION.md for the full breaking-change list; the
detailed change set below was carried forward from the pre-release
tranche.
Changed¶
Gate-3 hardening follow-up. Three review-driven safety fixes land on top of the v3-followup tranche:
ArcGIS requests routed through
_choose_verbnow forcePOSTwhenever the effective session transport is"body"or"query"(including wrappedResilientSession(ArcGISTokenSession(...))stacks), preventing auth tokens from leaking into URL query strings on short requests.restgdf.resilience._retry._RetriedCtxnow mirrorsaiohttp’s dual request-manager shape soawait session.get(...)andasync with session.get(...)both work againstResilientSession.getgdf._advertised_max_record_count_factor()now rejectsbool,NaN, and infinity inputs so malformed vendor metadata falls back to the byte-identical pre-T9 path.Module-level
get_gdf(..., session=None)now closes the temporaryaiohttp.ClientSessionit creates internally, eliminating the unclosed-session leak on direct helper usage._iter_pages_raw(..., max_concurrent_pages=N)now keeps at mostNfetch tasks scheduled at once instead of pre-creating one task per page and only bounding execution with a semaphore, preventing pagination plans with many batches from exploding task memory.Legacy streaming helpers (
_feature_batch_generator,chunk_generator) now honor the repository-wide concurrency cap while they stream results and cancel outstanding work on early generator close, preventing abandoned page-fetch tasks from accumulating behind partially-consumed iterators.Hypothesis-backed property tests now live behind a dedicated
pytest --run-stressopt-in so the default suite remains a representative production-validation pass instead of mixing in a separate stress tier by default.
Pagination planner wiring. When an ArcGIS layer advertises
advancedQueryCapabilities.maxRecordCountFactor,get_query_data_batchesnow forwards that value tobuild_pagination_plan(advertised_factor=...). The wiring is strictly opt-in: servers that do not expose the field (or expose a non-positive / non-numeric value) get the previous byte-exact plan with noadvertised_factorkwarg. This replaces the deferred-plumbing stub.Feature-count retry delegation.
restgdf.utils.getinfo._feature_count_with_timeoutnow delegates its bounded timeout-retry loop torestgdf.resilience.bounded_retry_timeout(a new public helper) when theresilienceextra is installed, giving restgdf a single stamina-backed source of truth for retry semantics. When the extra is absent, the previous inline loop is preserved byte-for-byte as a fallback. The retryable exception set (asyncio.TimeoutError,TimeoutError,aiohttp.ServerTimeoutError) and R-69 (ClientConnectionErrorpropagates without retry) are preserved on both paths.GET/POST verb selection wiring. ArcGIS query requests now route through a single
_arcgis_requesthelper inrestgdf/utils/_http.pythat consults_choose_verb(8,192-byte threshold on URL + urlencoded body). Previously every call site was hard-codedPOST. Nine call sites acrossutils/getgdf.py,utils/getinfo.py, andutils/_query.pywere migrated. The GET path coercesbool/Nonevalues inparamsto"true"/"false"/""so yarl can serialize them; POST payloads are untouched. Zero behavior change for bodies above the threshold.Transport typing.
ArcGISTokenSessionnow exposesclose()andclosedthat delegate to its inneraiohttp.ClientSession, making it fully satisfy therestgdf._client._protocols.AsyncHTTPSessionProtocol. Internal call sites previously typedaiohttp.ClientSession | ArcGISTokenSessionwere widened toAsyncHTTPSessionacrossadapters/stream.py,directory/directory.py,featurelayer/featurelayer.py,utils/crawl.py,utils/getgdf.py,utils/getinfo.py,utils/_query.py, andutils/_stats.py. Zero runtime behavior change — widening to a superset Protocol is backwards-compatible for existing callers.
Added¶
Pagination¶
restgdf.errors.PaginationInconsistencyWarning— newUserWarningsubclass emitted by_resolve_pagewhen a batch page returns zero features but the server still setsexceededTransferLimit=true. The warning fires regardless of theon_truncationmode ("raise","ignore", or"split") so pathological server responses are always surfaced. Deliberately not included inrestgdf.errors.__all__or the top-level public API — warnings live outside theRestgdfErrortaxonomy; import viafrom restgdf.errors import PaginationInconsistencyWarning.
Domain resolution¶
FeatureLayer.get_df(resolve_domains=False)— new kwarg on the pandas-first tabular accessor. WhenTrue, coded-value domain fields are replaced in-place with their human-readable names using a single cached pass over the layer’s metadata (no per-row HTTP). Defaults toFalseso the base code path is byte-identical for existing callers.restgdf.adapters.pandas.resolve_domains(df, fields)— public helper exposing the same resolution logic for callers already holding apandas.DataFrame. Requires thegeoextra (pandas is part of that install surface).
Resilience¶
restgdf.resilience.bounded_retry_timeout— new public helper exposing a stamina-backed bounded retry loop for timeout-class exceptions. Used internally by_feature_count_with_timeoutwhen theresilienceextra is installed; safe for consumers on the same extra. The retryable exception set matches restgdf’s internal timeout policy (asyncio.TimeoutError,TimeoutError,aiohttp.ServerTimeoutError);aiohttp.ClientConnectionErrorpropagates immediately (R-69 preserved).
Streaming¶
FeatureLayer.iter_pages— low-level async generator yielding raw ArcGIS query-page envelopes withorder("request"default /"completion"),max_concurrent_pages(optional semaphore bound), andon_truncation("raise"default /"ignore"/"split"). The"split"strategy bisects the predicate’s OID list viaget_object_idsand recurses up to depth 32 before raising. Truncated pages under"ignore"log a structured warning on therestgdf.paginationlogger and continue.FeatureLayer.iter_features/FeatureLayer.stream_features— flatteniter_pagesinto individual feature dicts. Deliberate aliases:stream_featuresis the canonical public entrypoint,iter_featuresthe lower-level primitive.FeatureLayer.stream_feature_batches— yields onelist[feature_dict]per page, mirroringiter_pagesboundaries.FeatureLayer.stream_rows— yields row-shaped dicts (attributesmerged with rawgeometry). Pandas/GeoPandas-free; safe on a base install.FeatureLayer.stream_gdf_chunks— yieldsGeoDataFramechunks over the optional geo stack; each chunk inheritsattrs["spatial_reference"].iter_pagesnow emits exactly onefeature_layer.streamINTERNAL parent span wrapping the per-page loop when telemetry is enabled; no restgdf-owned per-page spans are emitted. No-op whenRESTGDF_TELEMETRY_ENABLEDis unset. Constructed insiderestgdf.utils.getgdf._iter_pages_raw.Spatial-reference propagation:
restgdf.utils.getgdf.get_gdf,FeatureLayer.get_gdf,FeatureLayer.sample_gdf,FeatureLayer.head_gdf, andchunk_generator/FeatureLayer.stream_gdf_chunksall stampgdf.attrs["spatial_reference"]with the raw dict from the layer’s metadata envelope (extent.spatialReferencepreferred, top-levelspatialReferencefallback). Normalization usesrestgdf.utils._metadata.normalize_spatial_reference.
Adapters and tabular output¶
restgdf.adapterssubpackage (lazy-loaded via PEP 562): four submodules covering dict / stream / pandas / geopandas shapes. All submodules are base-install safe at import time; pandas and geopandas are required only at call time and raiseOptionalDependencyErrorwhen missing.restgdf.adapters.dict—feature_to_row,features_to_rows, plusas_dict/as_json_dictre-exports.restgdf.adapters.stream—iter_feature_batches,iter_rows,iter_gdf_chunks.restgdf.adapters.pandas—rows_to_dataframe(sync) +arows_to_dataframe(async).restgdf.adapters.geopandas—rows_to_geodataframe+arows_to_geodataframe.
FeatureLayer.get_df()— async pandas-first tabular accessor. Sibling toget_gdf()that returns apandas.DataFramefrom the same row stream and does not require the geo extra.
Configuration¶
restgdf.Config— frozen pydantic 2.x aggregate of eight frozen sub-configs (TransportConfig,TimeoutConfig,RetryConfig,LimiterConfig,ConcurrencyConfig,AuthConfig,TelemetryConfig,ResilienceConfig) plusConfig.from_env(env=None)classmethod. Sub-configs and the aggregate are immutable at both slot and nested-field level.restgdf.get_config()— process-wide cachedConfigaccessor (functools.lru_cache(maxsize=1)).restgdf.reset_config_cache()— clears the cache; cascades bidirectionally with the existingreset_settings_cacheso tests can refresh all configuration with a single call regardless of which accessor they use.Nested env-var surface
RESTGDF_<CATEGORY>_<FIELD>wired throughConfig.from_envfor every sub-config field (RESTGDF_TRANSPORT_USER_AGENT,RESTGDF_TIMEOUT_TOTAL_S,RESTGDF_RETRY_ENABLED,RESTGDF_LIMITER_RATE_PER_HOST,RESTGDF_CONCURRENCY_MAX_CONCURRENT_REQUESTS,RESTGDF_AUTH_TOKEN_URL,RESTGDF_TELEMETRY_LOG_LEVEL, …). Invalid coercions and validator rejections raiseRestgdfResponseErrorwith the underlyingpydantic.ValidationErrorpreserved as__cause__.Settings.max_concurrent_requests: int = 8(field) +RESTGDF_MAX_CONCURRENT_REQUESTSenv-var coercion (BL-01). Default matches aiohttpTCPConnectorpool size.
Errors¶
restgdf.errorsmodule exposing the canonical exception taxonomy:RestgdfError,ConfigurationError,OptionalDependencyError,TransportError,RestgdfTimeoutError,RateLimitError,ArcGISServiceError,PaginationError,FieldDoesNotExistError,SchemaValidationError,AuthenticationError, andOutputConversionError. All re-exported from the top-levelrestgdfpackage via the lazy-import hook.PaginationError.batch_index/.page_sizeattributes carry pagination context when cursor-based iteration fails.RateLimitError.retry_afterattribute carries the optional seconds-until-retry hint, populated from the server’sRetry-Afterheader (integer seconds or RFC 7231 HTTP-date) by the resilience wrapper (Q-A12). New helper_parse_retry_afterinrestgdf.resilience._errors.Error-attribute population:
RestgdfResponseErrornow carries optionalurl,status_code, andrequest_idattributes (kw-only, defaultNone).TransportErrorgainsurlandstatus_code.RestgdfTimeoutErrorgainstimeout_kind("total","connect","read").RateLimitErrorgainsurlandstatus_codealongside existingretry_after. All new attrs are backward-compatible — existing call sites that omit them getNonedefaults.Five
AuthenticationErrorsubclasses —InvalidCredentialsError,TokenExpiredError,TokenRequiredError,TokenRefreshFailedError,AuthNotAttachedError. All carry.context,.attempt,.causeattributes withSecretStrauto-redaction.
Auth runtime¶
TokenSessionConfig.refresh_leeway_seconds(default60) +TokenSessionConfig.clock_skew_seconds(default30) — explicit integer fields (ge=0) replacing the implicit semantics of the previous singlerefresh_threshold_secondsknob (BL-04).ArcGISTokenSession.expires_at— tz-aware UTCdatetimeproperty computed from the epoch-msexpiresfield._utc_now()shim for deterministic wall-clock test control.Structured
auth.refresh.start/.success/.failurelog events at DEBUG level on therestgdf.authlogger.Bounded
/generateTokenretry — transient errors retried up to 3× with exponential backoff; deterministic errors propagate immediately. After exhaustion raisesTokenRefreshFailedError.Referer binding —
token_request_payloadhonoursconfig.refererand switches ArcGISclientto"referer"when set.
Normalization¶
restgdf._models.responses.NormalizedGeometry,NormalizedFeature, anditer_normalized_features(response, *, oid_field=None, sr=None)— typed intermediate models plus iterator overFeaturesResponse.features. Wire-levelfeatures: list[dict]stays for perf; normalization is opt-in. Geometrytypeis heuristically inferred from shape;object_idisint-coerced fromattributes[oid_field].restgdf._models.responses.AdvancedQueryCapabilities— typedPermissiveModelcompanion for the ArcGISadvancedQueryCapabilitiessub-object, with camelCase / snake_caseAliasChoiceswiring and permissiveextra="allow"preservation of unknown keys (BL-21).LayerMetadata.advanced_query_capabilities_typed: AdvancedQueryCapabilities | None— additive typed companion to the existing rawadvanced_query_capabilities: dict | Nonefield. Caller-opt-in; the raw dict stays the default representation.restgdf.utils._metadata.normalize_spatial_reference(sr)— pure helper returning(epsg_int | None, raw_dict | None)that preferslatestWkidoverwkidfor EPSG-consuming clients (R-28).concat_gdfspropagatesGeoDataFrame.attrs["spatial_reference"]across concatenation.restgdf.utils._metadata.normalize_date_fields(features, fields)— converts ArcGISesriFieldTypeDateepoch-ms integers to ISO-8601 UTC strings. Opt-in vianormalize_dates=Trueon the adapter layer.
Pagination¶
restgdf.utils._pagination.PaginationPlan(frozen dataclass) +build_pagination_plan(total_records, max_record_count, *, factor=1.0, advertised_factor=None)— pure-math pagination planner re-exported viarestgdf.utils.getinfo. Emits(resultOffset, resultRecordCount)tuples byte-identical to the previous inline arithmetic inget_query_data_batches; clampsfactor > advertised_factorwith a warning viaget_logger("pagination").get_query_data_batchesis rerouted through the planner with no public-signature change and all pinned fixtures preserved.
Observability¶
restgdf._logging.get_logger(suffix)library-wide logger factory andbuild_log_extrastandardextra=envelope helper. Existingget_drift_logger/restgdf.schema_driftcontract unchanged.restgdf[telemetry]optional extra —RestgdfInstrumentor(dynamic subclass ofAioHttpClientInstrumentor, R-58),feature_layer_stream_spanasync context manager (INTERNAL span, R-21),span_context_fieldshelper, and_SpanContextFilterauto-attached to therestgdfroot logger for trace/span log correlation.docs/recipes/tracing.md— structured observability, error-attribute inspection, and OpenTelemetry integration.docs/recipes/streaming.md— the three streaming shapes,on_truncationoptions,ordervariants, andmax_concurrent_pagesknob.
Resilience¶
restgdf.ResilienceConfig— frozen pydantic sub-config controlling the stamina-based retry wrapper and per-service-root token-bucket rate limiter. Fields:enabled,rate_per_service_root_per_second,respect_retry_after_max_s,fallback_retry_after_seconds,backend. Exposed viarestgdf.Config.resilienceand in top-level__all__. Disabled by default; opt in viaRESTGDF_RESILIENCE_ENABLED=1.restgdf.resilience.ResilientSession— retry + rate-limit adapter implementing theAsyncHTTPSessionprotocol. Stamina-based retry with 429/5xx awareness. Pure pass-through whenResilienceConfig.enabled=False. Requirespip install restgdf[resilience].[project.optional-dependencies] resilienceextra:stamina>=24.2,aiolimiter>=1.1.Per-service-root token-bucket rate limiting via
LimiterRegistryand 429-cooldown viaCooldownRegistryinrestgdf.resilience._limiter._service_root(url)derives the rate-limit key by truncating at the firstFeatureServer/MapServer/ImageServer/SceneServerpath segment.
Transport protocols and drift¶
restgdf._client._protocols.AsyncHTTPSession—@runtime_checkabletyping.Protocolcapturing theget/post/close/closedsurface restgdf transport sessions rely on; re-exported fromrestgdf._client.restgdf._models._drift.FieldSetDriftObserver— observer class that tracks attribute-key appearance / disappearance across feature-page batches and emits dedupedfield_appeared/field_disappearedrecords through the existingrestgdf.schema_driftlogger.Private
restgdf.utils._http._choose_verb(url, body=None)seam returning"POST"for/queryand/queryRelatedRecords,"GET"for bare service/layer metadata URLs, and"POST"as the conservative default. Call sites unchanged; forward-compatible stub for BL-50’s future ~1800-byte GET→POST auto-switch.
Internal helpers¶
restgdf.utils._concurrency.bounded_gather(*aws, semaphore)— caps concurrent fan-out via anasyncio.BoundedSemaphorewhile preservingasyncio.gatherresult ordering andreturn_exceptionssemantics (BL-01).restgdf.utils.getinfo._feature_count_with_timeout— inline bounded retry aroundget_feature_countwith exponential backoff. Retries only onasyncio.TimeoutError,TimeoutError, andaiohttp.ServerTimeoutError; connection-level failures (aiohttp.ClientConnectionError) and deterministic errors (RestgdfResponseError, schema mismatches) propagate on the first attempt. Exhausted timeouts raiseRestgdfTimeoutErrorwith__cause__preserved (BL-51).Directory.safe_crawlnow routes its per-layerfeature_countprobe through aBoundedSemaphoresized fromConcurrencyConfig.max_concurrent_requests(Q-A7).restgdf.__getattr__now consults a_REMOVED_EXPORTSextension point before raisingAttributeError, letting future phases register removed top-level names with aDeprecationWarning+ migration message. Mapping empty in this release (BL-57).
Tests + tooling¶
Taxonomy + observability contract tests (
tests/test_taxonomy_contract.py) assertingerrors.__all__shape and thatget_logger(suffix)emits a structured record for everyLOGGER_SUFFIXESentry (BL-37).Minimal-install contract test (
tests/test_minimal_install.py) guarding against accidental import ofpandas/geopandas/pyogriowhen users install the base package without extras (BL-38).Streaming-recipe discoverability regression test (
tests/test_streaming_recipe_discoverable.py).hypothesis,aioresponses, andopentelemetry-sdkadded to thedevextra. Newtests/test_crawl_property_hypothesis.pyscaffold andtests/_mocks/aioresponses_helpers.pyshared fixtures (BL-39, R-62 scope).bumpverpre_commit_hook(scripts/bumpver_stamp_date.py) auto-stampsCITATION.cff::date-releasedto the release date on everybumpver update, keeping the citation metadata in lock-step withversion:. Newtest_citation_cff_date_released_is_iso_8601pins the ISO-8601 date format (BL-40 follow-up).Install-combination CI matrix (R-62, v3-followup T5). New
install_combinationsjob in.github/workflows/pytest.ymlruns the test suite against six explicit pip install surfaces: base,[geo],[resilience],[telemetry],[geo,resilience,telemetry], and[dev]. Wired into theciaggregator so regressions in any single extra fail PR checks before merge.Coverage-recovery tests (v3-followup T1–T4). Four targeted test modules lift measured coverage from 96.53% to 98.16%:
tests/test_resilience_retry_coverage.py(17 tests;_retry.py79% → 99%),tests/test_telemetry_coverage.py(9 tests;_correlation.py100%,_spans.py97%),tests/test_credentials_coverage.py(6 tests;credentials.py91% → 100%), andtests/test_getgdf_coverage.py(10 tests;getgdf.py95% → 99%). Coverage floor inpyproject.toml([tool.coverage.report] fail_under) raised from 96 to 97 to match (v3-followup T11).
Changed¶
Breaking¶
Default token wire transport flipped from
"body"to"header"(BL-13). Tokens are now sent via theX-Esri-Authorizationheader. Settransport="body"inAuthConfig/TokenSessionConfigto restore the old behavior.refresh_leeway_secondsdefault raised 60 → 120 (BL-13).getgdf/_get_sub_featuresnow raiserestgdf.errors.PaginationError(notRuntimeError) onexceededTransferLimit=true.PaginationErrorcarriesbatch_indexandpage_size(BL-08).PaginationErrorno longer multi-inheritsRuntimeError(phase-3d consolidation under the BL-06 taxonomy). Callers catchingRuntimeErroraroundfeature_count/ pagination calls must widen toRestgdfErroror narrow toPaginationError/ArcGISServiceError(BL-09, R-02).
Non-breaking¶
FeatureLayer.where(new_where)now reuses the parent’s cached metadata so no second metadata GET (?f=json) is issued when the parent was already prepped viaprep()/from_url(). A single feature-count POST (returnCountOnly=true) scoped to the refinedwhereclause is still issued sorefined.countremains correct for the refined filter. The newwhere_clauseis threaded throughdata["where"]so subsequent query / streaming calls honour it (BL-46).ArcGISTokenSession.token_needs_updaterefactored to useexpires_atand_utc_now()instead of inline epoch arithmetic (BL-16).Reactive 498/499 handling in
_call_with_auth_retry— HTTP 498 triggers single-flight refresh + one retry; HTTP 499 raisesAuthNotAttachedErrorimmediately (BL-11).restgdf.utils.getinfo.service_metadata,restgdf.utils.crawl.fetch_all_data, andrestgdf.utils.crawl.safe_crawlnow route their internalasyncio.gatherfan-out throughbounded_gatherwith a per-callasyncio.BoundedSemaphore. Saturation semantics = wait (no new exception) (BL-01).ArcGISTokenSession.update_token_if_needednow collapses concurrent refresh attempts onto a single/generateTokenPOST via a lazily-initialized per-instanceasyncio.Lockwith a double-checkedtoken_needs_update()inside the lock. The new_refresh_lockfield isinit=False,repr=False,compare=False(BL-03).RestgdfResponseErrornow inherits fromrestgdf.errors.RestgdfErrorin addition toValueError. Class identity and thefrom restgdf._models._errors import RestgdfResponseErrorimport path are preserved;except ValueError:call sites keep working (BL-06).restgdf.utils._optional._optional_dependency_errornow returnsrestgdf.errors.OptionalDependencyErrorinstead of a bareModuleNotFoundError. Existingexcept ModuleNotFoundError:andexcept ImportError:handlers still catch the new exception becauseOptionalDependencyErrormulti-inheritsModuleNotFoundError(BL-07).HTTP timeouts are now plumbed through
Settings.timeout_secondsinto every library-maintainedsession.get/session.postcall site (restgdf.utils._query,restgdf.utils._stats,restgdf.utils.getgdf._get_sub_features/get_sub_gdf,ArcGISTokenSession.update_token, and theArcGISTokenSession.get/.postwrappers). The newrestgdf.utils._http.default_timeout()helper returns anaiohttp.ClientTimeoutsized fromSettings.timeout_seconds(float, default30.0, overridable viaRESTGDF_TIMEOUT_SECONDS). Callers that already passtimeout=keep precedence (BL-02).ArcGISTokenSession.__post_init__now respects a caller-suppliedconfig=TokenSessionConfig(...)instead of overwriting it, and derives theTokenSessionConfigsplit fields fromtoken_refresh_thresholdinternally (no longer via the deprecatedrefresh_threshold_secondsalias), so plain construction no longer fires aDeprecationWarning.token_refresh_thresholdis resynced from the validated config after construction.pyproject.toml::[tool.coverage.report].exclude_alsoextended withif TYPE_CHECKING:,@overload, and bare-ellipsis stub lines (standard coverage.py idioms, pydantic / httpx / attrs precedent). Thresholdfail_under=97unchanged (R-63).
Deprecated¶
FeatureLayer.row_dict_generator— useFeatureLayer.stream_rows. EmitsDeprecationWarningand continues to delegate to the module-levelrow_dict_generatorhelper for backwards compatibility with existingunittest.mock.patchcall sites.get_token()— emitsDeprecationWarningon every call (BL-14). Migrate toArcGISTokenSessionfor async token management.get_tokennow also acceptspydantic.SecretStrpasswords.restgdf.Settings/restgdf.get_settings()— userestgdf.Config/restgdf.get_config().get_settings()emits a singleDeprecationWarningon first call and constructs its return value fromget_config(); existing callers continue to work unchanged. Will be removed no earlier than restgdf 3.0 (BL-18).Six flat environment variables —
RESTGDF_TIMEOUT_SECONDS,RESTGDF_TOKEN_URL,RESTGDF_REFRESH_THRESHOLD,RESTGDF_USER_AGENT,RESTGDF_LOG_LEVEL,RESTGDF_MAX_CONCURRENT_REQUESTS— in favour of theirRESTGDF_<CATEGORY>_<FIELD>replacements. The old names continue to work but emit aDeprecationWarningwhen read viaConfig.from_env/get_config; when both old and new names are set the new one wins and the warning notes the override (BL-18).TokenSessionConfig.refresh_threshold_seconds— a read/write alias emittingDeprecationWarning. Reads returnrefresh_leeway_seconds + clock_skew_seconds; constructor writes split the supplied total intoclock_skew_seconds = min(30, total)andrefresh_leeway_seconds = total - clock_skew_seconds. Migrate to the explicit field pair.
Removed¶
restgdf.utils._metadata.FIELDDOESNOTEXISTsentinel (and its re-export viarestgdf.utils.getinfo). Call sites must nowexcept FieldDoesNotExistErrorfrom the BL-06 taxonomy. Hard break — no compat shim (BL-09, R-02).
Fixed¶
bumpverfile pattern forCITATION.cffanchored to^version: {version}$(was unanchoredversion: {version}), preventing a latent release-time defect where the regex would silently rewritecff-version: 1.2.0to the new release version on everybumpver update. Discovered viabumpver update --dryduring the CITATION auto-stamp work.ArcGISTokenSession.update_tokennow forwards the session’sverify_sslflag asssl=on the/generateTokenPOST. Previously the flag was honoured for feature / query requests but ignored during token refresh, soverify_ssl=Falsesessions could still fail TLS verification against self-signed ArcGIS Enterprise deployments.
2.0.0 - 2026-04-20¶
Major release — pydantic 2.13 integration. See
MIGRATION.md for a complete breaking-changes table and
migration recipes.
Breaking¶
Public return and attribute shapes changed from plain
dict/TypedDictto pydanticBaseModelclasses:FeatureLayer.metadata→LayerMetadataDirectory.metadata→LayerMetadataDirectory.services,Directory.services_with_feature_count, andDirectory.crawl(...)→list[CrawlServiceEntry]get_metadata(...)→LayerMetadatasafe_crawl(...)→CrawlReport
AGOLUserPass.passwordis nowpydantic.SecretStr; call.get_secret_value()at the HTTP-POST boundary.restgdf._types.*TypedDicts are replaced by lazy aliases that re-export the new pydantic models and emitDeprecationWarningon import. The shim will be removed in 3.x.
Added¶
LayerMetadata,ServiceInfo,FieldSpec,Feature,FeaturesResponse,CountResponse,ObjectIdsResponse,TokenResponse,ErrorInfo,ErrorResponse,CrawlReport,CrawlServiceEntry,CrawlError— pydantic response models.AGOLUserPass,TokenSessionConfig— pydantic credentials / session config models.Settings,get_settings— process-wide runtime configuration backed byRESTGDF_*environment variables (CHUNK_SIZE,TIMEOUT_SECONDS,USER_AGENT,LOG_LEVEL,TOKEN_URL,REFRESH_THRESHOLD,DEFAULT_HEADERS_JSON).RestgdfResponseError— typed error raised when a strict-tier response fails validation; carriesmodel_name,context, andrawpayload attributes.restgdf.compat.as_dict/restgdf.compat.as_json_dict— migration helpers that convert any returned model (or passthrough any non-model) to a plain dict.restgdf.schema_driftlogger — opt-in observability for vendor variance;NullHandlerby default.Directory.report— the fullCrawlReport(services, errors, root metadata) from the most recent.crawl()call.
Dependencies¶
Added
pydantic>=2.13.3,<3.
1.x¶
Earlier releases were not formally tracked here. See the Git tag history and PyPI release notes for pre-2.0 changes.