Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

All notable changes to this project will be documented in this file.

## 1.3.11

### Added

- `roboflow api-key` CLI command group and SDK methods to create, list, get,
update, protect, and revoke workspace API keys — including scoped keys, folder
restrictions, and custom metadata (scoping/metadata require the Advanced API
Keys plan feature).

## 1.3.10

### Added
Expand Down
1 change: 1 addition & 0 deletions CLI-COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ Version numbers are always numeric — that's how `x/y` is disambiguated between
| Command | Description |
|---------|-------------|
| `auth` | Login, logout, status, set default workspace |
| `api-key` | List, create, update, protect, disable, revoke workspace API keys |
| `workspace` | List and inspect workspaces |
| `project` | List, get, create projects |
| `version` | List, get, download, export dataset versions |
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ idna==3.7
cycler
kiwisolver>=1.3.1
matplotlib
numpy>=1.18.5
numpy>=1.18.5,<2.5 # 2.5.0 ships type stubs using 3.12+ `type` syntax that mypy (pinned to 3.10) rejects
opencv-python-headless==4.10.0.84
Pillow>=7.1.2
# https://github.com/roboflow/roboflow-python/issues/390
Expand Down
2 changes: 1 addition & 1 deletion roboflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
CLIPModel = None # type: ignore[assignment,misc]
GazeModel = None # type: ignore[assignment,misc]

__version__ = "1.3.10"
__version__ = "1.3.11"


