mirror of
https://github.com/Comfy-Org/comfy-python-sdk.git
synced 2026-11-04 23:37:47 +00:00
[PR #204] [MERGED] feat(jobs): label jobs with metadata and list jobs by label #204
Labels
No labels
audit:ai-readiness
audit:community
audit:github
audit:health-scoring
audit:policy
audit:release
audit:supply-chain
effort:medium
effort:small
effort:small
effort:trivial
priority:nice-to-have
priority:nice-to-have
priority:recommended
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Comfy-Org/comfy-python-sdk#204
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/Comfy-Org/comfy-python-sdk/pull/204
Author: @vqt123
Created: 10/5/2026
Status: ✅ Merged
Merged: 10/8/2026
Merged by: @vqt123
Base:
main← Head:vinh/dplat-2463-job-metadata📝 Commits (10+)
8008597feat(jobs): job metadata on submit, Job.metadata, and list_jobsc3cac1ffeat(jobs): map metadata_invalid to InvalidWorkflow; JobSummary carries create/update time and deployment idc4ecefdfix(jobs): refused metadata raises the base ComfyError; JobSummary times are datetimes9de4aecdocs(jobs): note invalid_cursor and what JobSummary.deployment_id names35d95bddocs(jobs): Comfy Cloud refuses labels for now (metadata_not_supported)848938cfix(jobs): read odd job metadata leniently; retry a paced 429 on each list page969d2a7fix(jobs): check list filters on each item; wait at least a second on a 429b17d4adfix(jobs): compare list filter values as the text the query sends7cbdbbefix(jobs): pickle a job with metadata; check list filters as the query sends them45d610afix(jobs): raise ComfyError for a job list item without id or status📊 Changes
11 files changed (+1466 additions, -48 deletions)
View changed files
📝
CHANGELOG.md(+37 -0)📝
README.md(+84 -2)📝
src/comfy_low/__init__.py(+4 -2)📝
src/comfy_low/transport.py(+137 -17)📝
src/comfy_sdk/__init__.py(+2 -1)📝
src/comfy_sdk/client.py(+183 -7)📝
src/comfy_sdk/jobs.py(+117 -3)📝
tests/conftest.py(+79 -6)📝
tests/test_async.py(+2 -2)➕
tests/test_job_metadata.py(+814 -0)📝
tests/test_jobs.py(+7 -8)📄 Description
What changes for a developer
Before: a job had no labels. To find "all the jobs I ran for customer acme", a developer had to keep their own list of job ids.
Now:
submittakes string labels, the job returns them, andlist_jobsfinds jobs by them.submit(..., metadata=...)onComfyandAsyncComfysends the map as themetadatafield ofPOST /api/v2/jobs. Without it, the request body is the same as before.COMFY_BASE_URL). Comfy Cloud refuses them for now:submitgets 422metadata_not_supportedandlist_jobsgets 501not_implemented, each raised asComfyErrorwith that code.metadatais a single string, so it refuses a label map with 422invalid_request. The SDK reads its stringmetadataas no labels, so a filteredlist_jobsyields nothing there, and an unfiltered one yields the proxy's newest jobs (50 by default, up to 100 withlimit), each withmetadata{}, and stops there, since the proxy sends no next cursor. Its items carrycreated_at, notcreate_time, socreate_timereads asNonethere.Job.metadataandAsyncJob.metadatareturn the labels, or{}when the job has none. Ametadatathat is not a map of strings reads as{}, and a value that is not a string is dropped. It never raises, so an odd answer to a submit cannot hide a job the server already created.list_jobs(metadata=None, limit=None)callsGET /api/v2/jobswith onemetadata[<key>]=<value>query parameter per filter. It followsnext_cursoruntil the last page and yields every match.limitis the page size. The SDK also checks the filters on each item and skips one whose labels do not match, comparing each key and value as the text the query sends ({"run": 7}matches the label "7",{1: "a"}the key "1"). The query and the check build their pairs with one helper, so they cannot disagree. So a host that ignores the filters yields only real matches, though one step of the iteration can then read several pages, or every page, before it yields or ends. Each page retries a 429 that carriesRetry-After, the same waysubmitdoes, with its own 60-second budget.submitandlist_jobsnow waits at least one second, soRetry-After: 0cannot drive a tight loop. Before,submitretried at once onRetry-After: 0. OnAsyncComfyit is an async iterator.JobSummary(id,status,create_time,update_time,deployment_id,metadata, anddata, the item as the server sent it).create_timeandupdate_timearedatetimevalues. The fields match the TypeScript SDK's list item. A list item has no outputs or links, so it is not aJobhandle;client.jobs.get(summary.id)returns the full job.metadata_invalidraisesComfyErrorwith that code and the server's message. The message names the key when one key or value breaks a rule, and gives the count when there are more than 16 pairs. A bad filter raisesComfyErrorwith codeinvalid_metadata_filter, and a cursor the server did not issue raisesComfyErrorwith codeinvalid_cursor.deployment_idis the deployment copy that ran the job, so after a deployment update it can differ from the deployment's current id.Generated code is untouched
spec/openapi.yamlandsrc/comfy_low/models/_generated.pydo not have these fields yet, and this PR does not edit them. The transport is hand-written, so the change lives there:Jobwith a lenientmetadatafield. It overrides the field rather than adding one, so it keeps reading leniently after the spec sync adds a strictmetadatatoJob. A test simulates that strict field.list_jobson the transport returns each page as a dict. It is not inOPERATION_IDSyet. When the spec sync adds the list operation,tests/test_spec_coverage.pywill ask for it to be added there, mapped tolist_jobs.Depends on
The server-side job-labels change, which adds job metadata and the job list filters. It is in production.
Tests
New file
tests/test_job_metadata.py, against the existing stub server intests/conftest.py(extended to echometadataor serve any given shape of it, serveGET /api/v2/jobsin pages with realJobStatusvalues, answer chosen list requests 429, and record each list path and query):submitwithmetadataputs it in the body besideworkflow, and the returned job exposes it (sync and async).submitwith nometadata, or with{}, sends exactly{"workflow": ...}(sync and async).{}; the returned dict is a copy;refresh()keeps the labels (async).list_jobssendsmetadata[client]=acme,metadata[run]=...andlimit=2, follows the cursor across three pages, yields all five jobs in order, and makes no request after the last page (sync). The async client follows two pages with the same filters.list_jobs()with no arguments requests the bare/api/v2/jobs, not/jobs?.cancel()keepsmetadataon the returned job (sync and async).metadatais{"n": 1},{"n": null, ...}, the string"batch-7"or a list reads as labels on submit,jobs.get,refresh,waitandcancel(sync and async), and on a list item. AJobwith a strictmetadata: dict[str, str]field, as the spec sync will add, still reads them.__proto__,constructor, an empty key, punctuation, non-ASCII.metadatais a string, and keeps paging (sync and async).metadata={"run": 7}yields the job labelled run "7". On a proxy-shaped list a filtered call yields nothing.Retry-After: 0on a list page waits 1 second (sync and async); the existing submit tests forRetry-After: 0and-5now expect 1 second instead of 0.hash(summary)works. A job model from post, get or cancel pickles and unpickles with itsmetadata(sync and async).{1: "a"}and{"1": b"a"}sendmetadata[1]=aand keep the job labelled "1": "a".RateLimited; a list 429 withoutRetry-Afterraises at once with no wait. A page with nojobskey yields nothing, and paging continues past it (sync and async).Retry-After: 3on the first request for each page is retried after 3 seconds with the same query (sync and async). A 501not_implementedraisesComfyErrorwith that code and is not retried.JobSummarykeepsid,status,create_time,update_time,deployment_idand the raw item; missing optional fields read asNoneand missing labels as{}. Nanosecond times parse; an unreadable time reads asNoneand stays indata.metadata_invalidraisesComfyError(notInvalidWorkflow) with that code, status 422 and the server's real message (the key for a bad key, the count for 17 pairs); the submit is not retried (sync and async).invalid_metadata_filterfrom the list raisesComfyErrorwith that code (sync and async); a 400invalid_cursordoes the same.JobSummaryshows its id and status but notdata, the raw item; equality and the id-only hash are unchanged.idorstatusis missing ornull, or that is not a JSON object, raisesComfyErrorwith codeinvalid_responsenaming the problem, and is not skipped; so does a page that is not an object, a page whosejobsis not an array (a missing ornulljobsreads as an empty page), anid,statusordeployment_idof the wrong type, and anext_cursorthat is not a string (0,falseand[]included) or repeats one already sent; anullor emptynext_cursorends the list (sync and async).metadata_not_supported(Comfy Cloud today) raisesComfyErrorwith that code, notInvalidWorkflow, and is not retried.Run locally, each with its own exit status:
ruff check .passed,ruff format --check .passed,mypy srcpassed,pytest1236 passed and 9 skipped (the skips are the live-server tests),scripts/check_drift.pypassed,scripts/check_public_repo_hygiene.pypassed.Not run: this SDK against a live server. The server-side change passed its own test on a staging deployment, covering the same request and response shapes.
Risk: low. It was rated high only for the release order, which is now met: the server-side job-labels change is in production. The code itself is low risk: Every new argument is optional and defaults to the old request. The generated models are not edited. Job responses now go through a subclass that adds one optional field, so a response without
metadatareads exactly as before. One existing behaviour changes:submitwaits at least 1 s before re-sending after a 429, even whenRetry-Afteris 0.Summary by CodeRabbit
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.