From 57997ffe552a9e7320eb6beaac37d3eee11ccc76 Mon Sep 17 00:00:00 2001 From: Mike McDougall Date: Fri, 17 Jul 2026 10:31:14 -1000 Subject: [PATCH 1/2] feat(coverage): sdk-coverage.v1.json per-capability snapshot (#182) Publish compatibility/sdk-coverage.v1.json, mapping this SDK's client source to honua-server's canonical capability key vocabulary (capability-keys.v1.json, honua-server#2893) for the cross-product capability matrix (honua-server#2892). - scripts/gen_sdk_coverage.py hand-curates 29 covered/partial keys (of 110 canonical keys) after a full source review of packages/honua-sdk and packages/honua-admin; every other key is genuinely untouched by this client SDK and is omitted rather than padded with a "none" entry. - Every entrypoint is verified by resolving its dotted path against the installed packages, so a renamed/removed class or method fails the gate until the snapshot is updated in the same PR -- the real drift signal. - The canonical key list is consumed, never copied: keys are validated against a pinned offline fixture (compatibility/capability-keys.fixture.json) for local/offline runs, or the live published URL when HONUA_CAPABILITY_KEY_LIST_URL is set (CI sets it on every run). - sinceVersion is an honest "unreleased (source preview)" marker on every entry -- neither honua-sdk nor honua-admin has been published to PyPI. - Wired into ci.yml (gate + trunk-only artifact upload) and publish-python-sdk.yml (gate blocks both release workflows). Schema and rules documented in docs/sdk-coverage.md, linked from docs/compatibility.md. --- .github/workflows/ci.yml | 22 + .github/workflows/publish-python-sdk.yml | 20 + compatibility/capability-keys.fixture.json | 668 +++++++++++++++++++++ compatibility/sdk-coverage.v1.json | 288 +++++++++ docs/compatibility.md | 8 + docs/sdk-coverage.md | 144 +++++ mkdocs.yml | 1 + scripts/gen_sdk_coverage.py | 633 +++++++++++++++++++ tests/test_sdk_coverage_gate.py | 249 ++++++++ 9 files changed, 2033 insertions(+) create mode 100644 compatibility/capability-keys.fixture.json create mode 100644 compatibility/sdk-coverage.v1.json create mode 100644 docs/sdk-coverage.md create mode 100644 scripts/gen_sdk_coverage.py create mode 100644 tests/test_sdk_coverage_gate.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8c0e4f..7ed9987 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,6 +170,28 @@ jobs: - name: Run compatibility gate run: python scripts/compatibility_gate.py + - name: Run SDK coverage gate + # Validates compatibility/sdk-coverage.v1.json: every capability key + # is checked against honua-server's *live* published key list (not + # the offline compatibility/capability-keys.fixture.json fallback + # gen_sdk_coverage.py uses locally), every entrypoint is resolved + # against the installed packages (renamed/removed symbol -> fail), + # and the committed snapshot must match a fresh generation exactly. + env: + HONUA_CAPABILITY_KEY_LIST_URL: https://raw.githubusercontent.com/honua-io/honua-server/trunk/docs/gis/data/capability-keys.v1.json + run: python scripts/gen_sdk_coverage.py + + - name: Upload SDK coverage snapshot artifact + # Published on trunk builds only (per honua-sdk-python#182), for + # honua-evidence's aggregate capability-matrix run to consume. + if: github.ref == 'refs/heads/trunk' && github.event_name == 'push' + uses: actions/upload-artifact@v7 + with: + name: sdk-coverage-v1 + path: compatibility/sdk-coverage.v1.json + if-no-files-found: error + retention-days: 90 + security-audit: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/publish-python-sdk.yml b/.github/workflows/publish-python-sdk.yml index d102de4..748feb5 100644 --- a/.github/workflows/publish-python-sdk.yml +++ b/.github/workflows/publish-python-sdk.yml @@ -113,6 +113,16 @@ jobs: run: python scripts/compatibility_gate.py working-directory: ${{ github.workspace }} + - name: Run SDK coverage gate + # A release must not ship a stale coverage claim (honua-sdk-python#182): + # fail the publish if compatibility/sdk-coverage.v1.json drifts from + # the SDK source, references a renamed/removed entrypoint, or uses a + # capability key honua-server no longer publishes. + env: + HONUA_CAPABILITY_KEY_LIST_URL: https://raw.githubusercontent.com/honua-io/honua-server/trunk/docs/gis/data/capability-keys.v1.json + run: python scripts/gen_sdk_coverage.py + working-directory: ${{ github.workspace }} + - name: Install honua-admin (for full smoke coverage) run: pip install -e "${{ github.workspace }}/packages/honua-admin" @@ -192,6 +202,16 @@ jobs: run: python scripts/compatibility_gate.py working-directory: ${{ github.workspace }} + - name: Run SDK coverage gate + # A release must not ship a stale coverage claim (honua-sdk-python#182): + # fail the publish if compatibility/sdk-coverage.v1.json drifts from + # the SDK source, references a renamed/removed entrypoint, or uses a + # capability key honua-server no longer publishes. + env: + HONUA_CAPABILITY_KEY_LIST_URL: https://raw.githubusercontent.com/honua-io/honua-server/trunk/docs/gis/data/capability-keys.v1.json + run: python scripts/gen_sdk_coverage.py + working-directory: ${{ github.workspace }} + - name: Run tests # Exercise the full suite (not just ``tests/admin/``) so the admin # publish smoke mirrors the sdk job and CI; an sdk-side regression diff --git a/compatibility/capability-keys.fixture.json b/compatibility/capability-keys.fixture.json new file mode 100644 index 0000000..634f94f --- /dev/null +++ b/compatibility/capability-keys.fixture.json @@ -0,0 +1,668 @@ +{ + "_comment": "PINNED, POINT-IN-TIME FIXTURE. This is the offline fallback key list used when HONUA_CAPABILITY_KEY_LIST_URL is unset (local dev / no network). It is a trimmed copy (key/category/edition/displayName only -- descriptions dropped) of honua-io/honua-server's published capability-keys.v1.json, taken 2026-07-17. honua-sdk-python does not own this vocabulary -- consume, never redefine it. CI always validates against the live URL (see .github/workflows/ci.yml); this fixture only backstops local/offline runs of scripts/gen_sdk_coverage.py. Refresh with `python scripts/gen_sdk_coverage.py --refresh-key-list-fixture` whenever honua-server's vocabulary changes.", + "source": "https://raw.githubusercontent.com/honua-io/honua-server/trunk/docs/gis/data/capability-keys.v1.json", + "schemaVersion": "1.0.0", + "pinnedAt": "2026-07-17", + "capabilities": [ + { + "key": "admin.control-plane", + "category": "ControlPlane", + "edition": "Community", + "displayName": "Admin Control Plane" + }, + { + "key": "ai.agent-operations", + "category": "AI", + "edition": "Pro", + "displayName": "Agent Operations (Validation Layer)" + }, + { + "key": "ai.approval-workflows", + "category": "AI", + "edition": "Enterprise", + "displayName": "Agent Approval Workflows" + }, + { + "key": "ai.grounding", + "category": "AI", + "edition": "Pro", + "displayName": "Spec Grounding Mutations" + }, + { + "key": "ai.mcp-discovery", + "category": "AI", + "edition": "Community", + "displayName": "MCP Discovery & Query" + }, + { + "key": "ai.spec-apply", + "category": "AI", + "edition": "Pro", + "displayName": "Spec Apply Execution" + }, + { + "key": "ai.spec-artifacts", + "category": "AI", + "edition": "Community", + "displayName": "Spec Artifact Retrieval" + }, + { + "key": "ai.workflow-generation", + "category": "AI", + "edition": "Pro", + "displayName": "AI Workflow and Content Generation" + }, + { + "key": "alerts.dwell", + "category": "Alerts", + "edition": "Enterprise", + "displayName": "Dwell Trigger" + }, + { + "key": "alerts.enter-exit", + "category": "Alerts", + "edition": "Pro", + "displayName": "Enter/Exit Geofence Triggers" + }, + { + "key": "alerts.evaluation", + "category": "Alerts", + "edition": "Pro", + "displayName": "Alert Evaluation Engine" + }, + { + "key": "alerts.threshold", + "category": "Alerts", + "edition": "Enterprise", + "displayName": "Threshold Trigger" + }, + { + "key": "analytics.buffer-aggregate", + "category": "Analytics", + "edition": "Pro", + "displayName": "Buffer Aggregate" + }, + { + "key": "analytics.clustering", + "category": "Analytics", + "edition": "Pro", + "displayName": "Spatial Clustering" + }, + { + "key": "analytics.content", + "category": "Analytics", + "edition": "Community", + "displayName": "Analysis Artifact Content" + }, + { + "key": "analytics.density", + "category": "Analytics", + "edition": "Pro", + "displayName": "Density Binning" + }, + { + "key": "analytics.line-of-sight", + "category": "Analytics", + "edition": "Pro", + "displayName": "Line of Sight" + }, + { + "key": "analytics.reporting", + "category": "Analytics", + "edition": "Community", + "displayName": "Analysis Reporting" + }, + { + "key": "analytics.slice", + "category": "Analytics", + "edition": "Pro", + "displayName": "Slice/Volumetric Analysis" + }, + { + "key": "analytics.spatial-join", + "category": "Analytics", + "edition": "Pro", + "displayName": "Spatial Join" + }, + { + "key": "analytics.sun-shadow", + "category": "Analytics", + "edition": "Pro", + "displayName": "Sun/Shadow Analysis" + }, + { + "key": "analytics.viewshed", + "category": "Analytics", + "edition": "Pro", + "displayName": "Viewshed" + }, + { + "key": "caching.output-cache", + "category": "Caching", + "edition": "Pro", + "displayName": "Output Caching" + }, + { + "key": "caching.redis", + "category": "Caching", + "edition": "Pro", + "displayName": "Redis Distributed Cache" + }, + { + "key": "channels.aws-sns", + "category": "Channels", + "edition": "Enterprise", + "displayName": "AWS SNS Delivery" + }, + { + "key": "channels.azure-eventgrid", + "category": "Channels", + "edition": "Enterprise", + "displayName": "Azure Event Grid Delivery" + }, + { + "key": "channels.digest", + "category": "Channels", + "edition": "Enterprise", + "displayName": "Digest Delivery" + }, + { + "key": "channels.email", + "category": "Channels", + "edition": "Enterprise", + "displayName": "Email Delivery" + }, + { + "key": "channels.slack", + "category": "Channels", + "edition": "Enterprise", + "displayName": "Slack Delivery" + }, + { + "key": "channels.teams", + "category": "Channels", + "edition": "Enterprise", + "displayName": "Microsoft Teams Delivery" + }, + { + "key": "channels.webhook", + "category": "Channels", + "edition": "Pro", + "displayName": "Webhook Delivery" + }, + { + "key": "collaboration.map-sessions", + "category": "Collaboration", + "edition": "Community", + "displayName": "Map Collaboration Sessions" + }, + { + "key": "demo.showcase", + "category": "Demo", + "edition": "Community", + "displayName": "Demo Showcase Surfaces" + }, + { + "key": "discovery.capability-manifest", + "category": "Discovery", + "edition": "Community", + "displayName": "Capability Manifest" + }, + { + "key": "dr.backup-automation", + "category": "DisasterRecovery", + "edition": "Enterprise", + "displayName": "Backup Automation" + }, + { + "key": "dr.cache-backup", + "category": "DisasterRecovery", + "edition": "Enterprise", + "displayName": "Cache State Backup" + }, + { + "key": "dr.failover", + "category": "DisasterRecovery", + "edition": "Enterprise", + "displayName": "Failover Playbooks" + }, + { + "key": "dr.rto-rpo-reporting", + "category": "DisasterRecovery", + "edition": "Enterprise", + "displayName": "RTO/RPO Reporting" + }, + { + "key": "editing.branch-versioning", + "category": "Editing", + "edition": "Enterprise", + "displayName": "Branch Versioning" + }, + { + "key": "editing.featureserver-edits", + "category": "Editing", + "edition": "Pro", + "displayName": "FeatureServer Editing" + }, + { + "key": "enrichment.datasets", + "category": "Enrichment", + "edition": "Community", + "displayName": "Data Enrichment Datasets" + }, + { + "key": "fieldops.forms", + "category": "FieldOps", + "edition": "Community", + "displayName": "Field Collection Forms" + }, + { + "key": "fieldops.offline-sync", + "category": "FieldOps", + "edition": "Pro", + "displayName": "Offline/Field Sync" + }, + { + "key": "geocoding.batch", + "category": "Geocoding", + "edition": "Enterprise", + "displayName": "Batch Geocoding" + }, + { + "key": "geocoding.failover", + "category": "Geocoding", + "edition": "Pro", + "displayName": "Provider Failover" + }, + { + "key": "geocoding.forward", + "category": "Geocoding", + "edition": "Pro", + "displayName": "Forward Geocoding" + }, + { + "key": "geocoding.reverse", + "category": "Geocoding", + "edition": "Pro", + "displayName": "Reverse Geocoding" + }, + { + "key": "identity.claims-mapping", + "category": "Identity", + "edition": "Enterprise", + "displayName": "Claims Mapping" + }, + { + "key": "identity.mtls-client-certificate", + "category": "Identity", + "edition": "Enterprise", + "displayName": "mTLS Client-Certificate Authentication" + }, + { + "key": "identity.oidc", + "category": "Identity", + "edition": "Pro", + "displayName": "OIDC Authentication" + }, + { + "key": "identity.oidc-multi-provider", + "category": "Identity", + "edition": "Enterprise", + "displayName": "OIDC Multi-Provider SSO" + }, + { + "key": "identity.portal-sharing", + "category": "Identity", + "edition": "Community", + "displayName": "ArcGIS Portal Sharing Read Surface" + }, + { + "key": "identity.portal-token", + "category": "Identity", + "edition": "Community", + "displayName": "ArcGIS Portal Token Issuance" + }, + { + "key": "identity.saml", + "category": "Identity", + "edition": "Community", + "displayName": "SAML 2.0 Authentication" + }, + { + "key": "identity.scim", + "category": "Identity", + "edition": "Community", + "displayName": "SCIM 2.0 Provisioning" + }, + { + "key": "import.file", + "category": "Import", + "edition": "Community", + "displayName": "File Import" + }, + { + "key": "import.geoserver", + "category": "Import", + "edition": "Enterprise", + "displayName": "GeoServer Import" + }, + { + "key": "import.geoservices", + "category": "Import", + "edition": "Enterprise", + "displayName": "GeoServices Import" + }, + { + "key": "ops.health", + "category": "Ops", + "edition": "Community", + "displayName": "Health Checks" + }, + { + "key": "ops.observability", + "category": "Ops", + "edition": "Community", + "displayName": "Observability" + }, + { + "key": "plugin.sdk", + "category": "Extensibility", + "edition": "Enterprise", + "displayName": "Plugin/Extension SDK" + }, + { + "key": "printing.layout-templates", + "category": "Printing", + "edition": "Pro", + "displayName": "Print Layout Templates" + }, + { + "key": "printing.pdf-output", + "category": "Printing", + "edition": "Pro", + "displayName": "PDF Print Output" + }, + { + "key": "process.geoprocessing", + "category": "Process", + "edition": "Community", + "displayName": "Geoprocessing Task Execution" + }, + { + "key": "process.ogc-api-processes", + "category": "Process", + "edition": "Community", + "displayName": "OGC API Processes" + }, + { + "key": "raster.cloud-cog-serving", + "category": "Raster", + "edition": "Pro", + "displayName": "COG Serving" + }, + { + "key": "raster.cloud-storage-config", + "category": "Raster", + "edition": "Pro", + "displayName": "Cloud Storage Configuration" + }, + { + "key": "raster.multidim-coverage", + "category": "Raster", + "edition": "Pro", + "displayName": "Multidimensional Coverage (NetCDF/HDF5/Zarr)" + }, + { + "key": "raster.temporal-mosaic", + "category": "Raster", + "edition": "Pro", + "displayName": "Temporal Raster Mosaic" + }, + { + "key": "raster.terrain-rgb", + "category": "Raster", + "edition": "Community", + "displayName": "Terrain-RGB Tiles" + }, + { + "key": "routing.solve", + "category": "Routing", + "edition": "Pro", + "displayName": "Network Routing" + }, + { + "key": "scene.bim-ingest", + "category": "Scene", + "edition": "Enterprise", + "displayName": "CityGML/BIM Scene Ingest" + }, + { + "key": "scene.catalog", + "category": "Scene", + "edition": "Community", + "displayName": "Scene Catalog" + }, + { + "key": "scene.pointcloud-ingest", + "category": "Scene", + "edition": "Enterprise", + "displayName": "Point Cloud Scene Ingest" + }, + { + "key": "serve.3d-tiles-scene", + "category": "Serve", + "edition": "Community", + "displayName": "3D Tiles Scene Serving" + }, + { + "key": "serve.elevation", + "category": "Serve", + "edition": "Community", + "displayName": "Elevation Query" + }, + { + "key": "serve.geoservices-featureserver", + "category": "Serve", + "edition": "Community", + "displayName": "FeatureServer Query" + }, + { + "key": "serve.geoservices-geocodeserver", + "category": "Serve", + "edition": "Community", + "displayName": "GeocodeServer Discovery" + }, + { + "key": "serve.geoservices-geometry-service", + "category": "Serve", + "edition": "Community", + "displayName": "Geometry Service" + }, + { + "key": "serve.geoservices-imageserver", + "category": "Serve", + "edition": "Community", + "displayName": "ImageServer" + }, + { + "key": "serve.geoservices-mapserver", + "category": "Serve", + "edition": "Community", + "displayName": "MapServer" + }, + { + "key": "serve.geoservices-root", + "category": "Serve", + "edition": "Community", + "displayName": "GeoServices REST Root" + }, + { + "key": "serve.geoservices-vectortileserver", + "category": "Serve", + "edition": "Community", + "displayName": "VectorTileServer" + }, + { + "key": "serve.i3s-scene", + "category": "Serve", + "edition": "Community", + "displayName": "I3S Scene Serving" + }, + { + "key": "serve.odata", + "category": "Serve", + "edition": "Community", + "displayName": "OData v4" + }, + { + "key": "serve.ogc-api-coverages", + "category": "Serve", + "edition": "Community", + "displayName": "OGC API Coverages" + }, + { + "key": "serve.ogc-api-edr", + "category": "Serve", + "edition": "Community", + "displayName": "OGC API - EDR" + }, + { + "key": "serve.ogc-api-features", + "category": "Serve", + "edition": "Community", + "displayName": "OGC API Features" + }, + { + "key": "serve.ogc-api-maps", + "category": "Serve", + "edition": "Community", + "displayName": "OGC API Maps" + }, + { + "key": "serve.ogc-api-records", + "category": "Serve", + "edition": "Community", + "displayName": "OGC API Records" + }, + { + "key": "serve.ogc-api-tiles", + "category": "Serve", + "edition": "Community", + "displayName": "OGC API Tiles" + }, + { + "key": "serve.sensorthings", + "category": "Serve", + "edition": "Community", + "displayName": "OGC SensorThings API" + }, + { + "key": "serve.stac", + "category": "Serve", + "edition": "Community", + "displayName": "STAC API" + }, + { + "key": "serve.vector-tiles", + "category": "Serve", + "edition": "Community", + "displayName": "Vector Tiles (MVT/TileJSON/PMTiles)" + }, + { + "key": "serve.wcs", + "category": "Serve", + "edition": "Community", + "displayName": "WCS 2.0.1" + }, + { + "key": "serve.wfs", + "category": "Serve", + "edition": "Community", + "displayName": "WFS 2.0" + }, + { + "key": "serve.wms", + "category": "Serve", + "edition": "Community", + "displayName": "WMS 1.3" + }, + { + "key": "serve.wmts", + "category": "Serve", + "edition": "Community", + "displayName": "WMTS 1.0" + }, + { + "key": "staticmap.high-dpi", + "category": "StaticMap", + "edition": "Pro", + "displayName": "High-DPI Static Maps" + }, + { + "key": "staticmap.large-dimensions", + "category": "StaticMap", + "edition": "Pro", + "displayName": "Large Static Maps" + }, + { + "key": "staticmap.rich-overlays", + "category": "StaticMap", + "edition": "Pro", + "displayName": "Rich Static Map Overlays" + }, + { + "key": "streaming.feature-subscriptions", + "category": "Streaming", + "edition": "Pro", + "displayName": "Real-Time Feature Streams" + }, + { + "key": "styling.auto-suggest", + "category": "Styling", + "edition": "Pro", + "displayName": "Auto-Cartographic Styling" + }, + { + "key": "styling.defaults", + "category": "Styling", + "edition": "Community", + "displayName": "Smart Style Defaults" + }, + { + "key": "styling.ogc-api-styles", + "category": "Styling", + "edition": "Community", + "displayName": "OGC API Styles" + }, + { + "key": "temporal.animation-api", + "category": "Temporal", + "edition": "Pro", + "displayName": "Animation API Contract" + }, + { + "key": "temporal.extent-discovery", + "category": "Temporal", + "edition": "Community", + "displayName": "Temporal Extent Discovery" + }, + { + "key": "temporal.filtering", + "category": "Temporal", + "edition": "Community", + "displayName": "Temporal Query Filtering" + }, + { + "key": "temporal.histogram", + "category": "Temporal", + "edition": "Pro", + "displayName": "Temporal Histogram (Date Bins)" + }, + { + "key": "temporal.time-series-tiles", + "category": "Temporal", + "edition": "Pro", + "displayName": "Time-Series Tile Filtering" + } + ] +} diff --git a/compatibility/sdk-coverage.v1.json b/compatibility/sdk-coverage.v1.json new file mode 100644 index 0000000..02e14f3 --- /dev/null +++ b/compatibility/sdk-coverage.v1.json @@ -0,0 +1,288 @@ +{ + "schemaVersion": "sdk-coverage.v1", + "package": "honua-sdk-python (packages/honua-sdk + packages/honua-admin)", + "generator": "scripts/gen_sdk_coverage.py", + "keyListPolicy": "Canonical capability key vocabulary is owned and published by honua-io/honua-server (docs/gis/data/capability-keys.v1.json, honua-server#2893). This SDK consumes it for validation only -- keys are never redefined or copied here beyond the entries this SDK actually implements. See scripts/gen_sdk_coverage.py for the HONUA_CAPABILITY_KEY_LIST_URL / pinned-fixture resolution order.", + "capabilities": [ + { + "key": "admin.control-plane", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_admin.AsyncHonuaAdminClient", + "honua_admin.HonuaAdminClient" + ] + }, + { + "key": "discovery.capability-manifest", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.AsyncHonuaClient.capabilities", + "honua_admin.AsyncHonuaAdminClient.get_capabilities", + "honua_admin.AsyncHonuaAdminClient.get_capability_flags", + "honua_admin.AsyncHonuaAdminClient.check_compatibility" + ] + }, + { + "key": "editing.featureserver-edits", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.GeoServicesFeatureServerClient.apply_edits", + "honua_sdk.AsyncHonuaClient.apply_edits", + "honua_sdk.AsyncHonuaClient.apply_edits_result" + ] + }, + { + "key": "geocoding.forward", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.geocoding.HonuaGeocodingClient.forward_geocode", + "honua_sdk.async_geocoding.AsyncHonuaGeocodingClient.forward_geocode" + ] + }, + { + "key": "geocoding.reverse", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.geocoding.HonuaGeocodingClient.reverse_geocode", + "honua_sdk.async_geocoding.AsyncHonuaGeocodingClient.reverse_geocode" + ] + }, + { + "key": "ops.health", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.AsyncHonuaClient.readiness" + ] + }, + { + "key": "process.ogc-api-processes", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.geoprocessing.HonuaGeoprocessing", + "honua_sdk.geoprocessing.AsyncHonuaGeoprocessing" + ] + }, + { + "key": "raster.multidim-coverage", + "status": "partial", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.GeoServicesImageServerClient.multidimensional_info" + ], + "note": "Read-only: the client can query dimension/variable metadata for an already-registered multidimensional ImageServer via multidimensionalInfo. There is no client surface to register or configure a NetCDF4/HDF5/Zarr multidimensional coverage -- that is server-admin configuration this SDK's control-plane client does not expose." + }, + { + "key": "raster.temporal-mosaic", + "status": "partial", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.GeoServicesImageServerClient.compute_histograms", + "honua_sdk.protocols.GeoServicesImageServerClient.compute_statistics_histograms", + "honua_sdk.protocols.GeoServicesImageServerClient.get_samples" + ], + "note": "mosaic_rule + a single ISO-8601 time instant select a raster temporal mosaic on the ImageServer histogram/statistics/sample analysis operations only. export_image, tile, identify, and query do not accept a typed time/mosaic_rule parameter (only generic extra_params passthrough), so temporal mosaic selection does not extend to raster rendering/export." + }, + { + "key": "scene.catalog", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.SceneClient.list_scenes", + "honua_sdk.protocols.SceneClient.get_scene" + ] + }, + { + "key": "serve.3d-tiles-scene", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.SceneClient", + "honua_sdk.protocols.AsyncSceneClient", + "honua_sdk.protocols.enumerate_tileset_contents", + "honua_sdk.protocols.parse_scene_package_manifest" + ] + }, + { + "key": "serve.elevation", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.ElevationClient", + "honua_sdk.protocols.AsyncElevationClient" + ] + }, + { + "key": "serve.geoservices-featureserver", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.GeoServicesFeatureServerClient", + "honua_sdk.protocols.AsyncGeoServicesFeatureServerClient", + "honua_sdk.AsyncHonuaClient.feature_server" + ] + }, + { + "key": "serve.geoservices-geometry-service", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.GeoServicesGeometryServerClient", + "honua_sdk.protocols.AsyncGeoServicesGeometryServerClient", + "honua_sdk.AsyncHonuaClient.geometry_server" + ] + }, + { + "key": "serve.geoservices-imageserver", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.GeoServicesImageServerClient", + "honua_sdk.protocols.AsyncGeoServicesImageServerClient", + "honua_sdk.AsyncHonuaClient.image_server" + ] + }, + { + "key": "serve.geoservices-mapserver", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.GeoServicesMapServerClient", + "honua_sdk.protocols.AsyncGeoServicesMapServerClient", + "honua_sdk.AsyncHonuaClient.map_server", + "honua_sdk.AsyncHonuaClient.export_map" + ] + }, + { + "key": "serve.geoservices-root", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.AsyncHonuaClient.list_services", + "honua_sdk.AsyncHonuaClient.list_service_summaries" + ] + }, + { + "key": "serve.i3s-scene", + "status": "partial", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.SceneClient.resolve_scene", + "honua_sdk.protocols.SceneClient.fetch_tile" + ], + "note": "I3S-tagged scene endpoints resolve and fetch as opaque bytes through the same generic scene-endpoint resolution used for 3D Tiles, but there is no I3S-specific scene-layer-package (.slpk) node/resource-tree parsing -- only the 3D Tiles tileset.json walker (enumerate_tileset_contents) is implemented." + }, + { + "key": "serve.odata", + "status": "partial", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.ODataClient", + "honua_sdk.protocols.AsyncODataClient" + ], + "note": "Read/query only (service_document, metadata, layers, features, and pagination helpers). No create/update/delete entity-set operations are implemented, so the write half of 'query and edit features through OData v4' is not covered." + }, + { + "key": "serve.ogc-api-coverages", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.OgcCoveragesClient", + "honua_sdk.protocols.AsyncOgcCoveragesClient" + ] + }, + { + "key": "serve.ogc-api-features", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.ogc.HonuaOgcFeatures", + "honua_sdk.ogc.HonuaOgcFeatureCollection", + "honua_sdk.ogc.AsyncHonuaOgcFeatures", + "honua_sdk.ogc.AsyncHonuaOgcFeatureCollection" + ] + }, + { + "key": "serve.ogc-api-maps", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.OgcMapsClient", + "honua_sdk.protocols.AsyncOgcMapsClient" + ] + }, + { + "key": "serve.ogc-api-records", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.OgcRecordsClient", + "honua_sdk.protocols.OgcRecordsCollectionClient", + "honua_sdk.protocols.AsyncOgcRecordsClient", + "honua_sdk.protocols.AsyncOgcRecordsCollectionClient" + ] + }, + { + "key": "serve.ogc-api-tiles", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.OgcTilesClient", + "honua_sdk.protocols.AsyncOgcTilesClient" + ] + }, + { + "key": "serve.stac", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.StacClient", + "honua_sdk.protocols.AsyncStacClient" + ] + }, + { + "key": "serve.wfs", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.WfsClient", + "honua_sdk.protocols.AsyncWfsClient" + ] + }, + { + "key": "serve.wms", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.WmsClient", + "honua_sdk.protocols.AsyncWmsClient" + ] + }, + { + "key": "serve.wmts", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.WmtsClient", + "honua_sdk.protocols.AsyncWmtsClient" + ] + }, + { + "key": "styling.ogc-api-styles", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_admin.AsyncHonuaAdminClient.list_styles", + "honua_admin.AsyncHonuaAdminClient.get_stylesheet", + "honua_admin.AsyncHonuaAdminClient.get_style_metadata", + "honua_admin.AsyncHonuaAdminClient.update_style" + ] + } + ] +} diff --git a/docs/compatibility.md b/docs/compatibility.md index c300d0f..58e670c 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -70,3 +70,11 @@ compatibility changes and covered by targeted admin tests. Pull request CI runs the compatibility gate as its own job. The publish workflow also runs the same gate before package build/upload steps, so a failed server matrix or public API drift blocks release tags and manual publish runs. + +## Capability Coverage Snapshot + +A separate artifact, [`compatibility/sdk-coverage.v1.json`](https://github.com/honua-io/honua-sdk-python/blob/trunk/compatibility/sdk-coverage.v1.json), +tracks this SDK's per-capability coverage against honua-server's canonical +capability key vocabulary for the cross-product capability matrix. See +[SDK Capability Coverage](sdk-coverage.md) for its schema, the honesty +rules it enforces, and how the drift gate works. diff --git a/docs/sdk-coverage.md b/docs/sdk-coverage.md new file mode 100644 index 0000000..497ab08 --- /dev/null +++ b/docs/sdk-coverage.md @@ -0,0 +1,144 @@ +# SDK Capability Coverage Snapshot + +`compatibility/sdk-coverage.v1.json` is this SDK's producer snapshot for +honua-io/honua-server's cross-product capability matrix +(honua-server#2892 / #2893, honua-sdk-python#182). It maps this SDK's +client source to the canonical, server-owned capability key vocabulary so +[honua-evidence](https://github.com/honua-io/honua-evidence) can join every +producer's coverage into one aggregate view. + +This document is the schema reference required by the repo's compatibility +docs convention (see [Compatibility](compatibility.md)); the generator that +produces and validates the snapshot is +[`scripts/gen_sdk_coverage.py`](https://github.com/honua-io/honua-sdk-python/blob/trunk/scripts/gen_sdk_coverage.py). + +## Rules + +These mirror the "no padding, no invented claims" rules used for the +site claims ledger and other producer snapshots in this ecosystem: + +- **Consume, never copy.** The canonical capability key vocabulary is + owned by honua-server + ([`docs/gis/data/capability-keys.v1.json`](https://github.com/honua-io/honua-server/blob/trunk/docs/gis/data/capability-keys.v1.json)). + This repository never redefines or forks that vocabulary -- it only + validates that every key in its own snapshot exists in the upstream + list. +- **`partial` requires a note.** Any capability marked `partial` must + carry a `note` explaining exactly where SDK coverage stops. +- **Never pad.** A capability this SDK's client source does not touch is + omitted from the snapshot entirely -- there is no `status: "none"` + entry to skim past. +- **Honest `sinceVersion`.** This SDK is a source preview: neither + `honua-sdk` nor `honua-admin` has been published to PyPI yet (see the + repo README/AGENTS.md "Status: alpha"). Every entry's `sinceVersion` is + the literal string `"unreleased (source preview; not yet published to + PyPI)"` rather than an invented release number. + +## Schema + +```json +{ + "schemaVersion": "sdk-coverage.v1", + "package": "honua-sdk-python (packages/honua-sdk + packages/honua-admin)", + "generator": "scripts/gen_sdk_coverage.py", + "keyListPolicy": "", + "capabilities": [ + { + "key": "serve.geoservices-featureserver", + "status": "covered", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": [ + "honua_sdk.protocols.GeoServicesFeatureServerClient", + "honua_sdk.protocols.AsyncGeoServicesFeatureServerClient", + "honua_sdk.AsyncHonuaClient.feature_server" + ] + }, + { + "key": "serve.odata", + "status": "partial", + "sinceVersion": "unreleased (source preview; not yet published to PyPI)", + "entrypoints": ["honua_sdk.protocols.ODataClient", "honua_sdk.protocols.AsyncODataClient"], + "note": "Read/query only ...; no create/update/delete entity-set operations are implemented." + } + ] +} +``` + +| Field | Type | Meaning | +|---|---|---| +| `key` | string | A canonical capability key from honua-server's published key list. | +| `status` | `"covered"` \| `"partial"` | `covered`: the SDK implements a full client surface for this capability. `partial`: implemented but incomplete -- see `note`. | +| `sinceVersion` | string | Honest release marker (see above). | +| `entrypoints` | string[] | Dotted paths to the main classes/functions/methods that implement the capability, e.g. `honua_sdk.protocols.StacClient`. Verified to actually resolve against the installed packages every time the gate runs (see below). | +| `note` | string | Required when `status` is `"partial"`; explains exactly where coverage stops. Absent for `"covered"` entries. | + +## How coverage is decided + +This is a *client* SDK, so "covered" means the SDK has a client surface +that talks to the corresponding server capability -- not that the +capability is implemented server-side. The mapping in +`scripts/gen_sdk_coverage.py::COVERAGE` was built by reading the SDK +source (`packages/honua-sdk`, `packages/honua-admin`) against every one of +honua-server's published capability keys, one at a time, during +honua-sdk-python#182. Server-side-only capabilities this SDK's clients +have no surface for at all (identity/SSO protocols, alert/channel +delivery, disaster recovery, printing/staticmap rendering, most +`analytics.*` named processes, plugin SDKs, data import jobs, etc.) are +omitted rather than marked `none`. + +## Drift protection + +Three independent checks run every time the gate executes +(`python scripts/gen_sdk_coverage.py`), and all three are exercised by +`tests/test_sdk_coverage_gate.py`: + +1. **Key validation.** Every key in `COVERAGE` must exist in the + canonical key list. An unknown key (typo, or a key renamed/removed + upstream) fails the gate. +2. **Entrypoint resolution.** Every dotted path in every entry's + `entrypoints` is resolved via `importlib` + `getattr` against the + *installed* `honua_sdk` / `honua_admin` packages. If a referenced + class, function, or method is renamed or removed, the gate fails until + `COVERAGE` is updated in the same PR -- this is the real "coverage + changed without a snapshot update" drift signal from #182's + acceptance criteria. +3. **Snapshot currency.** The committed `compatibility/sdk-coverage.v1.json` + must match `collect_snapshot()`'s current output byte-for-byte. + +```bash +# Run the gate (used in CI; also runnable locally, offline by default) +python scripts/gen_sdk_coverage.py + +# Rewrite the snapshot after an intentional coverage change +python scripts/gen_sdk_coverage.py --update-snapshot + +# Re-pin the offline key-list fixture after an upstream vocabulary change +python scripts/gen_sdk_coverage.py --refresh-key-list-fixture +``` + +## Key-list resolution (fixture vs. live) + +`scripts/gen_sdk_coverage.py` resolves the canonical key list in this +order, mirroring the `KEY_LIST_URL` pattern used by +[honua-samples' `validate-manifests.mjs`](https://github.com/honua-io/honua-samples/blob/trunk/scripts/validate-manifests.mjs): + +1. `HONUA_CAPABILITY_KEY_LIST_URL` env var, if set -- fetched live. CI + sets this on every run (PR and trunk push) so the gate always + validates against honua-server's current published vocabulary, not a + potentially-stale local copy. +2. `compatibility/capability-keys.fixture.json` -- a pinned, + point-in-time offline copy, used whenever the env var is unset (local + dev, offline work, or the unit test suite). This keeps + `tests/test_sdk_coverage_gate.py` network-free and deterministic. + +The generated snapshot itself never embeds which source validated it -- +`collect_snapshot()` is a pure function of `COVERAGE` plus live +introspection, so the committed file is identical either way. + +## CI and release blocking + +Pull request and trunk-push CI run the coverage gate as part of the +`compatibility` job, with `HONUA_CAPABILITY_KEY_LIST_URL` set to +honua-server's published raw URL. On `trunk` pushes only, the validated +`compatibility/sdk-coverage.v1.json` is also uploaded as a build artifact +for honua-evidence's aggregate run to consume. diff --git a/mkdocs.yml b/mkdocs.yml index 499b9b1..57d3bef 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,6 +41,7 @@ nav: - Protocol parity: protocol-parity.md - Authentication: auth.md - Compatibility: compatibility.md + - SDK capability coverage: sdk-coverage.md - Troubleshooting: troubleshooting.md - Diagnostic bundles: diagnostic-bundles.md - Examples: examples.md diff --git a/scripts/gen_sdk_coverage.py b/scripts/gen_sdk_coverage.py new file mode 100644 index 0000000..3595d94 --- /dev/null +++ b/scripts/gen_sdk_coverage.py @@ -0,0 +1,633 @@ +"""Generator + CI gate for ``compatibility/sdk-coverage.v1.json``. + +Publishes this SDK's per-capability coverage snapshot against the canonical +capability key vocabulary owned by honua-io/honua-server (see +honua-server#2893 / #2892 and honua-sdk-python#182). Consumers of the +snapshot (honua-evidence's aggregate run, the capability matrix) join on +``key``. + +Design, mirroring ``scripts/compatibility_gate.py``: + +- ``collect_snapshot()`` is a pure function of :data:`COVERAGE` (the hand- + curated key -> status/entrypoints/note mapping below) plus live + introspection of the installed ``honua_sdk`` / ``honua_admin`` packages. + It does **not** depend on which key-list source was used, so the + committed snapshot is identical whether generated against the pinned + fixture or the live upstream URL. +- Every entrypoint in :data:`COVERAGE` is resolved via ``importlib`` + + ``getattr`` against the *installed* packages. If a referenced class, + function, or method is renamed or removed, generation fails -- this is + the real drift signal: an SDK change that invalidates a coverage claim + fails CI until a human updates :data:`COVERAGE` in the same PR. +- The canonical key list is *consumed, never copied*: every key in + :data:`COVERAGE` is validated against it (unknown key -> fail). Key-list + resolution order (mirrors honua-samples' ``validate-manifests.mjs`` + ``KEY_LIST_URL`` pattern): + + 1. ``HONUA_CAPABILITY_KEY_LIST_URL`` env var, if set -- fetched live. + CI sets this to honua-server's published raw URL on every run, so + PRs validate against current upstream truth (satisfies "unknown keys + fail" from #182). + 2. ``compatibility/capability-keys.fixture.json`` -- a pinned, + point-in-time offline copy, used for local/offline dev so this + script never *requires* network access to run. + +Rules from #182, enforced below: + +- ``partial`` requires a non-empty ``note`` saying where coverage stops. +- Capabilities the SDK does not touch are omitted entirely -- never + padded with a ``none`` entry. +- ``sinceVersion`` is an honest, non-invented marker: this SDK is a + source preview with no published PyPI release, so every entry uses the + literal :data:`SINCE_VERSION` string rather than a package version + number nobody can actually install. + +Usage:: + + python scripts/gen_sdk_coverage.py # run the gate + python scripts/gen_sdk_coverage.py --update-snapshot # rewrite the snapshot + python scripts/gen_sdk_coverage.py --refresh-key-list-fixture # re-pin the fixture +""" + +from __future__ import annotations + +import argparse +import dataclasses +import difflib +import json +import sys +import urllib.request +from collections.abc import Sequence +from pathlib import Path +from typing import Any +from urllib.error import URLError + +ROOT = Path(__file__).resolve().parents[1] +SNAPSHOT_PATH = ROOT / "compatibility" / "sdk-coverage.v1.json" +KEY_LIST_FIXTURE_PATH = ROOT / "compatibility" / "capability-keys.fixture.json" +DEFAULT_KEY_LIST_URL = ( + "https://raw.githubusercontent.com/honua-io/honua-server/trunk/docs/gis/data/capability-keys.v1.json" +) +KEY_LIST_URL_ENV_VAR = "HONUA_CAPABILITY_KEY_LIST_URL" + +SCHEMA_VERSION = "sdk-coverage.v1" + +# This SDK is a source preview: neither honua-sdk nor honua-admin has ever +# been published to PyPI (see AGENTS.md / README "Status: alpha"). Never +# invent a released version number here -- this literal string is the +# honest answer for every entry until a real release ships. +SINCE_VERSION = "unreleased (source preview; not yet published to PyPI)" + +KEY_LIST_POLICY = ( + "Canonical capability key vocabulary is owned and published by " + "honua-io/honua-server (docs/gis/data/capability-keys.v1.json, " + "honua-server#2893). This SDK consumes it for validation only -- keys " + "are never redefined or copied here beyond the entries this SDK " + "actually implements. See scripts/gen_sdk_coverage.py for the " + "HONUA_CAPABILITY_KEY_LIST_URL / pinned-fixture resolution order." +) + + +def _add_source_paths() -> None: + for path in (ROOT / "packages" / "honua-admin", ROOT / "packages" / "honua-sdk"): + value = str(path) + if value not in sys.path: + sys.path.insert(0, value) + + +_add_source_paths() + + +@dataclasses.dataclass(frozen=True) +class CoverageEntry: + key: str + status: str # "covered" | "partial" + entrypoints: tuple[str, ...] + note: str | None = None + + +# --------------------------------------------------------------------------- +# Hand-curated coverage inventory. +# +# Each entry maps a canonical honua-server capability key to what this +# repository's *client* source actually implements. This is a client SDK, +# so "covered" means "the SDK has a surface that talks to that server +# capability", not that the capability is implemented server-side. +# +# Every capability NOT listed here is intentionally omitted (status "none", +# never padded) -- e.g. every ``identity.*``, ``alerts.*``, ``channels.*``, +# ``dr.*``, ``analytics.*``, ``fieldops.*``, ``printing.*``, ``staticmap.*``, +# ``ai.*``, ``routing.*``, ``caching.*``, and ``import.*`` key, because this +# SDK has no client surface for any of them (verified by source grep during +# #182 triage -- those are either server-admin-only config surfaces this +# SDK's control-plane client doesn't expose, or protocols this SDK's data +# plane never implemented). +# --------------------------------------------------------------------------- +COVERAGE: tuple[CoverageEntry, ...] = ( + CoverageEntry( + key="serve.geoservices-root", + status="covered", + entrypoints=( + "honua_sdk.AsyncHonuaClient.list_services", + "honua_sdk.AsyncHonuaClient.list_service_summaries", + ), + ), + CoverageEntry( + key="serve.geoservices-featureserver", + status="covered", + entrypoints=( + "honua_sdk.protocols.GeoServicesFeatureServerClient", + "honua_sdk.protocols.AsyncGeoServicesFeatureServerClient", + "honua_sdk.AsyncHonuaClient.feature_server", + ), + ), + CoverageEntry( + key="editing.featureserver-edits", + status="covered", + entrypoints=( + "honua_sdk.protocols.GeoServicesFeatureServerClient.apply_edits", + "honua_sdk.AsyncHonuaClient.apply_edits", + "honua_sdk.AsyncHonuaClient.apply_edits_result", + ), + ), + CoverageEntry( + key="serve.geoservices-mapserver", + status="covered", + entrypoints=( + "honua_sdk.protocols.GeoServicesMapServerClient", + "honua_sdk.protocols.AsyncGeoServicesMapServerClient", + "honua_sdk.AsyncHonuaClient.map_server", + "honua_sdk.AsyncHonuaClient.export_map", + ), + ), + CoverageEntry( + key="serve.geoservices-imageserver", + status="covered", + entrypoints=( + "honua_sdk.protocols.GeoServicesImageServerClient", + "honua_sdk.protocols.AsyncGeoServicesImageServerClient", + "honua_sdk.AsyncHonuaClient.image_server", + ), + ), + CoverageEntry( + key="serve.geoservices-geometry-service", + status="covered", + entrypoints=( + "honua_sdk.protocols.GeoServicesGeometryServerClient", + "honua_sdk.protocols.AsyncGeoServicesGeometryServerClient", + "honua_sdk.AsyncHonuaClient.geometry_server", + ), + ), + CoverageEntry( + key="raster.temporal-mosaic", + status="partial", + entrypoints=( + "honua_sdk.protocols.GeoServicesImageServerClient.compute_histograms", + "honua_sdk.protocols.GeoServicesImageServerClient.compute_statistics_histograms", + "honua_sdk.protocols.GeoServicesImageServerClient.get_samples", + ), + note=( + "mosaic_rule + a single ISO-8601 time instant select a raster " + "temporal mosaic on the ImageServer histogram/statistics/sample " + "analysis operations only. export_image, tile, identify, and " + "query do not accept a typed time/mosaic_rule parameter (only " + "generic extra_params passthrough), so temporal mosaic selection " + "does not extend to raster rendering/export." + ), + ), + CoverageEntry( + key="raster.multidim-coverage", + status="partial", + entrypoints=("honua_sdk.protocols.GeoServicesImageServerClient.multidimensional_info",), + note=( + "Read-only: the client can query dimension/variable metadata " + "for an already-registered multidimensional ImageServer via " + "multidimensionalInfo. There is no client surface to register " + "or configure a NetCDF4/HDF5/Zarr multidimensional coverage -- " + "that is server-admin configuration this SDK's control-plane " + "client does not expose." + ), + ), + CoverageEntry( + key="serve.ogc-api-features", + status="covered", + entrypoints=( + "honua_sdk.ogc.HonuaOgcFeatures", + "honua_sdk.ogc.HonuaOgcFeatureCollection", + "honua_sdk.ogc.AsyncHonuaOgcFeatures", + "honua_sdk.ogc.AsyncHonuaOgcFeatureCollection", + ), + ), + CoverageEntry( + key="serve.ogc-api-maps", + status="covered", + entrypoints=("honua_sdk.protocols.OgcMapsClient", "honua_sdk.protocols.AsyncOgcMapsClient"), + ), + CoverageEntry( + key="serve.ogc-api-tiles", + status="covered", + entrypoints=("honua_sdk.protocols.OgcTilesClient", "honua_sdk.protocols.AsyncOgcTilesClient"), + ), + CoverageEntry( + key="serve.ogc-api-coverages", + status="covered", + entrypoints=("honua_sdk.protocols.OgcCoveragesClient", "honua_sdk.protocols.AsyncOgcCoveragesClient"), + ), + CoverageEntry( + key="serve.ogc-api-records", + status="covered", + entrypoints=( + "honua_sdk.protocols.OgcRecordsClient", + "honua_sdk.protocols.OgcRecordsCollectionClient", + "honua_sdk.protocols.AsyncOgcRecordsClient", + "honua_sdk.protocols.AsyncOgcRecordsCollectionClient", + ), + ), + CoverageEntry( + key="process.ogc-api-processes", + status="covered", + entrypoints=("honua_sdk.geoprocessing.HonuaGeoprocessing", "honua_sdk.geoprocessing.AsyncHonuaGeoprocessing"), + ), + CoverageEntry( + key="serve.stac", + status="covered", + entrypoints=("honua_sdk.protocols.StacClient", "honua_sdk.protocols.AsyncStacClient"), + ), + CoverageEntry( + key="serve.wfs", + status="covered", + entrypoints=("honua_sdk.protocols.WfsClient", "honua_sdk.protocols.AsyncWfsClient"), + ), + CoverageEntry( + key="serve.wms", + status="covered", + entrypoints=("honua_sdk.protocols.WmsClient", "honua_sdk.protocols.AsyncWmsClient"), + ), + CoverageEntry( + key="serve.wmts", + status="covered", + entrypoints=("honua_sdk.protocols.WmtsClient", "honua_sdk.protocols.AsyncWmtsClient"), + ), + CoverageEntry( + key="serve.odata", + status="partial", + entrypoints=("honua_sdk.protocols.ODataClient", "honua_sdk.protocols.AsyncODataClient"), + note=( + "Read/query only (service_document, metadata, layers, features, " + "and pagination helpers). No create/update/delete entity-set " + "operations are implemented, so the write half of 'query and " + "edit features through OData v4' is not covered." + ), + ), + CoverageEntry( + key="serve.3d-tiles-scene", + status="covered", + entrypoints=( + "honua_sdk.protocols.SceneClient", + "honua_sdk.protocols.AsyncSceneClient", + "honua_sdk.protocols.enumerate_tileset_contents", + "honua_sdk.protocols.parse_scene_package_manifest", + ), + ), + CoverageEntry( + key="serve.i3s-scene", + status="partial", + entrypoints=("honua_sdk.protocols.SceneClient.resolve_scene", "honua_sdk.protocols.SceneClient.fetch_tile"), + note=( + "I3S-tagged scene endpoints resolve and fetch as opaque bytes " + "through the same generic scene-endpoint resolution used for 3D " + "Tiles, but there is no I3S-specific scene-layer-package (.slpk) " + "node/resource-tree parsing -- only the 3D Tiles tileset.json " + "walker (enumerate_tileset_contents) is implemented." + ), + ), + CoverageEntry( + key="scene.catalog", + status="covered", + entrypoints=("honua_sdk.protocols.SceneClient.list_scenes", "honua_sdk.protocols.SceneClient.get_scene"), + ), + CoverageEntry( + key="serve.elevation", + status="covered", + entrypoints=("honua_sdk.protocols.ElevationClient", "honua_sdk.protocols.AsyncElevationClient"), + ), + CoverageEntry( + key="geocoding.forward", + status="covered", + entrypoints=( + "honua_sdk.geocoding.HonuaGeocodingClient.forward_geocode", + "honua_sdk.async_geocoding.AsyncHonuaGeocodingClient.forward_geocode", + ), + ), + CoverageEntry( + key="geocoding.reverse", + status="covered", + entrypoints=( + "honua_sdk.geocoding.HonuaGeocodingClient.reverse_geocode", + "honua_sdk.async_geocoding.AsyncHonuaGeocodingClient.reverse_geocode", + ), + ), + CoverageEntry( + key="discovery.capability-manifest", + status="covered", + entrypoints=( + "honua_sdk.AsyncHonuaClient.capabilities", + "honua_admin.AsyncHonuaAdminClient.get_capabilities", + "honua_admin.AsyncHonuaAdminClient.get_capability_flags", + "honua_admin.AsyncHonuaAdminClient.check_compatibility", + ), + ), + CoverageEntry( + key="admin.control-plane", + status="covered", + entrypoints=("honua_admin.AsyncHonuaAdminClient", "honua_admin.HonuaAdminClient"), + ), + CoverageEntry( + key="styling.ogc-api-styles", + status="covered", + entrypoints=( + "honua_admin.AsyncHonuaAdminClient.list_styles", + "honua_admin.AsyncHonuaAdminClient.get_stylesheet", + "honua_admin.AsyncHonuaAdminClient.get_style_metadata", + "honua_admin.AsyncHonuaAdminClient.update_style", + ), + ), + CoverageEntry( + key="ops.health", + status="covered", + entrypoints=("honua_sdk.AsyncHonuaClient.readiness",), + ), +) + + +def _resolve_entrypoint(dotted_path: str) -> tuple[bool, str | None]: + """Resolve ``dotted_path`` against the installed packages. + + Tries the longest importable module prefix, then walks any remaining + dotted attributes (class -> method) with ``getattr``. Returns + ``(True, None)`` on success or ``(False, error_message)`` on failure -- + the real drift check: a renamed/removed symbol fails this. + """ + import importlib + + parts = dotted_path.split(".") + module = None + split_index = 0 + for index in range(len(parts), 0, -1): + candidate = ".".join(parts[:index]) + try: + module = importlib.import_module(candidate) + except ImportError: + continue + split_index = index + break + if module is None: + return False, f"no importable module prefix for {dotted_path!r}" + + obj: Any = module + for attr in parts[split_index:]: + try: + obj = getattr(obj, attr) + except AttributeError: + return False, f"{dotted_path!r}: no attribute {attr!r} on {obj!r}" + return True, None + + +def check_entrypoints_resolve(entries: Sequence[CoverageEntry] = COVERAGE) -> list[str]: + failures = [] + for entry in entries: + if not entry.entrypoints: + failures.append(f"{entry.key}: must list at least one entrypoint.") + continue + for dotted_path in entry.entrypoints: + ok, error = _resolve_entrypoint(dotted_path) + if not ok: + failures.append(f"{entry.key}: entrypoint drift -- {error}.") + return failures + + +def check_partial_notes(entries: Sequence[CoverageEntry] = COVERAGE) -> list[str]: + failures = [] + for entry in entries: + if entry.status not in {"covered", "partial"}: + failures.append(f"{entry.key}: status must be 'covered' or 'partial', got {entry.status!r}.") + if entry.status == "partial" and not (entry.note and entry.note.strip()): + failures.append(f"{entry.key}: status 'partial' requires a non-empty note saying where coverage stops.") + if entry.status == "covered" and entry.note: + failures.append(f"{entry.key}: status 'covered' should not carry a partial-style note ({entry.note!r}).") + return failures + + +def check_keys_are_unique(entries: Sequence[CoverageEntry] = COVERAGE) -> list[str]: + seen: dict[str, int] = {} + failures = [] + for entry in entries: + seen[entry.key] = seen.get(entry.key, 0) + 1 + for key, count in seen.items(): + if count > 1: + failures.append(f"{key}: listed {count} times in COVERAGE; each capability key must appear once.") + return failures + + +def _extract_canonical_keys(payload: Any, *, source: str) -> set[str]: + if not isinstance(payload, dict) or not isinstance(payload.get("capabilities"), list): + raise ValueError(f"{source}: expected an object with a 'capabilities' array.") + keys = set() + for item in payload["capabilities"]: + if not isinstance(item, dict) or not isinstance(item.get("key"), str) or not item["key"]: + raise ValueError(f"{source}: every capabilities[] entry must be an object with a non-empty 'key'.") + keys.add(item["key"]) + if not keys: + raise ValueError(f"{source}: capabilities[] must not be empty.") + return keys + + +def load_key_list_fixture(path: Path = KEY_LIST_FIXTURE_PATH) -> set[str]: + payload = json.loads(path.read_text(encoding="utf-8")) + return _extract_canonical_keys(payload, source=str(path)) + + +def fetch_key_list(url: str, *, timeout: float = 15.0) -> set[str]: + with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310 -- fixed https URL, not user input + payload = json.loads(response.read().decode("utf-8")) + return _extract_canonical_keys(payload, source=url) + + +def resolve_canonical_keys(url_override: str | None = None) -> tuple[set[str], str]: + """Resolve the canonical key set, returning ``(keys, source_label)``. + + Resolution order: an explicit ``url_override`` argument, then the + ``HONUA_CAPABILITY_KEY_LIST_URL`` env var, then the pinned offline + fixture. Network is only ever touched when a URL is explicitly + requested, so this script (and its tests) run offline by default. + """ + import os + + url = url_override or os.environ.get(KEY_LIST_URL_ENV_VAR) + if url: + return fetch_key_list(url), url + return load_key_list_fixture(), f"{KEY_LIST_FIXTURE_PATH.relative_to(ROOT)} (pinned fixture)" + + +def check_keys_are_canonical(canonical_keys: set[str], entries: Sequence[CoverageEntry] = COVERAGE) -> list[str]: + failures = [] + for entry in entries: + if entry.key not in canonical_keys: + failures.append( + f"{entry.key}: not present in the canonical capability key list " + "-- fix the key, or this capability was renamed/removed upstream." + ) + return failures + + +def collect_snapshot() -> dict[str, Any]: + """Build the sdk-coverage.v1.json document. + + Deliberately independent of which key-list source was used to validate + :data:`COVERAGE`, so the committed snapshot is identical whether + generated against the pinned fixture or the live upstream URL. + """ + capabilities = [ + { + "key": entry.key, + "status": entry.status, + "sinceVersion": SINCE_VERSION, + "entrypoints": list(entry.entrypoints), + **({"note": entry.note} if entry.note else {}), + } + for entry in sorted(COVERAGE, key=lambda entry: entry.key) + ] + return { + "schemaVersion": SCHEMA_VERSION, + "package": "honua-sdk-python (packages/honua-sdk + packages/honua-admin)", + "generator": "scripts/gen_sdk_coverage.py", + "keyListPolicy": KEY_LIST_POLICY, + "capabilities": capabilities, + } + + +def _json_dumps(data: dict[str, Any]) -> str: + return json.dumps(data, indent=2, sort_keys=False) + "\n" + + +def update_snapshot(path: Path = SNAPSHOT_PATH) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(_json_dumps(collect_snapshot()), encoding="utf-8") + + +def check_snapshot_current(path: Path = SNAPSHOT_PATH) -> list[str]: + actual = collect_snapshot() + try: + expected = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return [f"SDK coverage snapshot is missing: {path}"] + + if actual == expected: + return [] + + expected_text = _json_dumps(expected).splitlines() + actual_text = _json_dumps(actual).splitlines() + diff = "\n".join( + difflib.unified_diff(expected_text, actual_text, fromfile=str(path), tofile="current coverage", lineterm="") + ) + return [ + "SDK coverage snapshot drift detected. Run " + "`python scripts/gen_sdk_coverage.py --update-snapshot` after " + "reviewing any intentional coverage change.\n" + f"{diff}" + ] + + +def run_gate(url_override: str | None = None) -> list[str]: + failures = [] + failures.extend(check_keys_are_unique()) + failures.extend(check_partial_notes()) + failures.extend(check_entrypoints_resolve()) + canonical_keys, source_label = resolve_canonical_keys(url_override) + failures.extend(check_keys_are_canonical(canonical_keys)) + failures.extend(check_snapshot_current()) + if not failures: + print(f"Validated {len(COVERAGE)} capability keys against {source_label} ({len(canonical_keys)} known keys).") + return failures + + +def refresh_key_list_fixture(url: str = DEFAULT_KEY_LIST_URL, path: Path = KEY_LIST_FIXTURE_PATH) -> None: + with urllib.request.urlopen(url, timeout=15.0) as response: # noqa: S310 -- fixed https URL, not user input + payload = json.loads(response.read().decode("utf-8")) + capabilities = payload.get("capabilities") + if not isinstance(capabilities, list): + raise ValueError(f"{url}: expected an object with a 'capabilities' array.") + + fixture = { + "_comment": ( + "PINNED, POINT-IN-TIME FIXTURE. This is the offline fallback key " + "list used when HONUA_CAPABILITY_KEY_LIST_URL is unset (local " + "dev / no network). It is a trimmed copy " + "(key/category/edition/displayName only -- descriptions " + "dropped) of honua-io/honua-server's published " + "capability-keys.v1.json. honua-sdk-python does not own this " + "vocabulary -- consume, never redefine it. CI always validates " + "against the live URL (see .github/workflows/ci.yml); this " + "fixture only backstops local/offline runs of " + "scripts/gen_sdk_coverage.py. Refresh with " + "`python scripts/gen_sdk_coverage.py --refresh-key-list-fixture` " + "whenever honua-server's vocabulary changes." + ), + "source": url, + "schemaVersion": payload.get("schemaVersion"), + "capabilities": [ + { + "key": item.get("key"), + "category": item.get("category"), + "edition": item.get("edition"), + "displayName": item.get("displayName"), + } + for item in capabilities + if isinstance(item, dict) + ], + } + path.write_text(json.dumps(fixture, indent=2, sort_keys=False) + "\n", encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--update-snapshot", + action="store_true", + help="Rewrite compatibility/sdk-coverage.v1.json from the current COVERAGE inventory.", + ) + parser.add_argument( + "--refresh-key-list-fixture", + action="store_true", + help="Re-fetch the live key list and rewrite the pinned offline fixture.", + ) + parser.add_argument( + "--key-list-url", + default=None, + help=f"Override the canonical key list URL for this run (defaults to ${KEY_LIST_URL_ENV_VAR} or the fixture).", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.refresh_key_list_fixture: + refresh_key_list_fixture() + print(f"Refreshed {KEY_LIST_FIXTURE_PATH.relative_to(ROOT)}") + return 0 + if args.update_snapshot: + update_snapshot() + print(f"Updated {SNAPSHOT_PATH.relative_to(ROOT)}") + return 0 + + failures = run_gate(args.key_list_url) + if failures: + for failure in failures: + print(failure, file=sys.stderr) + return 1 + print("SDK coverage gate passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sdk_coverage_gate.py b/tests/test_sdk_coverage_gate.py new file mode 100644 index 0000000..fd9f8d5 --- /dev/null +++ b/tests/test_sdk_coverage_gate.py @@ -0,0 +1,249 @@ +"""Tests for the SDK capability coverage snapshot gate (honua-sdk-python#182).""" + +from __future__ import annotations + +import dataclasses +import importlib.util +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GATE_PATH = ROOT / "scripts" / "gen_sdk_coverage.py" +SPEC = importlib.util.spec_from_file_location("gen_sdk_coverage", GATE_PATH) +assert SPEC is not None +assert SPEC.loader is not None +gen_sdk_coverage = importlib.util.module_from_spec(SPEC) +# Register in sys.modules before exec: gen_sdk_coverage.py defines a frozen +# dataclass, and dataclasses' type-resolution machinery looks the defining +# module up via sys.modules[cls.__module__] -- without this it raises +# AttributeError on a None module during class creation. +sys.modules[SPEC.name] = gen_sdk_coverage +SPEC.loader.exec_module(gen_sdk_coverage) + + +def test_snapshot_is_current() -> None: + assert gen_sdk_coverage.check_snapshot_current(gen_sdk_coverage.SNAPSHOT_PATH) == [] + + +def test_snapshot_detects_drift(tmp_path: Path) -> None: + snapshot = gen_sdk_coverage.collect_snapshot() + snapshot["capabilities"].pop() + + snapshot_path = tmp_path / "sdk-coverage.v1.json" + snapshot_path.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8") + + failures = gen_sdk_coverage.check_snapshot_current(snapshot_path) + + assert any("SDK coverage snapshot drift detected" in failure for failure in failures) + + +def test_snapshot_missing_file_reports_failure(tmp_path: Path) -> None: + missing_path = tmp_path / "does-not-exist.json" + + failures = gen_sdk_coverage.check_snapshot_current(missing_path) + + assert any("is missing" in failure for failure in failures) + + +def test_entrypoints_all_resolve() -> None: + assert gen_sdk_coverage.check_entrypoints_resolve() == [] + + +def test_entrypoints_resolve_detects_renamed_class() -> None: + entries = ( + dataclasses.replace( + gen_sdk_coverage.COVERAGE[0], + entrypoints=("honua_sdk.protocols.ThisClassDoesNotExist",), + ), + ) + + failures = gen_sdk_coverage.check_entrypoints_resolve(entries) + + assert any("entrypoint drift" in failure for failure in failures) + assert any("ThisClassDoesNotExist" in failure for failure in failures) + + +def test_entrypoints_resolve_detects_renamed_method() -> None: + entries = ( + dataclasses.replace( + gen_sdk_coverage.COVERAGE[0], + entrypoints=("honua_sdk.AsyncHonuaClient.this_method_does_not_exist",), + ), + ) + + failures = gen_sdk_coverage.check_entrypoints_resolve(entries) + + assert any("entrypoint drift" in failure for failure in failures) + + +def test_entry_requires_at_least_one_entrypoint() -> None: + entries = (dataclasses.replace(gen_sdk_coverage.COVERAGE[0], entrypoints=()),) + + failures = gen_sdk_coverage.check_entrypoints_resolve(entries) + + assert any("must list at least one entrypoint" in failure for failure in failures) + + +def test_partial_status_requires_a_note() -> None: + entries = (dataclasses.replace(gen_sdk_coverage.COVERAGE[0], status="partial", note=None),) + + failures = gen_sdk_coverage.check_partial_notes(entries) + + assert any("requires a non-empty note" in failure for failure in failures) + + +def test_partial_status_rejects_blank_note() -> None: + entries = (dataclasses.replace(gen_sdk_coverage.COVERAGE[0], status="partial", note=" "),) + + failures = gen_sdk_coverage.check_partial_notes(entries) + + assert any("requires a non-empty note" in failure for failure in failures) + + +def test_covered_status_rejects_a_partial_style_note() -> None: + entries = (dataclasses.replace(gen_sdk_coverage.COVERAGE[0], status="covered", note="stops here"),) + + failures = gen_sdk_coverage.check_partial_notes(entries) + + assert any("should not carry a partial-style note" in failure for failure in failures) + + +def test_unknown_status_is_rejected() -> None: + entries = (dataclasses.replace(gen_sdk_coverage.COVERAGE[0], status="none"),) + + failures = gen_sdk_coverage.check_partial_notes(entries) + + assert any("status must be 'covered' or 'partial'" in failure for failure in failures) + + +def test_all_committed_entries_pass_the_note_rule() -> None: + assert gen_sdk_coverage.check_partial_notes() == [] + + +def test_duplicate_keys_are_rejected() -> None: + entries = (*gen_sdk_coverage.COVERAGE, gen_sdk_coverage.COVERAGE[0]) + + failures = gen_sdk_coverage.check_keys_are_unique(entries) + + assert any("listed 2 times" in failure for failure in failures) + + +def test_committed_keys_are_unique() -> None: + assert gen_sdk_coverage.check_keys_are_unique() == [] + + +def test_key_list_fixture_loads_and_covers_every_committed_key() -> None: + canonical_keys = gen_sdk_coverage.load_key_list_fixture() + + assert len(canonical_keys) > 0 + assert gen_sdk_coverage.check_keys_are_canonical(canonical_keys) == [] + + +def test_unknown_key_is_rejected_against_the_fixture() -> None: + canonical_keys = gen_sdk_coverage.load_key_list_fixture() + entries = (dataclasses.replace(gen_sdk_coverage.COVERAGE[0], key="not.a-real-key"),) + + failures = gen_sdk_coverage.check_keys_are_canonical(canonical_keys, entries) + + assert any("not present in the canonical capability key list" in failure for failure in failures) + + +def test_resolve_canonical_keys_defaults_to_the_pinned_fixture(monkeypatch) -> None: + monkeypatch.delenv(gen_sdk_coverage.KEY_LIST_URL_ENV_VAR, raising=False) + + keys, source_label = gen_sdk_coverage.resolve_canonical_keys() + + assert "pinned fixture" in source_label + assert keys == gen_sdk_coverage.load_key_list_fixture() + + +def test_resolve_canonical_keys_prefers_explicit_url_override(monkeypatch) -> None: + monkeypatch.setenv(gen_sdk_coverage.KEY_LIST_URL_ENV_VAR, "https://example.invalid/should-not-be-used.json") + calls = [] + + def fake_fetch(url: str, *, timeout: float = 15.0): + calls.append(url) + return {"fake.key"} + + monkeypatch.setattr(gen_sdk_coverage, "fetch_key_list", fake_fetch) + + keys, source_label = gen_sdk_coverage.resolve_canonical_keys("https://example.invalid/override.json") + + assert calls == ["https://example.invalid/override.json"] + assert source_label == "https://example.invalid/override.json" + assert keys == {"fake.key"} + + +def test_resolve_canonical_keys_uses_env_var_when_no_override(monkeypatch) -> None: + monkeypatch.setenv(gen_sdk_coverage.KEY_LIST_URL_ENV_VAR, "https://example.invalid/from-env.json") + calls = [] + + def fake_fetch(url: str, *, timeout: float = 15.0): + calls.append(url) + return {"fake.key"} + + monkeypatch.setattr(gen_sdk_coverage, "fetch_key_list", fake_fetch) + + _keys, source_label = gen_sdk_coverage.resolve_canonical_keys() + + assert calls == ["https://example.invalid/from-env.json"] + assert source_label == "https://example.invalid/from-env.json" + + +def test_extract_canonical_keys_rejects_malformed_payload() -> None: + try: + gen_sdk_coverage._extract_canonical_keys({"nope": []}, source="test") + except ValueError as error: + assert "capabilities" in str(error) + else: + raise AssertionError("expected ValueError for a payload without 'capabilities'") + + +def test_extract_canonical_keys_rejects_empty_capabilities() -> None: + try: + gen_sdk_coverage._extract_canonical_keys({"capabilities": []}, source="test") + except ValueError as error: + assert "must not be empty" in str(error) + else: + raise AssertionError("expected ValueError for empty capabilities[]") + + +def test_run_gate_passes_offline(monkeypatch) -> None: + monkeypatch.delenv(gen_sdk_coverage.KEY_LIST_URL_ENV_VAR, raising=False) + + assert gen_sdk_coverage.run_gate() == [] + + +def test_collect_snapshot_is_independent_of_key_list_source() -> None: + # The generated document must not embed which key-list source validated + # it (fixture vs. live URL), so the committed snapshot is identical + # regardless of resolve_canonical_keys()'s resolution path. Confirm the + # keys used in this test process (via check_keys_are_canonical) never + # feed into collect_snapshot()'s output. + before = gen_sdk_coverage.collect_snapshot() + gen_sdk_coverage.resolve_canonical_keys() # exercises the fixture path + after = gen_sdk_coverage.collect_snapshot() + + assert before == after + assert "keyListUrl" not in before + assert "keyListSource" not in before + + +def test_snapshot_omits_untouched_capabilities() -> None: + snapshot = gen_sdk_coverage.collect_snapshot() + keys = {entry["key"] for entry in snapshot["capabilities"]} + + # Never-padded per #182: capabilities this SDK does not touch must not + # appear at all, e.g. identity/alerts/channels/dr/ai/routing/caching + # protocols this client has no surface for. + for untouched_prefix in ("identity.", "alerts.", "channels.", "dr.", "ai.", "routing.", "caching.", "fieldops."): + assert not any(key.startswith(untouched_prefix) for key in keys), untouched_prefix + + +def test_snapshot_every_entry_has_the_honest_since_version() -> None: + snapshot = gen_sdk_coverage.collect_snapshot() + + for entry in snapshot["capabilities"]: + assert entry["sinceVersion"] == gen_sdk_coverage.SINCE_VERSION + assert "published to PyPI" in entry["sinceVersion"] or "unreleased" in entry["sinceVersion"] From 0574e998d5850bd2e0571874de05d52d572c49c8 Mon Sep 17 00:00:00 2001 From: Mike McDougall Date: Fri, 17 Jul 2026 10:37:25 -1000 Subject: [PATCH 2/2] ci: pin the new upload-artifact step by commit hash --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ed9987..254767b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,7 +185,7 @@ jobs: # Published on trunk builds only (per honua-sdk-python#182), for # honua-evidence's aggregate capability-matrix run to consume. if: github.ref == 'refs/heads/trunk' && github.event_name == 'push' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: sdk-coverage-v1 path: compatibility/sdk-coverage.v1.json