Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ All notable changes to this project will be documented in this file.

### Added

- `Workspace.workflow_deploy_gates()` returns a client for workflow deploy gates.
`save_version` saves a config as a version without publishing it, `run` starts
the version's deploy checks, `get_version` and `wait_for_version` read the
verdicts, and `deploy` publishes a version once it passed every gate. The
Workflow Eval gate is managed with `get_eval_gate`, `set_eval_gate`,
`update_eval_gate`, `set_eval_gate_new_check_thresholds`, `remove_eval_gate`
and `get_eval_checks`. The manual approval gate is managed with
`get_approval_gate`, `set_approval_gate` and `remove_approval_gate`, and
`list_approvals` and `get_version_approval` read who approved a version.
Approvers decide in the Roboflow app. Errors raise `WorkflowDeployGateError`,
whose `next_steps` lists what publishes the workflow
([#579](https://github.com/roboflow/roboflow-python/pull/579)).
- `roboflow workflow version save`, `roboflow workflow deploy-gates list|run|status`,
`roboflow workflow deploy-gates eval get|set|update|new-check-thresholds|remove|checks`,
`roboflow workflow deploy-gates approval get|set|remove|list|status`
and `roboflow workflow deploy`, which replaces the hidden stub. `run --wait` and
`status --wait` exit with code 1 when the version is not publishable
([#579](https://github.com/roboflow/roboflow-python/pull/579)).
- `Project.autolabel(..., keypoint_confidence_threshold=0.75)` and
`roboflow autolabel start --keypoint-confidence 0.75` set the lowest confidence
a predicted keypoint needs to be saved as visible when a keypoint-detection
Expand All @@ -21,6 +39,15 @@ All notable changes to this project will be documented in this file.
are already labeled aren't duplicated
([#570](https://github.com/roboflow/roboflow-python/pull/570)).

### Changed

- `rfapi.update_workflow` and `roboflow workflow update` on a workflow with deploy
gates raise `WorkflowDeployGateError` (a `RoboflowError`) with the server's
`code`, `gate_types` and `next_steps`, instead of a `RoboflowError` holding the
raw JSON. The server saves nothing in that case: save a version, run its
deploy checks and deploy it. Other `update_workflow` and `get_workflow` errors
now carry `status_code` ([#579](https://github.com/roboflow/roboflow-python/pull/579)).

### Deprecated

- `Project.autolabel(..., preserve_existing_annotations=True)` still works but
Expand Down
25 changes: 25 additions & 0 deletions CLI-COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,31 @@ roboflow workflow version list my-workflow
roboflow workflow fork other-ws/their-workflow
```

### Publish a workflow with deploy gates

A workflow with a deploy gate publishes only a saved version that passed it, so
`workflow update` is refused there. Save a version, run its checks, then deploy it:

```bash
roboflow workflow version save my-workflow --definition updated.json # prints the version id
roboflow workflow deploy-gates run my-workflow <version-id> --wait # exits 1 unless publishable
roboflow workflow deploy my-workflow <version-id>

# Gate a workflow on a Workflow Eval, with per-check minimum pass rates.
roboflow workflow deploy-gates eval set my-workflow --eval <eval-id> --threshold code-reading=0.98
roboflow workflow deploy-gates eval get my-workflow
roboflow workflow deploy-gates list

# Gate a workflow on manual approval: one approval from anyone named approves a version.
roboflow workflow deploy-gates approval set my-workflow --user <user-id> --role admin
roboflow workflow deploy-gates approval status my-workflow <version-id>
```

On an Auto-Publish workflow a version that passes publishes itself, so `deploy` is only needed on
a Protected one. Approvers approve or reject in the Roboflow app; the CLI only reads approvals, and
`run --wait` also waits for one. Setting or removing the approval rules needs a person, so an API
key is refused there.

### Fork a Universe project (async)

```bash
Expand Down
49 changes: 49 additions & 0 deletions docs/core/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,52 @@ Pages include Dataset revisions, coverage counts and a possibly unknown (`None`)
`total`. Follow `nextCursor` even when `items` is empty, keeping the same query and
limit (1–48, default 24). On HTTP 409, restart without a cursor. Preview URLs expire.
This search does not execute a Workflow or create embeddings.

## Publish a Workflow with Deploy Gates

A workflow with a deploy gate publishes only a saved version that passed every gate, so
`update_workflow` raises `WorkflowDeployGateError` (`code == "deploy_check_required"`) and saves
nothing. Save a version, run its gates and deploy it:

```python
gates = workspace.workflow_deploy_gates()
workflow_id = workspace.get_workflow("my-workflow")["workflow"]["id"]

version_id = gates.save_version(workflow_id, definition)["version"]["id"]
gates.run(workflow_id, version_id)
verdicts = gates.wait_for_version(workflow_id, version_id)
if verdicts["publishable"]:
gates.deploy(workflow_id, version_id)
else:
for gate in verdicts["gates"]:
print(gate["type"], gate["status"], gate["reason"])
```

`save_version` and `run` send an `Idempotency-Key`; pass `idempotency_key=` to retry one safely.
On an Auto-Publish workflow a version that passes publishes itself, so `deploy` is only needed on a
Protected one. Scopes: `workflow:update` to save and deploy; `workflow:update`,
`workflow-evals:run`, `workflow:read` and `model:infer` to run; `workflow:read` to read verdicts.
Add `workflow-evals:read` to see a check's Eval Run results. Folder-scoped keys are refused.

To gate a workflow on a Workflow Eval:

```python
gates.set_eval_gate(workflow_id, "EVAL_ID", check_thresholds={"code-reading": 0.98})
```

Checks left out need every applicable Case to pass.

A manual approval gate waits for one approval from a member or role its rules name. Approvers
decide in the Roboflow app, so `run` starts nothing for it and `wait_for_version` waits for the
approval:

```python
gates.set_approval_gate(workflow_id, user_ids=["USER_ID"], roles=["admin"])
approval = gates.get_version_approval(workflow_id, version_id)
print(approval["status"], approval["missing"])
```

Approvers need the Manage Workflows permission, and the version's author cannot approve it unless
`allow_self_approval=True`. Changing the rules makes earlier decisions stop counting. Only a person
changes the rules: `set_approval_gate` and `remove_approval_gate` refuse a plain API key with
`deploy_approval_rules_user_required`.
17 changes: 15 additions & 2 deletions roboflow/adapters/rfapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -1756,7 +1756,7 @@ def get_workflow(api_key, workspace_url, workflow_url):
params={"api_key": api_key},
)
if response.status_code != 200:
raise RoboflowError(response.text)
raise RoboflowError(response.text, status_code=response.status_code)
return response.json()


Expand Down Expand Up @@ -1813,6 +1813,14 @@ def update_workflow(api_key, workspace_url, *, workflow_id, workflow_name, workf
definitions (``{"version", "inputs", "steps", "outputs"}``) are
auto-wrapped in ``{"specification": ...}`` to match the backend's
stored shape; see ``_normalize_workflow_config``.

Raises:
WorkflowDeployGateError: the workflow has deploy gates, so its config
cannot publish this way (``409 deploy_check_required``). Nothing was
saved. ``exc.next_steps`` lists the requests that publish it: save a
version, run its deploy checks and deploy it once they pass (see
``Workspace.workflow_deploy_gates``). It is a ``RoboflowError``.
RoboflowError: any other failure, with ``status_code`` set.
"""
config = _normalize_workflow_config(config)
payload: Dict[str, str] = {
Expand All @@ -1827,7 +1835,12 @@ def update_workflow(api_key, workspace_url, *, workflow_id, workflow_name, workf
json=payload,
)
if response.status_code != 200:
raise RoboflowError(response.text)
from roboflow.adapters.workflowdeploygatesapi import deploy_gate_refusal

refusal = deploy_gate_refusal(response)
if refusal is not None:
raise refusal
raise RoboflowError(response.text, status_code=response.status_code)
return response.json()


Expand Down
117 changes: 117 additions & 0 deletions roboflow/adapters/workflowdeploygatesapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Transport for the public Workflow deploy gates API.

Every route lives under ``{API_URL}/workspaces/{workspace}/workflows``. The API
shares the Workflow Evals API's bearer auth, ``Idempotency-Key`` rule and error
envelope, so this module reuses that transport's helpers. The per-endpoint
surface lives in :class:`roboflow.core.workflow_deploy_gates.WorkflowDeployGates`.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Dict, List, Optional
from urllib.parse import quote

from roboflow.adapters.workflowevalsapi import (
DEFAULT_TIMEOUT,
WorkflowEvalError,
_clean_params,
_headers,
error_from_response,
)
from roboflow.config import API_URL
from roboflow.util import http

if TYPE_CHECKING:
import requests

# The codes a deploy gate refusal carries, on the deploy gates API and on the
# legacy publish paths (``POST /{workspace}/updateWorkflow``).
DEPLOY_GATE_ERROR_CODES = frozenset(
{
"deploy_check_required",
"deploy_check_not_passed",
"deploy_check_conflict",
"deploy_check_unavailable",
"deploy_check_local_inference_not_allowed",
"deploy_gate_invalid_eval",
"deploy_gate_feature_unavailable",
"deploy_gate_permission_required",
"deploy_gate_invalid_rules",
"deploy_approval_rules_user_required",
"deploy_approval_not_allowed",
"deploy_approval_invalid_reviewers",
}
)


class WorkflowDeployGateError(WorkflowEvalError):
"""Error returned by the deploy gates API, or a publish a deploy gate refused.

Same envelope as :class:`WorkflowEvalError`: ``code``, ``category``,
``retryable``, ``request_id``, ``message`` and ``details``. A refused publish
(``deploy_check_required`` from ``updateWorkflow``, ``deploy_check_not_passed``
from :meth:`WorkflowDeployGates.deploy`) lists the requests that publish the
workflow in :attr:`next_steps`.
"""

@property
def next_steps(self) -> List[Dict[str, Any]]:
"""``[{action, method, path, description}]`` that move the workflow towards publishing."""
steps = self.details.get("nextSteps")
return steps if isinstance(steps, list) else []

@property
def gate_types(self) -> List[str]:
"""The gate types behind the refusal, e.g. ``["workflow_eval", "manual_approval"]``."""
gate_types = self.details.get("gateTypes")
if isinstance(gate_types, list):
return gate_types
gate_type = self.details.get("gateType")
return [gate_type] if isinstance(gate_type, str) else []


def deploy_gate_refusal(response: requests.Response) -> Optional[WorkflowDeployGateError]:
"""Return the deploy gate refusal a non-2xx legacy response carries, else ``None``."""
error = error_from_response(response, WorkflowDeployGateError)
if isinstance(error, WorkflowDeployGateError) and error.code in DEPLOY_GATE_ERROR_CODES:
return error
return None


def base_url(workspace_url: str) -> str:
return f"{API_URL}/workspaces/{quote(workspace_url, safe='')}/workflows"


def request(
api_key: str,
workspace_url: str,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
body: Any = None,
idempotency_key: Optional[str] = None,
timeout: float = DEFAULT_TIMEOUT,
) -> Any:
"""Call one deploy gates route and return the decoded JSON body.

Raises :class:`WorkflowDeployGateError` for any non-2xx response.
"""
response = http.request(
method.upper(),
f"{base_url(workspace_url)}{path}",
params=_clean_params(params),
json=body,
headers=_headers(api_key, idempotency_key=idempotency_key),
timeout=timeout,
)
if not 200 <= response.status_code < 300:
raise error_from_response(response, WorkflowDeployGateError)
if response.status_code == 204 or not response.content:
return None
try:
return response.json()
except ValueError:
raise WorkflowDeployGateError(
f"Expected JSON from {method.upper()} {path}", status_code=response.status_code
) from None
14 changes: 8 additions & 6 deletions roboflow/adapters/workflowevalsapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import json
import uuid
from typing import TYPE_CHECKING, Any, Dict, Iterator, Optional
from typing import TYPE_CHECKING, Any, Dict, Iterator, Optional, Type
from urllib.parse import quote

from roboflow.adapters.rfapi import RoboflowError
Expand Down Expand Up @@ -78,16 +78,18 @@ def to_dict(self) -> Dict[str, Any]:
return payload


def error_from_response(response: requests.Response) -> WorkflowEvalError:
"""Translate a non-2xx response into a :class:`WorkflowEvalError`."""
def error_from_response(
response: requests.Response, error_class: Type[WorkflowEvalError] = WorkflowEvalError
) -> WorkflowEvalError:
"""Translate a non-2xx response into a :class:`WorkflowEvalError` (or ``error_class``)."""
body: Any = None
try:
body = response.json()
except ValueError:
pass
error = body.get("error") if isinstance(body, dict) else None
if isinstance(error, dict):
return WorkflowEvalError(
return error_class(
str(error.get("message") or error.get("code") or response.reason or "Request failed"),
status_code=response.status_code,
code=error.get("code"),
Expand All @@ -101,9 +103,9 @@ def error_from_response(response: requests.Response) -> WorkflowEvalError:
)
if isinstance(error, str):
message = body.get("message") or error
return WorkflowEvalError(str(message), status_code=response.status_code, code=error, body=body)
return error_class(str(message), status_code=response.status_code, code=error, body=body)
text = (response.text or "").strip() or response.reason or "Request failed"
return WorkflowEvalError(text, status_code=response.status_code, body=body)
return error_class(text, status_code=response.status_code, body=body)


def new_idempotency_key() -> str:
Expand Down
Loading
Loading