Make the meter-driver client conform to the Meter Driver Specification v1.4.0 - #33
Conversation
Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
sparkmeter/config/provider_settings.pyLines 1265-1274 1265 if not provider:
1266 return []
1267 try:
1268 return list(_stored_field_specs(load_provider_runtime_settings(provider)).values())
! 1269 except DriverConfigError:
! 1270 return []
1271
1272
1273 def get_live_interface_details(service_url, selected_interface=None, timeout=2.0, provider=None):
1274 """Fetch the interface inventory the driver advertises right now.sparkmeter/conftest.pyLines 88-96 88 raise requirements_error
89 return FakeJsonResponse({"required_fields": list(required_fields)})
90 if url.endswith("/openapi.json"):
91 return FakeJsonResponse(document)
! 92 raise AssertionError("unexpected GET {}".format(url))
93
94 fake_get.calls = calls
95 return fake_get |
|
Identified by Claude Fable 5.1, reviewed and verified by human. Separately, a test is failing intermittently when running locally (threaded concurrency test in P1 — fix before merge
P2 — should fix, or explicitly accept
P3 — minor
|
1f5758a to
790419b
Compare
The README directs contributors to the groundbolt-dev workspace for the development environment, links it wherever it was named, and marks the webapp-only local setup as the exception rather than the default.
… HTTP host HttpCommandClient.init_driver posts to the spec's /v1/init route. GrpcCommandClient.init_driver raises a ValueError naming whichever of heartbeat_period_duration and aes_key the payload lacks, since the ConfigureDriver message has fixed fields. _grpc_target returns only an advertised or saved target; build_command_client and build_event_client raise GrpcTargetUnavailable when gRPC is selected without one instead of guessing host:50051 or silently falling back to HTTP. Docstrings now point at the meter_driver_spec.grpc stubs from the meter-driver-spec wheel.
…ter-driver block
validate_contract requires every route the Meter Driver Specification
v1.4.0 marks required (/v1/requirements, /v1/init, /v1/nodes/register,
/v1/nodes/{node_id}, /v1/nodes/{node_id}/configure-meter,
/v1/meters/configure, /v1/events, /v1/status, /v1/healthz) and reads
interfaces from the x-meter-driver block, synthesizing an http interface
when the block is absent.
Init fields come from GET /v1/requirements, which is now mandatory and
raises ProviderRegistrationError on failure; each name is typed from the
document's InitRequest schema (the /v1/init request body, else
components.schemas.InitRequest), with a oneOf such as AesKeyInput reduced
to its string alternative, and undocumented names typed as string. The
/v1/commands configure_provider vendor-option schema remains as an
optional extension whose fields follow the required ones with
required=False; validate_provider_config_payload no longer demands
values for such fields and omits blank optional values from the init
payload.
get_runtime_status probes only /v1/healthz; the legacy /health leg is
gone.
Tests use spec-shaped documents plus meter_driver_spec_openapi.json, the
spec's own openapi/meter-driver.yaml converted to JSON.
_load_driver_init_payload no longer hardcodes aes_key and heartbeat_period_duration. It validates the driver's config file with validate_provider_config_payload, so the payload is exactly the fields the driver reported on GET /v1/requirements (persisted as required_fields, typed from its InitRequest schema) with the stored field_values coerced to those types; blank optional extras are omitted and a driver requiring no fields gets an empty body. A missing required value or an uncoercible value skips init with a warning instead of posting a partial body. Only aes_key, when present as a string, is format-checked as 32 hex characters; a byte-array key passes through.
The spec's GET /v1/events declares no parameters, so stream_json_events sends no client_id query string; the client identifies itself through the X-Client-Id header alone.
The side-channel set in events.py is exactly the Meter Driver Specification's event type constants; the reference driver's own alias for driver_configuration_applied is no longer accepted and is logged as an unknown type like any other non-spec name.
A driver serving the spec's own OpenAPI document is registered from its base URL, its generated config lists the fields from /v1/requirements, and reconcile then sends exactly one POST /v1/init carrying those fields and nothing else. The events tests pin the side-channel set to the spec's event names and check that a vendor alias is reported as unknown; the lifespan tests use a spec event name for their "other event" case.
… discovery from requirements
Contract validation now checks the spec's required operations rather
than path keys: each of GET /v1/requirements, POST /v1/init, POST
/v1/nodes/register, DELETE /v1/nodes/{node_id}, POST
/v1/nodes/{node_id}/configure-meter, POST /v1/meters/configure, GET
/v1/events, GET /v1/status and GET /v1/healthz must be documented under
its path with that method. Path parameters match by position, so
/v1/nodes/{id} is accepted; a trailing slash is not. Every document
access is type-guarded (document, info, paths, x-meter-driver, $ref
targets, oneOf/anyOf alternatives, allOf parts) so a malformed document
raises ProviderRegistrationError, never AttributeError.
Init-field discovery validates GET /v1/requirements with the wheel's
RequirementsResponse model (required_fields is array[string]; nothing is
stringified, blank names are rejected), names the transport error in its
message, warns for a field absent from InitRequest.properties, follows
$ref chains with a cycle guard, merges allOf composition, and reads
OpenAPI 3.1 type arrays.
validate_contract is split: inspect_contract fetches and validates the
document and discovers interfaces in one round trip, and is what
get_live_interface_details uses, so an advertised gRPC target is never
lost to a slow or failing /v1/requirements; validate_contract adds the
requirements round trip for registration. get_live_interface_details
takes an optional provider and reports the fields recorded in its config
file. save_provider_settings loses the unread aes_key and channel
parameters.
get_runtime_status is offline unless /v1/healthz answers a JSON object
with "ok": true, and when /v1/status answers something other than a
JSON object.
Config payloads: whitespace-only values count as missing; values are
checked against the recorded pattern, minimum and maximum; only fields
the driver listed are sent; aes_key must be 32 hex characters or an
array of 16 byte values; bare-name required_fields entries from configs
written before types were recorded are treated as required strings with
a warning pointing at re-registration.
The spec-document fixture, a fake response class and a fake_driver
factory move to sparkmeter/conftest.py; the fixture's regeneration
command is documented there and a test checks its version against the
installed meter-driver-spec wheel.
check_grpc_selection requires an advertised grpc interface with a target and, because the gRPC ConfigureDriver message has fixed fields, heartbeat_period_duration and aes_key among the driver's required /v1/requirements fields. save_provider_settings applies it to a gRPC selection instead of silently falling back to http, and the settings form's validate_selected_interface reports the same message. The driver-field helpers on the forms are named for what they list (driver_field_spec, driver_fields), both templates label the list "Driver fields" and mark each entry required or optional, and the views pass the saved provider so its recorded fields are shown without a /v1/requirements round trip.
ensure_metering_runtime catches GrpcTargetUnavailable from the client builders, logs an error naming the provider, closes a command client built before the event client failed, leaves app.state.metering unset and returns False instead of aborting startup.
_load_driver_init_payload no longer applies its own AES hex regex; the
spec's AesKeyInput forms, the recorded pattern/minimum/maximum and the
field filter are enforced by validate_provider_config_payload. Tests
cover an empty payload initializing the driver with {} and a ValueError
from init_driver propagating out of reconcile_all.
_grpc_event_to_raw_dict no longer adds renamed convenience fields (event_type, meter_id, energy_wh, ...) that nothing reads. Readings are rendered with every spec field, the phased reading with all per-phase fields, phases and computed_fields_version, so both parse as the wheel's models; pass-through messages keep default-valued fields and restore a 64-bit node_id to an integer. The http_sse docstring states that X-Client-Id is ThunderCloud's own convention, and events.py records that the OpenAPI event type constants are normative over the differently named prose table in the spec's section 6.
The README's Meter drivers section describes what a meter driver is here, that any driver implementing the Meter Driver Specification v1.4.0 works, how one is registered and configured from Global Settings > Meter Drivers, where its config file lives, and that the development workspace runs the meter-driver-emulator so no gateway hardware is needed.
|
This branch is based on
I rebased just those onto current I also verified it against the real Not proposing to force-push over this branch — just offering |
792811c to
5cbe4da
Compare
Registration lists the /v1/requirements fields as required, then every other property of the contract's InitRequest schema as an optional field typed from that schema, then the /v1/commands vendor-option extras for names not already listed. A spec-conformant driver that leaves the optional channel out of /v1/requirements now receives an operator-supplied channel in its init body; a blank optional value is still omitted. InitRequest typing takes precedence over vendor-option typing for a name present in both, as it already did for aes_key.
MessageToDict omits an unset submessage field even with always_print_fields_with_no_presence, so a heartbeat whose driver never set millisecond_read_reply_stats or millisecond_set_config_reply_stats, or a gateway status without firmware_version, failed the spec model and was dropped. Pass-through events now render every singular submessage outside a oneof from its value, which is the zeroed default when unset; oneof members and google.protobuf wrappers stay absent when unset. The gRPC event tests build real meter_driver_pb2 messages and validate the heartbeat and gateway status payloads against the spec models instead of replacing MessageToDict.
get_runtime_status reported a driver offline when /v1/status answered JSON that is not an object, while a transport failure or invalid JSON on the same call left it online with no gateway. Liveness comes from /v1/healthz alone; any unusable /v1/status answer now reports online with no gateway.
Registration now lists the InitRequest channel property as an optional field after the /v1/requirements fields. The reconcile test asserts it in the generated config, leaves it blank, and checks the single POST /v1/init still carries only the filled-in fields.
The driver client followed the reference driver's routes rather than the specification: init went to
/v1/sparknet/init, registration required a/v1/commandsroute the spec does not define, discovery read a non-spec extension block, and/v1/requirementswas only probed when that vendor route existed. A driver serving exactly the spec could not be registered or initialized.Now the client validates a driver's
openapi.jsonagainst the spec's required routes and methods, readsx-meter-driver, always discovers init fields from/v1/requirementstyped by the document'sInitRequest, posts init to/v1/initwith those fields, and recognizes exactly the spec's event names./v1/commandsvendor options remain as an optional extra. A gRPC selection with no advertised target, or with a contract that lacks the proto's fixed init fields, is refused at registration; if such a provider is already saved, startup logs it and continues with metering inactive instead of aborting.Tests cover every changed behavior against a fixture that is the spec's own OpenAPI document. The README's Meter drivers section describes registration and configuration against the specification.
pydantic, imported directly by the contract validation, is now a declared dependency.