def check_key(api_key, model, notebook, num_retries=0):
Expand Down
150 changes: 150 additions & 0 deletions roboflow/adapters/rfapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -1450,3 +1450,153 @@ def get_model_eval_image_predictions(
def get_model_eval_recommendations(api_key: str, workspace_url: str, eval_id: str) -> dict:
"""GET /{workspace}/model-evals/{evalId}/recommendations — improvement suggestions."""
return _eval_get(api_key, workspace_url, f"/{eval_id}/recommendations")


# ---------------------------------------------------------------------------
# API key management endpoints
# ---------------------------------------------------------------------------


class _FullAccess:
"""Sentinel distinguishing "unscoped/full access" from "omit scopes".

The API treats three ``scopes`` states differently: omitted inherits the
caller's own scopes, an explicit ``null`` grants full (unscoped) access, and
an empty ``[]`` grants no abilities. Passing ``None`` from Python means
"omit", so ``FULL_ACCESS`` is used to force an explicit ``"scopes": null``
into the request body.
"""

_instance = None

def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

def __repr__(self) -> str:
return "FULL_ACCESS"


FULL_ACCESS = _FullAccess()


def list_api_keys(
api_key: str,
workspace_url: str,
include_disabled: bool = False,
include_folders: bool = False,
) -> dict:
"""GET /{workspace}/api-keys — list API keys for a workspace."""
params: Dict[str, Union[str, bool]] = {"api_key": api_key}
if include_disabled:
params["includeDisabled"] = "true"
if include_folders:
params["includeFolders"] = "true"
response = requests.get(f"{API_URL}/{workspace_url}/api-keys", params=params)
if not response.ok:
raise RoboflowError(response.text, status_code=response.status_code)
return response.json()


def get_api_key(api_key: str, workspace_url: str, key_id: str) -> dict:
"""GET /{workspace}/api-keys/{keyId} — get a single API key by ID."""
encoded = quote(key_id, safe="")
response = requests.get(f"{API_URL}/{workspace_url}/api-keys/{encoded}", params={"api_key": api_key})
if not response.ok:
raise RoboflowError(response.text, status_code=response.status_code)
return response.json()


def get_publishable_key(api_key: str, workspace_url: str) -> dict:
"""GET /{workspace}/api-keys/publishable — get the workspace publishable key."""
response = requests.get(f"{API_URL}/{workspace_url}/api-keys/publishable", params={"api_key": api_key})
if not response.ok:
raise RoboflowError(response.text, status_code=response.status_code)
return response.json()


def create_api_key(
api_key: str,
workspace_url: str,
name: Optional[str] = None,
scopes: Union[List[str], _FullAccess, None] = None,
folder_ids: Optional[List[str]] = None,
custom_metadata: Optional[Dict] = None,
protected: bool = False,
) -> dict:
"""POST /{workspace}/api-keys — create a new API key.

The secret ``key`` value is returned only on creation (shown once).
Omitting ``scopes`` (or passing ``None``) inherits the calling credential's
own scopes, so a full-access credential creates a full-access key. Pass a list
to scope the key (``role:<name>`` presets are accepted), ``[]`` for a key with
no abilities, or ``FULL_ACCESS`` to send an explicit ``null`` (unscoped/full
access). ``scopes``, ``folder_ids``, and ``custom_metadata`` require the
Advanced API Keys plan feature — the backend returns 403 if unavailable.
"""
body: Dict[str, Any] = {}
if name is not None:
body["name"] = name
if scopes is FULL_ACCESS:
body["scopes"] = None
elif scopes is not None:
body["scopes"] = scopes
if folder_ids is not None:
body["folderIds"] = folder_ids
if custom_metadata is not None:
# Canonical wire field is camelCase `customMetadata` (consistent with `folderIds`).
body["customMetadata"] = custom_metadata
if protected:
body["protected"] = True
response = requests.post(f"{API_URL}/{workspace_url}/api-keys", params={"api_key": api_key}, json=body)
if not response.ok:
raise RoboflowError(response.text, status_code=response.status_code)
return response.json()


def update_api_key(api_key: str, workspace_url: str, key_id: str, **fields: Any) -> dict:
"""PATCH /{workspace}/api-keys/{keyId} — update an existing API key.

Pass only the fields you want to change as keyword arguments:
``name``, ``scopes``, ``custom_metadata``, ``protected``, ``disabled``.
``None`` values are omitted (left unchanged). To send explicit values,
pass ``scopes=[]`` (no abilities), ``scopes=FULL_ACCESS`` (unscoped/full
access, serialized as ``null``), or ``custom_metadata={}`` (clear metadata).
The API cannot unprotect a key (``protected=False`` → 403).
Disabling a protected key returns 409.
"""
encoded = quote(key_id, safe="")
# Canonical wire field is camelCase `customMetadata` (consistent with `folderIds`); callers may
# pass the Pythonic `custom_metadata` kwarg, which is normalized here.
wire_aliases = {"custom_metadata": "customMetadata"}
body: Dict[str, Any] = {}
for k, v in fields.items():
wire_key = wire_aliases.get(k, k)
if v is FULL_ACCESS:
body[wire_key] = None
elif v is not None:
body[wire_key] = v
response = requests.patch(
f"{API_URL}/{workspace_url}/api-keys/{encoded}",
params={"api_key": api_key},
json=body,
)
if not response.ok:
raise RoboflowError(response.text, status_code=response.status_code)
return response.json()


def revoke_api_key(api_key: str, workspace_url: str, key_id: str) -> dict:
"""DELETE /{workspace}/api-keys/{keyId} — revoke (permanently delete) an API key.

Revoking a protected key returns 409. This action is irreversible.
"""
encoded = quote(key_id, safe="")
response = requests.delete(
f"{API_URL}/{workspace_url}/api-keys/{encoded}",
params={"api_key": api_key},
)
if not response.ok:
raise RoboflowError(response.text, status_code=response.status_code)
return response.json()
2 changes: 2 additions & 0 deletions roboflow/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ def _walk(group: Any, prefix: str = "") -> None:
# ---------------------------------------------------------------------------

from roboflow.cli.handlers.annotation import annotation_app # noqa: E402
from roboflow.cli.handlers.api_key import api_key_app # noqa: E402
from roboflow.cli.handlers.asynctasks import asynctasks_app # noqa: E402
from roboflow.cli.handlers.auth import auth_app # noqa: E402
from roboflow.cli.handlers.batch import batch_app # noqa: E402
Expand All @@ -209,6 +210,7 @@ def _walk(group: Any, prefix: str = "") -> None:

# Register ALL commands in alphabetical order for clean --help output
app.add_typer(annotation_app, name="annotation")
app.add_typer(api_key_app, name="api-key")
app.add_typer(asynctasks_app, name="asynctasks")
app.add_typer(auth_app, name="auth")
app.add_typer(batch_app, name="batch", hidden=True) # All stubs — hidden until implemented
Expand Down
Loading
Loading