All notable changes to the Spooled Python SDK are documented here.
Workers hand back a job they claimed while shutting down, WebSocket clients stop reconnecting to a server that keeps closing them, and gRPC errors for a lost lease or an exhausted plan quota carry the same type and code as their REST counterparts. One change affects code that catches rate-limit errors from gRPC calls; read "Changed" before upgrading.
- A gRPC
RESOURCE_EXHAUSTEDfor a plan limit is nowQuotaExceededError. Messages such as "Daily job limit reached" or "queues limit reached (1/1)" share theRESOURCE_EXHAUSTEDstatus with per-second rate limits, and the gRPC client mapped all of them toRateLimitError. When the status has noretry-aftertrailer and the message says that a limit was reached, the client now raisesQuotaExceededError, the error REST raises for a quota. It is a subclass ofRateLimitError, soexcept RateLimitErrorkeeps catching it, but it is not retryable and carries noretry_after: a cap does not clear after a delay. A real rate limit stays aRateLimitError, and itsretry_afteris now read from theretry-aftertrailer when the status carries one. CreateScheduleParams.queue_nameandWorkflowJobDefinition.queue_nameaccept at most 100 characters (they accepted 255). The API rejects anything longer than 100 with a 400, the same cap asPOST /jobs, so a longer name now fails when the model is built instead of when the request is sent.UpdateScheduleParams.queue_nameis unchanged because the update route does not apply a new queue name.
- A worker no longer drops a job it claims while
stop()is running. Backend 0.1.126 and later does not requeue a job whose lease is still live when the worker deregisters, so a job claimed afterstop()had ended its drain window stayed inprocessinguntil the lease expired. The sync and async workers now fail such a job ("Worker stopped before the job started") instead of leaving it unstarted: it is rescheduled whenretry_count < max_retriesand dead-lettered otherwise, the same rule as a handler failure, andjob:failedis emitted. The handler does not run. stop()waits for a claim that is already in flight. A claim request on the wire whenstop()started returns jobs this worker holds leases for.stop()now waits for it so those jobs join the drain. The wait ends at the shutdown deadline, so a claim that hangs or retries cannot holdstop()pastshutdown_timeout; jobs it returns later are failed as above. When the syncstop()runs inside a signal handler on the thread that is blocked in the claim call, it does not wait for itself.- WebSocket clients stop reconnecting after repeated accept-then-close.
WebSocketClient,AsyncWebSocketClientand the WebSocket transport ofSpooledRealtimereset the attempt counter as soon as the upgrade succeeded. A server that accepted the connection and closed it at once (close 1011 when the event channel cannot be opened) therefore refilled the budget on every cycle and the client reconnected forever. The counter is now reset only after a socket stayed up for 10 seconds (STABLE_CONNECTION_SECONDS); a socket that closes sooner keeps counting towardmax_reconnect_attempts, and the client gives up when the budget is spent. An explicitdisconnect()still starts with a fresh budget. - A lost lease over gRPC is
LEASE_EXPIRED. UnaryComplete,FailandRenewLeasereport a lost lease asFAILED_PRECONDITIONwith the text "Lease expired", or the text saying thatlease_iddoes not match the job's current lease, and no error code. The client mapped it to aConflictErrorwith the generic codeconflict, sois_lease_expiredwas false and the worker kept the handler running. It now raisesConflictErrorwith codeLEASE_EXPIRED, so a worker stops the handler and sends no ack, as it does for REST. OtherFAILED_PRECONDITIONanswers, such as "Job is already completed", are still a plainConflictError.
ProcessResponse.error_code: the error code of an in-band ProcessJobs error (for exampleLEASE_EXPIRED), so callers can branch on it instead of parsingProcessResponse.error, which keeps the format"<code>: <message>".
Workers stop working a job whose lease is gone, and a partial DLQ retry now reports the jobs it left behind. Everything is additive: existing calls, parameters and return values keep working, and the fixes only change behaviour that was wrong.
- A worker no longer completes or fails a job after losing its lease. A job
heartbeat answered with HTTP 409
LEASE_EXPIRED(the lease timed out, orlease_idno longer matches the job's current lease) was logged and retried on the next tick. The handler kept running and then sentcompleteorfailwith the stale lease, so the same payload could run on two workers. The sync and async workers now treat that 409, on a heartbeat,completeorfail, as a lost lease: heartbeats and the job timeout timer stop,ctx.signalis set, the handler's result is dropped and no further ack is sent for that job. The async worker also cancels the handler task. A sync handler runs in a thread that cannot be killed, so it should watchctx.signalto stop early. Other 409 answers, such as "Job is already completed" on a replayed ack, behave as before. RetryDlqResponse.job_idsandretried_jobsare the same ids. The API answers withretried_jobs, sojob_idswasNone. It now mirrorsretried_jobs, and a response that only carries the olderjob_idsfillsretried_jobs.
RetryDlqResponse.skipped_jobs: the ids a DLQ retry left dead-lettered because reviving their workflow would pass the plan'smax_workflows. A partial retry is still HTTP 200, soretried_countandskipped_jobsare the only sign that some jobs were not retried.- Worker event
job:lease_lost, withJobLeaseLostEventData(job_id,queue_name,lease_id,operationas"heartbeat","complete"or"fail", and theerror), exported fromspooled.worker. spooled.errors.is_lease_expired(error): true for aConflictErrorwith codeLEASE_EXPIRED. The API uses that one code both when the lease timed out and whenlease_iddoes not match the job's current lease.
Catches up with backend 0.1.122. Everything is additive: existing calls, parameters and return values keep working, and the two fixes only change behaviour that was wrong.
webhooks.list(),get()andupdate()no longer fail on newer webhooks. A webhook subscribed tojob.deadlettered,job.retried,workflow.completedorworkflow.failed(which the dashboard already offers) made every read raisepydantic.ValidationError, for the whole organization's list.update()raised after the server had applied the change. Webhookevents,formatandlast_statusnow accept values this SDK does not know yet.- gRPC
queue.fail(..., retry=False)dead-letters the job again. The bundled proto had a plainbool retry, soFalsewas never put on the wire and the server retried the job up tomax_retries. The proto is synced with the backend (optional bool retry = 4) and the stubs regenerated; the ProcessJobsfailmessage is fixed the same way.retry=Noneleaves the field unset (server default: retry). - No response model fails on a new enum value any more: job, worker and workflow
status, plan tiers (plan_tier,UsageInfo.plan), webhook deliverystatusanddependency_modekeep an unknown value as a string. - gRPC
ALREADY_EXISTSmaps toConflictError(wasServerError). scripts/generate_grpc.shwrites LF line endings on Windows too.
- Webhook
format(json,slack,discord,teams) on create, update and read, and the four new events onWebhookEvent. client.jobs.list()filters:statusas a list or"failed,deadletter",q,job_type,created_after,created_before(RFC 3339; a naive datetime is UTC).jobs.dlq.list()takes the same filters exceptstatus.tagaccepts up to 1024 characters (was 64; the API allows 1024 bytes).jobs.list(),jobs.dlq.list()and the newjobs.history()return aPage: still alist, plushas_morefrom theX-Has-Moreheader.client.jobs.history(job_id, {"order_dir": "desc", "limit": 50})forGET /jobs/{id}/history(JobHistoryEvent:id,job_id,event_type,worker_id,details,created_at).Worker.jobs_processed/jobs_failedand the same onWorkerSummary.client.webhooks.list_inbound_deliveries()for the inbound webhook log (GET /webhooks/deliveries, filtersstatus,since,queue_name).client.rate_limit: theX-RateLimit-Limit,-Remaining,-Reset(Unix seconds, as an aware UTC datetime) and-Scopevalues of the latest response, which the API sends on every response.RateLimitError.scopeis new.- gRPC
workers.register(..., worker_id=...)(stable id upsert) andGrpcRegisterWorkerRequest.worker_id. client.metrics.get(token=...):GET /metricstakes the server's metrics token, not an API key.
- The README no longer says the API sends no
X-RateLimit-*headers, and no longer claimsSpooledClientreadsSPOOLED_API_KEY/SPOOLED_BASE_URL(it does not; the examples use those names).lease_idis documented as required on RESTcomplete/fail/heartbeat(422 without it) and gRPC settlement, and the gRPCStructlimits are spelled out: only object payloads, and integers of 2^53 and above lose precision over gRPC (REST keeps them). scripts/test_local.py/test_production.pyfollow the current API: they sendlease_id,confirm=Trueon DLQ purge, acceptsp_keys, expect/metricsto answer 401 without the metrics token, cover the additions above, and skip steps with side effects outside the test organization (new organization, login email, billing portal) against a non-local server unlessALLOW_UNSAFE=1.
Worker shutdown and failure semantics, realtime auth and several client defaults change, so this is a minor release. Everything below was found by the 2026-10-10 SDK audit and is covered by new unit tests; sync and async clients/workers get every fix.
stop()now drains instead of aborting. It stops claiming jobs, lets running handlers finish for up toshutdown_timeoutseconds, and still sendscomplete(orfail) for a handler that returned (or raised) in that window.ctx.signalis no longer set whenstop()starts; it is set only when a handler outlives the timeout (it is then failed with "Worker shutdown timeout", and the async handler is cancelled) or exceeds the job'stimeout_seconds. Job heartbeats keep running during the drain and the worker deregisters last.start()now returns only afterstop()has finished. Handlers that polledctx.signalto react to shutdown should rely onshutdown_timeoutinstead.PurgeDlqParams.confirmhas no default.client.jobs.dlq.purge({})used to confirm on your behalf and delete every dead-lettered job of the organization; it now raises a validation error. Pass"confirm": Trueexplicitly.- gRPC
complete,failandrenew_leaserequirelease_id(keyword-only, also onGrpcCompleteRequest/GrpcFailRequest/GrpcRenewLeaseRequest). The server already rejected settlement without one withINVALID_ARGUMENT. client.grpcdialsgrpc_address(defaultgrpc.spooled.cloud:443, TLS unless the host is local). It used to dial<api host>:50051and ignore the setting, which cannot reach Spooled Cloud. Self-hosted users setgrpc_address.WorkflowJob.keyandWorkflowJobStatus.keyareNoneinstead of"":GET /workflows/{id}does not return job keys. Mapjob_idto a key with thejob_idsfromworkflows.create().organizations.clear_webhook_token()is deprecated and raises the newUnsupportedOperationErrorwithout a network call: the API always rejects it. Rotate the token withregenerate_webhook_token().SpooledClient(admin_key=...)alone is now accepted (admin endpoints only).
- A handler that finished during shutdown was dropped, then re-run. See above; the job used to be re-queued by deregistering and executed twice.
timeout_secondsis enforced. A handler running past its job's timeout is signalled (ctx.signal), the job is failed with "Job timeout exceeded" and its lease is no longer extended. A sync handler thread cannot be killed, so it keeps occupying a concurrency slot until it returns; the async handler is cancelled.- Failure text is always accepted. An empty error becomes "Job failed" and
anything over 2048 characters is cut to 2048 before
POST /jobs/{id}/fail(the API answered 400, leaving the jobprocessinguntil its lease expired). - Acks are retried.
jobs.complete,jobs.fail,jobs.heartbeatandworkers.heartbeatretry network errors, timeouts, 5xx and 408 (they are lease-fenced, so a replay is safe). A failed ack is reported through the worker'serrorevent instead of only the debug log. - A plan limit is not retried. A 429 with code
QUOTA_EXCEEDED(noRetry-After) raises the newQuotaExceededError(aRateLimitError) at once instead of sleeping 3 x 30 s, and no longer counts toward the circuit breaker, so a full quota cannot block worker acks and reads. get_jwt_token()/realtime()no longer replace the REST bearer. For an API-key client the 24 h JWT became the bearer of every later REST call and was never renewed, so everything returned 401 a day later. The realtime transport keeps its own cached token (renewed before it expires; the asyncrealtime()now supplies a token provider too), and a configuredaccess_tokenwith arefresh_tokenis renewed before it expires.- Concurrent 401s share one token refresh. Only one of N simultaneous
requests used to recover; the rest raised
AuthenticationError. - SSE clients no longer use httpx's 5 s read timeout (60 s now, the server sends keep-alives every 15 s), which reconnected quiet streams every 5 s and lost the events in the gap.
- SSE checks the HTTP status. A 401/403/404 raises the typed error and stops
instead of reconnecting once a second forever; 429/5xx back off and are
retried. The retry counter is restored by a delivered event, not by a mere
connect, so a failing stream runs out of attempts. A job stream (
job_id) ends after the job's terminal status instead of replaying it on every reconnect, and the unifiedrealtime(type="sse")client reconnects when the server ends the stream (its 30 minute cap) and stops, withlast_errorand anerrorevent, on a rejected token. queues.pause(name)without a reason works (it sent an empty body with a JSON content type, which the API answers with 400).with_options()keeps the whole configuration (headers, retry, circuit breaker, user agent, timeouts,grpc_address,ws_url) instead of resetting everything but credentials, base URL, timeout and debug.- 422 responses raise
ValidationError(like 400);detailsmay be a list. - Schedule name/queue name and workflow name/job queue name accept up to 255 characters (the API limit), not 100.
- Async worker keeps references to its per-job tasks and tracks claimed jobs immediately, so a poll cannot over-claim.
- gRPC TLS auto-detection recognises
[::1]:portas local. - Removed a dead placeholder in
AsyncHttpClient.request().
- Synchronous SSE never delivered an event.
SSEClientandrealtime(type="sse")had two stacked bugs, found by running them against the production API: they enteredhttpx.Client.stream(...)by hand and dropped its context manager, so the garbage collector closed the response straight away ("the stream has been closed"); and they passediter_lines()tosseclient, which needs raw bytes to find event boundaries and raisedTypeErroron the first line. Both now usesend(..., stream=True)anditer_bytes(), andAsyncSSEClientkeeps its response open the same way. Verified live:job.createdandjob.completedarrive on all three clients.
- SSE queue subscriptions stream job events.
SSEClient(queue=...),AsyncSSEClient(queue=...)andrealtime(type="sse").subscribe(SubscriptionFilter(queue=...))connected to/events/queues/{name}, which only carriesqueue.stats, sojob.created/job.completedhandlers never fired. They now use/events?queue={name}(URL-encoded). Needs API 0.1.114+, where/api/v1/eventsstarted forwarding job events.
A contract-parity pass against the backend: every fix below is a place where
this SDK's route, request shape, or response model disagreed with what the API
actually serves. Pair it with backend 0.1.112, which supplies the response
fields several of these now read.
Schedule.payload_templateandSchedule.metadatanow accept any JSON. They were typeddict[str, Any], soGET /schedules/{id}raisedValidationErroron a string, array, or boolean.Job.payload/Job.result,ClaimedJob.payload, andCompleteJobParams.resultnow accept any JSON. They were typeddict[str, Any], soGET /jobs/{id}and claim/complete raisedValidationErroron a string, array, or boolean (backendserde_json::Value).Job.tagsandSchedule.tagsnow accept the API's JSON tags: an object ({"urgent": true}) or a string array (["urgent"]). They were typeddict[str, str], soGET /jobs/{id}andGET /schedules/{id}raisedValidationErroron either documented shape. Create/update params match.ingest.custom(sync and async) now maps OpenAPIWebhookResponse(job_id,queue_name,status). It previously required{job_id, created}(raised on empty 200) then returnedNoneand dropped the job id the API actually sends. An empty 200 still maps to unset fields.auth.logout(sync and async) now sends the refresh token in the body. Without it the access token is blacklisted but/auth/refreshstill mints a new pair, so logout did not end the session. The stored refresh token is used when the argument is omitted, matching the Node and PHP SDKs.auth.validatenow mapsclaims.org_id/api_key_id/queues/expontoorganization_id/api_key_id/queues/expires_at. It previously looked for top-levelorganization_idandexpires_at, which the API never sends, so a valid token looked empty.auth.check_email(sync and async) now callsGET /auth/check-email. It previously requestedGET /auth/email/check, which is not a backend route, so every check 404'd. The response model now also readsavailableandsignup_enabled, which the handler always sends.auth.start_email_loginnow readsemail_sent_tofromPOST /auth/email/start. It previously looked foremail_to, which the API never sends, so the masked address was alwaysNone.workflows.jobs.get_dependenciesnow derivesJobDependency.completedfromstatus == "completed". The API never sendscompleted, so the flag was alwaysFalse.organizations.check_slugnow readserrorandsuggestionfromGET /organizations/check-slug. It previously typedslug, which the API never sends, so a taken slug never offered an alternative.JobSummary.retry_countnow mapsattemptfromGET /jobsandGET /jobs/dlq. Those list bodies sendattemptandmax_retries, notretry_count, so the count was alwaysNone.BatchJobStatusnow readsretry_count,created_at, andcompleted_atfromGET /jobs/status. It previously typed onlyid/status/queue_name, so retry count and timestamps were dropped.admin.create_organization(sync and async) now returnsCreateOrganizationResponse.POST /admin/organizationssends{organization, api_key}; validating that wrapper asOrganizationraised on every create and dropped the one-time key.queues.delete()sendsdelete_jobs, andjobs.dlq.purge()sendsconfirmandolder_than. Both parameters exist on the API; the SDK previously dropped them, so "delete the jobs too" silently deleted only the config.ingest.custom()maps OpenAPIWebhookResponse(job_id,queue_name,status). It previously required a{job_id, created}body that the endpoint never sent. An empty 200 — a backend older than0.1.112— still maps to a response with those fields unset rather than raising.- Job list and DLQ summaries read the
job_typeandlast_errorthat backend0.1.112adds toJobSummary.
- Optional stable
worker_idon worker registration. Supplying the same id across restarts makes registration an upsert, so a restarting worker reuses its row instead of leaving the old one against the plan worker cap until the stale-worker reaper clears it (~2 minutes). Omitting it keeps the previous behaviour of a server-minted UUID. An id owned by another organization returns 409. auto_disabledin the outgoing-webhooklast_statusdomain. The backend now disables a webhook after 20 consecutive failed deliveries; re-enable it by settingenabled: true, which is charged against the plan webhook cap and can therefore return429 QUOTA_EXCEEDED.
- Webhook updates can now express clearing the signing secret distinctly from
leaving it alone. Backend 0.1.111 treats an explicit
nullas a destructive clear, so an untouched secret must be omitted rather than serialised. failure_counton outgoing webhooks is now counted once per delivery rather than once per retry attempt, so for the same failures it is roughly 5x smaller than before.- Documented that
last_usedon API keys is coarse (written at most once per key per five minutes) and that webhook delivery history is retained by plan rather than kept indefinitely.
- Backend 0.1.111 no longer accepts API keys in the query string on REST
endpoints. This SDK sends credentials as an
Authorizationheader on REST; SSE and WebSocket connections continue to use the query string, which the backend still supports for those routes.
- REST and gRPC job creation now omit unset retry/timeout defaults so the backend queue/server policy can apply; explicitly provided values are still sent.
- Long-lived gRPC
StreamJobs/ProcessJobscalls no longer inherit the unary 30-second deadline by default. Usestream_timeoutto opt into a stream deadline; stream deadline errors now preserve that timeout in the SDKTimeoutError. - Worker
progress()now emits local job logs instead of silently no-oping. - Maintainer scripts now pass Ruff unused-code checks.
- Add default gRPC per-call deadlines and map
grpc.RpcErrorstatuses into the SDK error hierarchy.
- The default REST
User-Agentnow derives from the canonical package version instead of remaining stuck atspooled-python/1.0.1. - Direct
SpooledWorkerOptionsconstruction now defaults its registration version to the canonical package version instead of1.0.0. - Added release-contract coverage for project metadata, source fallback, REST user-agent, worker options, sync/async workers, and gRPC registration.
- Added a release advisory checklist and strengthened the GitHub Release workflow to validate release metadata and run the normal lint, type, test, build, and artifact checks before Trusted Publishing uploads to PyPI.
- Aligned the optional gRPC runtime and generator floors with the checked-in
generated stubs (
grpcio/grpcio-tools1.80.0+, protobuf 6.33.5+) and regenerated the stubs and lockfile. - Worker and gRPC registration defaults now use the package's canonical version
instead of reporting
1.0.0; source-only imports retain a synchronized1.0.21fallback when installed package metadata is unavailable. - Sync and async workers now bind heartbeat, settlement, and cleanup to the exact active execution, so a stale lease cannot borrow, settle, remove, or cancel a replacement execution for the same job ID.
- Added regression coverage for bidirectional stream lease fencing, async heartbeat lease serialization, unary complete/fail/renew wire fields, and generated-stub imports at the declared minimum dependency versions.
- Lease fencing (backend v0.1.94, audit F9). The claim/dequeue response now
carries a
lease_idfencing token; the SDK captures it onClaimedJoband the workers echo it back on complete/fail/heartbeat. A stale token is rejected server-side with409 LEASE_EXPIRED(gRPCFAILED_PRECONDITION), so a worker whose lease was reclaimed can no longer complete or fail a job it no longer owns. Omitting the token preserves legacy behavior against older servers. - gRPC client fencing support.
complete(),fail(), andrenew_lease()accept an optionallease_idkeyword;GrpcJobexposes thelease_idreturned on dequeue, and theProcessJobsstreaming request models carry it as well. Stubs regenerated from the v0.1.94 proto (Job.lease_id = 19,CompleteRequest.lease_id = 4,FailRequest.lease_id = 5,RenewLeaseRequest.lease_id = 4).
- Realtime WebSocket commands now use the backend
{"cmd": ...}shape.subscribe,unsubscribe, andpingpreviously serialized as{"type": "subscribe"}. The backend'sClientCommandenum is internally tagged withcmd, so those frames failed to deserialize server-side and the command was silently dropped. They now send{"cmd": "subscribe", "queue": ..., "job_id": ...}(and likewise forunsubscribe/ping), matching production. subscribe()/unsubscribe()no longer block on an ack the server never sends. The synchronousWebSocketClientwaited up tocommand_timeoutseconds for a subscribe/unsubscribe/ping acknowledgement and then raisedTimeoutError— the backend sends no such response frame. Commands now fire and return immediately. Subscriptions are still tracked so they are re-sent on reconnect.- Server-Sent Events deliver the payload fields verbatim. Each event is
serialized adjacently-tagged as
{"type": "<PascalCase>", "data": {...}}in the SSEdata:field; the SDK now unwraps that envelope so typed handlers receive the inner fields (job_id,result, ...) instead of the wrapper.
Incoming events are still mapped from the backend's PascalCase type
(JobCompleted, QueueStats, ...) to the SDK's dotted event names
(job.completed, queue.stats, ...) before dispatch, and the untyped
catch-all handler continues to receive every event.
- Credentials are trimmed of surrounding whitespace. API keys, access tokens,
and refresh tokens read from a file or environment variable often carry a
trailing newline; the client now trims them at config resolution (an
all-whitespace value is treated as unset). Prevents a cryptic failure such as
Go's
net/http: invalid header field valueon a newline-tainted key.
- Realtime no longer exchanges the API key for a JWT via
POST /auth/loginon every (re)connect. The exchanged token is now cached on the client (sync and async) and reused across reconnects until it nears expiry, so reconnect storms no longer trip the login rate limiter and realtime can recover. Theexpclaim is read from the token (base64url-decoded, signature not verified) and refreshed ~60s early; a statically configuredaccess_tokenis still used verbatim. The synchronous realtime client mints tokens through this cache on each (re)connect via a token provider, andget_jwt_token(force_refresh=True)forces a re-login when the server rejects the current token.
spooled.__version__now resolves from the installed package metadata instead of a hardcoded literal that lagged the real release (was stuck at1.0.1).
- Requests could hang forever: a per-request
timeout=Nonedisabled the client timeout; the client-level timeout now always applies (sync + async). - Opaque job payloads were mangled: request key-case conversion recursed into
user
payload/result/metadata; those subtrees are now preserved verbatim. - Realtime WebSocket auth exchanges the API key for a JWT and connects with
?token=; standalone SSE clients honorauto_reconnect; SSEdisconnect()no longer wipes queue/job filters; the unified client no longer double-consumes the socket. - Auto token-refresh on 401 is now wired (was dead code).
Retry-Afteris honored on 429; query params are URL-encoded; non-idempotent POSTs are not retried unless an idempotency key is present.- Unknown server event types are dropped instead of misrouted as errors; the server-provided event timestamp is preserved.
- Raised the
protobufsecurity floor to 5.29.6.
- Commit
uv.lockso installs are reproducible.
- Corrected the API key prefix and the realtime example in the README.
- AsyncWorker: replaced deprecated
asyncio.get_event_loop().time()withtime.time()soActiveJob.started_atmatches the wall-clock semantics used by the synchronous worker and avoidsDeprecationWarningon Python 3.12+. - Realtime events:
RealtimeEvent.from_server_eventnow stampstimestampwith timezone-aware UTC (datetime.now(timezone.utc)) instead of a naive local datetime, fixingTypeErrorwhen comparing client-side fallback timestamps against server-provided aware datetimes. - Added the changelog file referenced by package metadata.
- Added missing
types-protobufdev dependency somypy --strictpasses in fresh environments. - Updated GitHub Actions workflows to current checkout, Python setup, and artifact actions.
Earlier release history is available in the repository tags.