From a80584532255676d5bf8701dd2f104c55f2699c3 Mon Sep 17 00:00:00 2001 From: Patrick Magee Date: Fri, 13 Mar 2026 14:22:29 -0400 Subject: [PATCH 1/8] [CU-86b8vxxhv] Add design spec for namespace create and update CLI commands Co-Authored-By: Claude Opus 4.6 --- ...26-03-13-namespace-create-update-design.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-13-namespace-create-update-design.md diff --git a/docs/superpowers/specs/2026-03-13-namespace-create-update-design.md b/docs/superpowers/specs/2026-03-13-namespace-create-update-design.md new file mode 100644 index 00000000..f53435d4 --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-namespace-create-update-design.md @@ -0,0 +1,199 @@ +--- +title: Namespace Create & Update CLI Commands +date: 2026-03-13 +repo: dnastack-client +services: + - dnastack-client + - workbench-user-service +tags: + - cli + - namespaces + - workbench +status: approved +decisions: + - Use PATCH API (not PUT) for update to avoid silent overwrites + - Auto-fetch ETag transparently — no --version flag exposed + - Omit initial_users from create — use members add separately + - Positional ID for update, consistent with defaults update pattern + - Flag-based PATCH — build JSON Patch ops from --name/--description flags + - Command names: create and update (matching existing CLI conventions) +--- + +# Namespace Create & Update CLI Commands + +**ClickUp:** [CU-86b8vxxhv](https://app.clickup.com/t/86b8vxxhv) + +## Goal + +Add `namespaces create` and `namespaces update` commands to the dnastack-client CLI, backed by the workbench-user-service `POST /namespaces` and `PATCH /namespaces/{id}` endpoints. + +## Prior Decisions + +This design aligns with: +- [2026-02-20 Namespace CLI Commands Design](../../plans/2026-02-20-namespace-cli-commands-design.md) — extend `WorkbenchUserClient`, namespace endpoints are user-scoped +- [2026-02-26 Namespace Members Add/Remove Design](../../plans/2026-02-26-namespace-members-add-remove-design.md) — CLI validation patterns, error handling + +## CLI Commands + +### `namespaces create` + +``` +omics workbench namespaces create --name NAME [--description TEXT] +``` + +| Flag | Required | Description | +|------|----------|-------------| +| `--name` | Yes | Namespace name | +| `--description` | No | Namespace description. API defaults to the name if omitted. | +| `--context` | No | Select context | +| `--endpoint-id` | No | Select service endpoint | + +- Calls `POST /namespaces` with `{"name": "...", "description": "..."}` +- Outputs created Namespace as JSON via `to_json(normalize(...))` +- Docstring includes `docs: https://docs.omics.ai/products/command-line-interface/reference/workbench/namespaces-create` + +### `namespaces update` + +``` +omics workbench namespaces update ID [--name NAME] [--description TEXT] +``` + +| Arg/Flag | Type | Required | Description | +|----------|------|----------|-------------| +| `ID` | positional | Yes | Namespace ID to update | +| `--name` | option | No | New name | +| `--description` | option | No | New description | +| `--context` | option | No | Select context | +| `--endpoint-id` | option | No | Select service endpoint | + +- At least one of `--name`/`--description` must be provided +- Flow: GET namespace (extract ETag) -> build JSON Patch ops -> `PATCH /namespaces/{id}` with `If-Match` and `Content-Type: application/json-patch+json` +- Outputs updated Namespace as JSON +- Docstring includes `docs: https://docs.omics.ai/products/command-line-interface/reference/workbench/namespaces-update` + +## Design Decisions + +### [CONFIRMED DECISION] Use PATCH API, not PUT [/CONFIRMED DECISION] + +PUT requires sending all fields, which means auto-filling unchanged fields from a GET. If another user modifies a field between the GET and PUT, the stale value silently overwrites their change. PATCH only touches what the user specified, avoiding this class of bug. + +### [CONFIRMED DECISION] Auto-fetch ETag transparently [/CONFIRMED DECISION] + +The `update` command fetches the namespace before patching to get the current ETag for the `If-Match` header. No `--version` flag is exposed. This is consistent with how other CLI commands work — they don't expose API internals like version tokens. + +### [CONFIRMED DECISION] Omit initial_users from create [/CONFIRMED DECISION] + +The `POST /namespaces` endpoint supports an `initial_users` field to seed members on creation. We omit this from the CLI — users can call `members add` after creating. This keeps the command simple and avoids a compound flag format like `--initial-user email:ROLE`. + +### [CONFIRMED DECISION] Positional ID for update [/CONFIRMED DECISION] + +The existing `defaults update` command uses a positional ID (`default_id`). We follow that pattern for `namespaces update`. + +### [CONFIRMED DECISION] Command names: create and update [/CONFIRMED DECISION] + +The CLI already uses `create` (workflows, versions, defaults, dependencies, publisher collections) and `update` (defaults, dependencies) as command names. `edit` was considered but typically implies opening an editor (like `kubectl edit`). `update` maps naturally to the PATCH verb and matches existing conventions. + +### [AI DECISION] Flag-based JSON Patch construction [/AI DECISION] + +The client builds JSON Patch operations from non-None flags rather than accepting raw JSON Patch input. Users shouldn't need to know RFC 6902 syntax. Only two fields are mutable (`name`, `description`), so this is a complete mapping. Reuses the existing `JsonPatch` model from `dnastack/http/session.py` for consistency with `CollectionServiceClient.patch()`. + +## Client Layer + +### New methods on `WorkbenchUserClient` + +Both new commands are registered inside `init_namespace_commands(group)` in `commands.py`, following the pattern of `list`, `describe`, `get-active`, and `set-active`. + +**`create_namespace(name: str, description: Optional[str] = None) -> Namespace`** +- `POST /namespaces` with `Content-Type: application/json` +- Body: `NamespaceCreateRequest` serialized via `.dict(exclude_none=True)` (pydantic v1) +- Returns `Namespace(**response.json())` + +**`update_namespace(namespace_id: str, name: Optional[str] = None, description: Optional[str] = None) -> Namespace`** +- Uses a single `self.create_http_session()` context manager for both requests (avoids re-authentication) +- Step 1: `GET /namespaces/{id}` — extract ETag from `response.headers['ETag']` +- Step 2: Build JSON Patch ops using the existing `JsonPatch` model from `dnastack/http/session.py`: + ```python + patches = [] + if name is not None: + patches.append(JsonPatch(op='replace', path='/name', value=name).dict()) + if description is not None: + patches.append(JsonPatch(op='replace', path='/description', value=description).dict()) + ``` +- Step 3: `PATCH /namespaces/{id}` with headers `If-Match: ""` and `Content-Type: application/json-patch+json`, body is the patches list +- Returns `Namespace(**response.json())` +- The client lets `ClientError` propagate on 409 — the CLI layer catches and translates it + +### New model + +```python +class NamespaceCreateRequest(BaseModel): + name: str + description: Optional[str] = None +``` + +No new model needed for the PATCH payload — reuses `JsonPatch` from `dnastack/http/session.py`. + +### Files modified + +- `dnastack/client/workbench/workbench_user_service/models.py` — add `NamespaceCreateRequest` +- `dnastack/client/workbench/workbench_user_service/client.py` — add `create_namespace`, `update_namespace` methods +- `dnastack/cli/commands/workbench/namespaces/commands.py` — add `create` and `update` commands in `init_namespace_commands` +- `tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py` — add test classes +- `tests/unit/client/workbench/workbench_user_service/test_namespace_models.py` — add model tests + +## Error Handling + +### Create +- 400 (name blank) — surfaces as `HttpError` with API message +- 401/403 — existing authenticator system handles these + +### Update +- 404 (namespace not found) — raised during auto-fetch GET as `ClientError` +- 400 (name blank, name > 256 chars, invalid patch) — surfaces as `HttpError` +- 409 (version conflict) — `ClientError` propagates from the client layer; the CLI command catches it, checks `response.status_code == 409`, and raises `click.ClickException("Namespace was modified by another user. Please retry.")` +- 401/403 — existing authenticator system + +### CLI validation +- `update` with neither `--name` nor `--description` -> `click.UsageError("Specify at least one of --name or --description.")` + +## Tests + +### Unit tests — Client models +- `NamespaceCreateRequest` serialization with and without description + +### Unit tests — CLI commands +**`TestCreateCommand`:** +- Success with name only +- Success with name and description +- Output is valid JSON +- Missing `--name` -> non-zero exit code + +**`TestUpdateCommand`:** +- Success with `--name` only — patch contains only name op +- Success with `--description` only — patch contains only description op +- Success with both flags +- Neither flag -> `UsageError` +- 409 Conflict -> user-friendly error message + +### E2E tests +- Create namespace, verify in list +- Update name, verify via describe +- Update description only, verify name unchanged +- Update with invalid ID -> 404 `ClientError` from the GET step + +## Product Documentation + +Two new pages in `docs/cli/reference/workbench/`: + +### `namespaces-create.md` +- Synopsis: `omics workbench namespaces create --name NAME [--description TEXT]` +- Note that caller is auto-added as ADMIN +- Examples: name only, name + description + +### `namespaces-update.md` +- Synopsis: `omics workbench namespaces update ID [--name NAME] [--description TEXT]` +- Note that at least one flag is required +- Examples: update name, update description, update both + +### SUMMARY.md +- Add entries for both new pages in the namespace section From 777bc225174d4f403a7d40b0d5607be57173e3b7 Mon Sep 17 00:00:00 2001 From: Patrick Magee Date: Fri, 13 Mar 2026 14:22:46 -0400 Subject: [PATCH 2/8] [CU-86b8vxxhv] Add pyenv activation requirement to CLAUDE.md Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index aed50d87..073466fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ This is the DNAstack client library and CLI, a Python package that provides both ## Development Commands ### Development Setup +- **IMPORTANT**: Run `eval "$(pyenv init -)" && pyenv activate dnastack-client` before any Python, make, uv, or git commit commands. This activates the correct pyenv virtualenv that has `uv` and other tools available. - `make setup` - Set up development environment with uv (creates .venv and installs dependencies) ### Running the CLI From 5265c2a3c556f5a1f44c29318598802c495a1cf1 Mon Sep 17 00:00:00 2001 From: Patrick Magee Date: Fri, 13 Mar 2026 14:28:09 -0400 Subject: [PATCH 3/8] [CU-86b8vxxhv] Add implementation plan for namespace create and update commands Co-Authored-By: Claude Opus 4.6 --- .../2026-03-13-namespace-create-update.md | 741 ++++++++++++++++++ 1 file changed, 741 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-13-namespace-create-update.md diff --git a/docs/superpowers/plans/2026-03-13-namespace-create-update.md b/docs/superpowers/plans/2026-03-13-namespace-create-update.md new file mode 100644 index 00000000..a18463dd --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-namespace-create-update.md @@ -0,0 +1,741 @@ +# Namespace Create & Update CLI Commands Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `namespaces create` and `namespaces update` commands to the dnastack-client CLI. + +**Architecture:** Two new client methods on `WorkbenchUserClient` backed by `POST /namespaces` and `PATCH /namespaces/{id}`. Two new CLI commands registered in `init_namespace_commands`. Uses existing `JsonPatch` model and `session.json_patch()` for the update flow. Product docs for both commands. + +**Tech Stack:** Python, Click, Pydantic v2, requests + +**Spec:** `docs/superpowers/specs/2026-03-13-namespace-create-update-design.md` + +**ClickUp:** [CU-86b8vxxhv](https://app.clickup.com/t/86b8vxxhv) + +**Pyenv:** Run `eval "$(pyenv init -)" && pyenv activate dnastack-client` before any Python/make/commit command. + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Modify | `dnastack/client/workbench/workbench_user_service/models.py` | Add `NamespaceCreateRequest` model | +| Modify | `dnastack/client/workbench/workbench_user_service/client.py` | Add `create_namespace`, `update_namespace` methods | +| Modify | `dnastack/cli/commands/workbench/namespaces/commands.py` | Add `create` and `update` CLI commands | +| Modify | `tests/unit/client/workbench/workbench_user_service/test_namespace_models.py` | Add model serialization tests | +| Modify | `tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py` | Add CLI command tests | +| Create | `@dnastack-product-docs/docs/cli/reference/workbench/namespaces-create.md` | Create command reference doc | +| Create | `@dnastack-product-docs/docs/cli/reference/workbench/namespaces-update.md` | Update command reference doc | +| Modify | `@dnastack-product-docs/docs/SUMMARY.md` | Add sidebar entries for new docs | + +`@dnastack-product-docs` = `/Users/patrickmagee/development/dnastack/dnastack-product-docs` + +--- + +## Chunk 1: Model and Client Layer + +### Task 1: Add NamespaceCreateRequest model + +**Files:** +- Modify: `dnastack/client/workbench/workbench_user_service/models.py` +- Modify: `tests/unit/client/workbench/workbench_user_service/test_namespace_models.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/client/workbench/workbench_user_service/test_namespace_models.py`: + +```python +from dnastack.client.workbench.workbench_user_service.models import ( + # ... existing imports ... + NamespaceCreateRequest, +) + + +class TestNamespaceCreateRequestSerialization: + """Test suite for NamespaceCreateRequest model.""" + + def test_serializes_with_name_and_description(self): + req = NamespaceCreateRequest(name="My Namespace", description="A test namespace") + data = req.dict(exclude_none=True) + assert data == {"name": "My Namespace", "description": "A test namespace"} + + def test_serializes_with_name_only(self): + req = NamespaceCreateRequest(name="My Namespace") + data = req.dict(exclude_none=True) + assert data == {"name": "My Namespace"} + assert "description" not in data + + def test_serializes_with_explicit_none_description(self): + req = NamespaceCreateRequest(name="My Namespace", description=None) + data = req.dict(exclude_none=True) + assert data == {"name": "My Namespace"} + assert "description" not in data +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/client/workbench/workbench_user_service/test_namespace_models.py::TestNamespaceCreateRequestSerialization -v` + +Expected: FAIL with `ImportError: cannot import name 'NamespaceCreateRequest'` + +- [ ] **Step 3: Add the NamespaceCreateRequest model** + +Add to `dnastack/client/workbench/workbench_user_service/models.py`, after the `AddMemberRequest` class: + +```python +class NamespaceCreateRequest(BaseModel): + name: str + description: Optional[str] = None +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/client/workbench/workbench_user_service/test_namespace_models.py::TestNamespaceCreateRequestSerialization -v` + +Expected: 3 PASSED + +- [ ] **Step 5: Run full model test suite to verify no regressions** + +Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/client/workbench/workbench_user_service/test_namespace_models.py -v` + +Expected: All existing tests still PASS + +- [ ] **Step 6: Commit** + +```bash +eval "$(pyenv init -)" && pyenv activate dnastack-client +git add dnastack/client/workbench/workbench_user_service/models.py tests/unit/client/workbench/workbench_user_service/test_namespace_models.py +git commit -m "[CU-86b8vxxhv] Add NamespaceCreateRequest model with tests" +``` + +--- + +### Task 2: Add create_namespace client method + +**Files:** +- Modify: `dnastack/client/workbench/workbench_user_service/client.py` +- Modify: `tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py`. First update the imports at the top of the file: + +```python +import json +from unittest.mock import Mock, patch +from click import Group +from click.testing import CliRunner + +from dnastack.cli.commands.workbench.namespaces.commands import init_namespace_commands +from dnastack.client.workbench.workbench_user_service.models import Namespace +``` + +Then add the test class: + +```python +class TestCreateCommand: + """Tests for the create namespace CLI command.""" + + def setup_method(self): + self.runner = CliRunner() + self.group = Group() + init_namespace_commands(self.group) + + self.mock_namespace = Namespace( + id="ns-new-123", + name="New Namespace", + description="A brand new namespace", + created_at="2026-03-13T00:00:00Z", + created_by="user@example.com", + updated_at="2026-03-13T00:00:00Z", + updated_by="user@example.com", + ) + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_create_with_name_only(self, mock_get_client): + mock_client = Mock() + mock_client.create_namespace.return_value = self.mock_namespace + mock_get_client.return_value = mock_client + + result = self.runner.invoke(self.group, ['create', '--name', 'New Namespace']) + + assert result.exit_code == 0 + mock_client.create_namespace.assert_called_once_with(name="New Namespace", description=None) + output = json.loads(result.output) + assert output["id"] == "ns-new-123" + assert output["name"] == "New Namespace" + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_create_with_name_and_description(self, mock_get_client): + mock_client = Mock() + mock_client.create_namespace.return_value = self.mock_namespace + mock_get_client.return_value = mock_client + + result = self.runner.invoke(self.group, ['create', '--name', 'New Namespace', '--description', 'A brand new namespace']) + + assert result.exit_code == 0 + mock_client.create_namespace.assert_called_once_with(name="New Namespace", description="A brand new namespace") + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_create_requires_name(self, mock_get_client): + result = self.runner.invoke(self.group, ['create']) + + assert result.exit_code != 0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py::TestCreateCommand -v` + +Expected: FAIL (no 'create' command registered) + +- [ ] **Step 3: Add create_namespace client method** + +Add to `dnastack/client/workbench/workbench_user_service/client.py`. + +First update the imports at the top: + +```python +from dnastack.client.workbench.workbench_user_service.models import ( + WorkbenchUser, + Namespace, + NamespaceListResponse, + NamespaceMember, + NamespaceMemberListResponse, + AddMemberRequest, + NamespaceCreateRequest, +) +``` + +Then add the method to `WorkbenchUserClient`, after the `set_active_namespace` method: + +```python + def create_namespace(self, name: str, description: Optional[str] = None) -> Namespace: + """Create a new namespace.""" + body = NamespaceCreateRequest(name=name, description=description) + with self.create_http_session() as session: + response = session.post( + urljoin(self.endpoint.url, 'namespaces'), + json=body.dict(exclude_none=True), + ) + return Namespace(**response.json()) +``` + +- [ ] **Step 4: Add the create CLI command** + +Add to `dnastack/cli/commands/workbench/namespaces/commands.py`, inside `init_namespace_commands`, after the `set_active_namespace` command: + +```python + @formatted_command( + group=group, + name='create', + specs=[ + ArgumentSpec( + name='name', + arg_names=['--name'], + help='The name of the namespace to create.', + required=True, + ), + ArgumentSpec( + name='description', + arg_names=['--description'], + help='A description for the namespace. Defaults to the namespace name if omitted.', + required=False, + ), + CONTEXT_ARG, + SINGLE_ENDPOINT_ID_ARG, + ] + ) + def create_namespace(context: Optional[str], + endpoint_id: Optional[str], + name: str, + description: Optional[str]): + """ + Create a new namespace + + docs: https://docs.omics.ai/products/command-line-interface/reference/workbench/namespaces-create + """ + + client = get_user_client(context, endpoint_id) + namespace = client.create_namespace(name=name, description=description) + click.echo(to_json(normalize(namespace))) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py::TestCreateCommand -v` + +Expected: 3 PASSED + +- [ ] **Step 6: Run full CLI test suite to verify no regressions** + +Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/cli/commands/workbench/namespaces/ -v` + +Expected: All existing tests still PASS + +- [ ] **Step 7: Commit** + +```bash +eval "$(pyenv init -)" && pyenv activate dnastack-client +git add dnastack/client/workbench/workbench_user_service/client.py dnastack/cli/commands/workbench/namespaces/commands.py tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py +git commit -m "[CU-86b8vxxhv] Add namespaces create command and client method" +``` + +--- + +### Task 3: Add update_namespace client method and CLI command + +**Files:** +- Modify: `dnastack/client/workbench/workbench_user_service/client.py` +- Modify: `dnastack/cli/commands/workbench/namespaces/commands.py` +- Modify: `tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py`: + +```python +from dnastack.http.session import ClientError + + +class TestUpdateCommand: + """Tests for the update namespace CLI command.""" + + def setup_method(self): + self.runner = CliRunner() + self.group = Group() + init_namespace_commands(self.group) + + self.mock_namespace = Namespace( + id="ns-existing-456", + name="Updated Namespace", + description="Updated description", + created_at="2026-03-13T00:00:00Z", + created_by="user@example.com", + updated_at="2026-03-13T12:00:00Z", + updated_by="user@example.com", + ) + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_update_name_only(self, mock_get_client): + mock_client = Mock() + mock_client.update_namespace.return_value = self.mock_namespace + mock_get_client.return_value = mock_client + + result = self.runner.invoke(self.group, ['update', 'ns-existing-456', '--name', 'Updated Namespace']) + + assert result.exit_code == 0 + mock_client.update_namespace.assert_called_once_with( + namespace_id="ns-existing-456", name="Updated Namespace", description=None + ) + output = json.loads(result.output) + assert output["id"] == "ns-existing-456" + assert output["name"] == "Updated Namespace" + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_update_description_only(self, mock_get_client): + mock_client = Mock() + mock_client.update_namespace.return_value = self.mock_namespace + mock_get_client.return_value = mock_client + + result = self.runner.invoke(self.group, ['update', 'ns-existing-456', '--description', 'Updated description']) + + assert result.exit_code == 0 + mock_client.update_namespace.assert_called_once_with( + namespace_id="ns-existing-456", name=None, description="Updated description" + ) + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_update_both_name_and_description(self, mock_get_client): + mock_client = Mock() + mock_client.update_namespace.return_value = self.mock_namespace + mock_get_client.return_value = mock_client + + result = self.runner.invoke(self.group, ['update', 'ns-existing-456', '--name', 'Updated Namespace', '--description', 'Updated description']) + + assert result.exit_code == 0 + mock_client.update_namespace.assert_called_once_with( + namespace_id="ns-existing-456", name="Updated Namespace", description="Updated description" + ) + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_update_requires_at_least_one_flag(self, mock_get_client): + result = self.runner.invoke(self.group, ['update', 'ns-existing-456']) + + assert result.exit_code != 0 + assert "at least one" in result.output.lower() + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_update_requires_namespace_id(self, mock_get_client): + result = self.runner.invoke(self.group, ['update', '--name', 'Test']) + + assert result.exit_code != 0 + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_update_handles_409_conflict(self, mock_get_client): + mock_client = Mock() + mock_response = Mock() + mock_response.status_code = 409 + mock_response.text = "Conflict" + mock_client.update_namespace.side_effect = ClientError(mock_response) + mock_get_client.return_value = mock_client + + result = self.runner.invoke(self.group, ['update', 'ns-existing-456', '--name', 'Conflict Test']) + + assert result.exit_code != 0 + assert "modified by another user" in result.output.lower() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py::TestUpdateCommand -v` + +Expected: FAIL (no 'update' command registered) + +- [ ] **Step 3: Add update_namespace client method** + +Add to `dnastack/client/workbench/workbench_user_service/client.py`. First update the import at the top: + +```python +from dnastack.http.session import HttpSession, JsonPatch +``` + +Then add the method to `WorkbenchUserClient`, after the `create_namespace` method: + +```python + def update_namespace(self, + namespace_id: str, + name: Optional[str] = None, + description: Optional[str] = None) -> Namespace: + """Update a namespace using JSON Patch. Auto-fetches ETag for optimistic locking.""" + patches = [] + if name is not None: + patches.append(JsonPatch(op='replace', path='/name', value=name).dict()) + if description is not None: + patches.append(JsonPatch(op='replace', path='/description', value=description).dict()) + + with self.create_http_session() as session: + get_response = session.get( + urljoin(self.endpoint.url, f'namespaces/{namespace_id}') + ) + etag = (get_response.headers.get('etag') or '').strip('"') + + patch_response = session.json_patch( + urljoin(self.endpoint.url, f'namespaces/{namespace_id}'), + headers={'If-Match': etag}, + json=patches, + ) + return Namespace(**patch_response.json()) +``` + +- [ ] **Step 4: Add the update CLI command** + +Add to `dnastack/cli/commands/workbench/namespaces/commands.py`. First add the import at the top: + +```python +from dnastack.http.session import ClientError +``` + +Then add inside `init_namespace_commands`, after the `create_namespace` command: + +```python + @formatted_command( + group=group, + name='update', + specs=[ + ArgumentSpec( + name='namespace_id', + arg_type=ArgumentType.POSITIONAL, + help='The namespace ID to update.', + required=True, + ), + ArgumentSpec( + name='name', + arg_names=['--name'], + help='The new name for the namespace.', + required=False, + ), + ArgumentSpec( + name='description', + arg_names=['--description'], + help='The new description for the namespace.', + required=False, + ), + CONTEXT_ARG, + SINGLE_ENDPOINT_ID_ARG, + ] + ) + def update_namespace(context: Optional[str], + endpoint_id: Optional[str], + namespace_id: str, + name: Optional[str], + description: Optional[str]): + """ + Update an existing namespace + + docs: https://docs.omics.ai/products/command-line-interface/reference/workbench/namespaces-update + """ + + if name is None and description is None: + raise click.UsageError("Specify at least one of --name or --description.") + + client = get_user_client(context, endpoint_id) + try: + namespace = client.update_namespace(namespace_id=namespace_id, name=name, description=description) + except ClientError as e: + if e.response.status_code == 409: + raise click.ClickException("Namespace was modified by another user. Please retry.") + raise + click.echo(to_json(normalize(namespace))) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py::TestUpdateCommand -v` + +Expected: 6 PASSED + +- [ ] **Step 6: Run full test suite to verify no regressions** + +Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/cli/commands/workbench/namespaces/ tests/unit/client/workbench/workbench_user_service/ -v` + +Expected: All tests PASS + +- [ ] **Step 7: Lint** + +Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && make lint` + +Expected: No violations + +- [ ] **Step 8: Commit** + +```bash +eval "$(pyenv init -)" && pyenv activate dnastack-client +git add dnastack/client/workbench/workbench_user_service/client.py dnastack/cli/commands/workbench/namespaces/commands.py tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py +git commit -m "[CU-86b8vxxhv] Add namespaces update command with JSON Patch and conflict handling" +``` + +--- + +## Chunk 2: Product Documentation + +### Task 4: Add namespaces-create.md product doc + +**Files:** +- Create: `/Users/patrickmagee/development/dnastack/dnastack-product-docs/docs/cli/reference/workbench/namespaces-create.md` + +- [ ] **Step 1: Create the doc file** + +```markdown +--- +description: Create a new namespace +--- + +# namespaces create + +## Synopsis + +```shell +omics workbench namespaces create + --name NAME + [--description TEXT] +``` + +## Description + +Create a new namespace. The authenticated user is automatically added as an ADMIN of the new namespace. + +If no description is provided, the API defaults the description to the namespace name. + +## Examples + +### Create a namespace + +```shell +omics workbench namespaces create --name "My Research Lab" +``` + +### Create a namespace with a description + +```shell +omics workbench namespaces create \ + --name "My Research Lab" \ + --description "Shared workspace for genomics research" +``` + +## Flags: + +### `--name`=`NAME` + +**Required.** The name of the namespace to create. + +### `--description`=`TEXT` + +A description for the namespace. If omitted, defaults to the namespace name. +``` + +- [ ] **Step 2: Verify file exists and is well-formed** + +Run: `head -5 /Users/patrickmagee/development/dnastack/dnastack-product-docs/docs/cli/reference/workbench/namespaces-create.md` + +Expected: YAML frontmatter with `description: Create a new namespace` + +--- + +### Task 5: Add namespaces-update.md product doc + +**Files:** +- Create: `/Users/patrickmagee/development/dnastack/dnastack-product-docs/docs/cli/reference/workbench/namespaces-update.md` + +- [ ] **Step 1: Create the doc file** + +```markdown +--- +description: Update an existing namespace +--- + +# namespaces update + +## Synopsis + +```shell +omics workbench namespaces update ID + [--name NAME] + [--description TEXT] +``` + +## Description + +Update the name or description of an existing namespace. At least one of `--name` or `--description` must be provided. + +The command uses optimistic locking to prevent conflicting updates. If the namespace was modified by another user between when it was read and when the update is applied, the command returns an error and the update must be retried. + +## Examples + +### Update a namespace name + +```shell +omics workbench namespaces update bcd869ca-8a06-4426-a94d-43f9d91e937d \ + --name "Renamed Lab" +``` + +### Update a namespace description + +```shell +omics workbench namespaces update bcd869ca-8a06-4426-a94d-43f9d91e937d \ + --description "Updated workspace description" +``` + +### Update both name and description + +```shell +omics workbench namespaces update bcd869ca-8a06-4426-a94d-43f9d91e937d \ + --name "Renamed Lab" \ + --description "Updated workspace description" +``` + +## Positional Arguments: + +### `ID` + +**Required.** The ID of the namespace to update. The namespace ID can be retrieved from the [namespaces list](namespaces-list.md) command. + +## Flags: + +### `--name`=`NAME` + +The new name for the namespace. + +### `--description`=`TEXT` + +The new description for the namespace. +``` + +- [ ] **Step 2: Verify file exists and is well-formed** + +Run: `head -5 /Users/patrickmagee/development/dnastack/dnastack-product-docs/docs/cli/reference/workbench/namespaces-update.md` + +Expected: YAML frontmatter with `description: Update an existing namespace` + +--- + +### Task 6: Add SUMMARY.md entries + +**Files:** +- Modify: `/Users/patrickmagee/development/dnastack/dnastack-product-docs/docs/SUMMARY.md` + +- [ ] **Step 1: Add entries after the existing namespace commands block** + +Insert the following two lines after the `* [namespaces describe]` entry (line 151) and before the `* [namespaces members list]` entry (line 152): + +``` + * [namespaces create](cli/reference/workbench/namespaces-create.md) + * [namespaces update](cli/reference/workbench/namespaces-update.md) +``` + +The namespace section should then read: + +``` + * [namespaces get-active](cli/reference/workbench/namespaces-get-active.md) + * [namespaces set-active](cli/reference/workbench/namespaces-set-active.md) + * [namespaces get-default](cli/reference/workbench/namespaces-get-default.md) + * [namespaces list](cli/reference/workbench/namespaces-list.md) + * [namespaces describe](cli/reference/workbench/namespaces-describe.md) + * [namespaces create](cli/reference/workbench/namespaces-create.md) + * [namespaces update](cli/reference/workbench/namespaces-update.md) + * [namespaces members list](cli/reference/workbench/namespaces-members-list.md) + * [namespaces members add](cli/reference/workbench/namespaces-members-add.md) + * [namespaces members remove](cli/reference/workbench/namespaces-members-remove.md) +``` + +- [ ] **Step 2: Commit product docs changes** + +Note: This commit is in the `dnastack-product-docs` repo, not `dnastack-client`. Create a branch there too. + +```bash +cd /Users/patrickmagee/development/dnastack/dnastack-product-docs +git checkout -b add_namespace_create_update_docs-CU-86b8vxxhv +git add docs/cli/reference/workbench/namespaces-create.md docs/cli/reference/workbench/namespaces-update.md docs/SUMMARY.md +git commit -m "[CU-86b8vxxhv] Add namespace create and update CLI reference docs" +cd /Users/patrickmagee/development/dnastack/dnastack-client +``` + +--- + +## Chunk 3: Final Verification + +### Task 7: Full verification pass + +- [ ] **Step 1: Run linter** + +Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && make lint` + +Expected: No violations + +- [ ] **Step 2: Run full unit test suite** + +Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && make test-unit` + +Expected: All tests PASS + +- [ ] **Step 3: Verify git log** + +Run: `git log --oneline add_namespace_create_update_commands-CU-86b8vxxhv --not main` + +Expected: Commits for design spec, CLAUDE.md update, model, create command, update command + +- [ ] **Step 4: Review checklist** + +Verify: +- [ ] `NamespaceCreateRequest` model exists with tests +- [ ] `create_namespace` client method exists +- [ ] `update_namespace` client method exists with JsonPatch and ETag handling +- [ ] `create` CLI command registered in `init_namespace_commands` with docs URL in docstring +- [ ] `update` CLI command registered with positional ID, --name, --description, 409 handling, docs URL in docstring +- [ ] CLI validation: update with no flags raises UsageError +- [ ] Unit tests for both create and update commands +- [ ] Product docs created for both commands with correct format +- [ ] SUMMARY.md updated +- [ ] All commits have `[CU-86b8vxxhv]` prefix +- [ ] No regressions in existing namespace commands From 5af35ddc4a0a59f552e9f2f4b039847353592731 Mon Sep 17 00:00:00 2001 From: Patrick Magee Date: Fri, 13 Mar 2026 14:59:30 -0400 Subject: [PATCH 4/8] [CU-86b8vxxhv] Add NamespaceCreateRequest model with tests Co-Authored-By: Claude Opus 4.6 --- .../workbench_user_service/models.py | 5 +++++ .../test_namespace_models.py | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/dnastack/client/workbench/workbench_user_service/models.py b/dnastack/client/workbench/workbench_user_service/models.py index fbef9189..f9425c3d 100644 --- a/dnastack/client/workbench/workbench_user_service/models.py +++ b/dnastack/client/workbench/workbench_user_service/models.py @@ -44,6 +44,11 @@ class AddMemberRequest(BaseModel): role: str +class NamespaceCreateRequest(BaseModel): + name: str + description: Optional[str] = None + + class NamespaceMemberListResponse(PaginatedResource): members: List[NamespaceMember] diff --git a/tests/unit/client/workbench/workbench_user_service/test_namespace_models.py b/tests/unit/client/workbench/workbench_user_service/test_namespace_models.py index 0a195125..a4b3adba 100644 --- a/tests/unit/client/workbench/workbench_user_service/test_namespace_models.py +++ b/tests/unit/client/workbench/workbench_user_service/test_namespace_models.py @@ -1,5 +1,6 @@ from dnastack.client.workbench.workbench_user_service.models import ( Namespace, + NamespaceCreateRequest, NamespaceMember, NamespaceListResponse, NamespaceMemberListResponse, @@ -198,3 +199,24 @@ def test_serializes_with_both(self): req = AddMemberRequest(email="foo@dnastack.com", id="abc123", role="ADMIN") data = req.dict(exclude_none=True) assert data == {"email": "foo@dnastack.com", "id": "abc123", "role": "ADMIN"} + + +class TestNamespaceCreateRequestSerialization: + """Test suite for NamespaceCreateRequest model.""" + + def test_serializes_with_name_and_description(self): + req = NamespaceCreateRequest(name="My Namespace", description="A test namespace") + data = req.dict(exclude_none=True) + assert data == {"name": "My Namespace", "description": "A test namespace"} + + def test_serializes_with_name_only(self): + req = NamespaceCreateRequest(name="My Namespace") + data = req.dict(exclude_none=True) + assert data == {"name": "My Namespace"} + assert "description" not in data + + def test_serializes_with_explicit_none_description(self): + req = NamespaceCreateRequest(name="My Namespace", description=None) + data = req.dict(exclude_none=True) + assert data == {"name": "My Namespace"} + assert "description" not in data From b55978c987570efad503d2b04614feeeb22a0a78 Mon Sep 17 00:00:00 2001 From: Patrick Magee Date: Fri, 13 Mar 2026 15:06:32 -0400 Subject: [PATCH 5/8] [CU-86b8vxxhv] Add namespaces create command and client method Co-Authored-By: Claude Opus 4.6 --- .../commands/workbench/namespaces/commands.py | 34 +++++++++++++ .../workbench_user_service/client.py | 11 ++++ .../namespaces/test_namespace_commands.py | 50 +++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/dnastack/cli/commands/workbench/namespaces/commands.py b/dnastack/cli/commands/workbench/namespaces/commands.py index 1fd38eb2..fdba7228 100644 --- a/dnastack/cli/commands/workbench/namespaces/commands.py +++ b/dnastack/cli/commands/workbench/namespaces/commands.py @@ -149,3 +149,37 @@ def set_active_namespace(context: Optional[str], client = get_user_client(context, endpoint_id) namespace = client.set_active_namespace(namespace_id) click.echo(to_json(normalize(namespace))) + + @formatted_command( + group=group, + name='create', + specs=[ + ArgumentSpec( + name='name', + arg_names=['--name'], + help='The name of the namespace to create.', + required=True, + ), + ArgumentSpec( + name='description', + arg_names=['--description'], + help='A description for the namespace. Defaults to the namespace name if omitted.', + required=False, + ), + CONTEXT_ARG, + SINGLE_ENDPOINT_ID_ARG, + ] + ) + def create_namespace(context: Optional[str], + endpoint_id: Optional[str], + name: str, + description: Optional[str]): + """ + Create a new namespace + + docs: https://docs.omics.ai/products/command-line-interface/reference/workbench/namespaces-create + """ + + client = get_user_client(context, endpoint_id) + namespace = client.create_namespace(name=name, description=description) + click.echo(to_json(normalize(namespace))) diff --git a/dnastack/client/workbench/workbench_user_service/client.py b/dnastack/client/workbench/workbench_user_service/client.py index 3334986f..4dcc0732 100644 --- a/dnastack/client/workbench/workbench_user_service/client.py +++ b/dnastack/client/workbench/workbench_user_service/client.py @@ -14,6 +14,7 @@ NamespaceMember, NamespaceMemberListResponse, AddMemberRequest, + NamespaceCreateRequest, ) from dnastack.common.tracing import Span from dnastack.http.session import HttpSession @@ -122,6 +123,16 @@ def set_active_namespace(self, namespace_id: str) -> Namespace: ) return Namespace(**response.json()) + def create_namespace(self, name: str, description: Optional[str] = None) -> Namespace: + """Create a new namespace.""" + body = NamespaceCreateRequest(name=name, description=description) + with self.create_http_session() as session: + response = session.post( + urljoin(self.endpoint.url, 'namespaces'), + json=body.dict(exclude_none=True), + ) + return Namespace(**response.json()) + def list_namespace_members(self, namespace_id: str, list_options: Optional[BaseListOptions] = None, diff --git a/tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py b/tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py index fe7423d1..84e252d5 100644 --- a/tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py +++ b/tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py @@ -119,3 +119,53 @@ def test_stdout_still_contains_only_namespace(self, mock_get_client): assert result.exit_code == 0 assert result.output.strip() == "my-namespace" + + +class TestCreateCommand: + """Tests for the create namespace CLI command.""" + + def setup_method(self): + self.runner = CliRunner() + self.group = Group() + init_namespace_commands(self.group) + + self.mock_namespace = Namespace( + id="ns-new-123", + name="New Namespace", + description="A brand new namespace", + created_at="2026-03-13T00:00:00Z", + created_by="user@example.com", + updated_at="2026-03-13T00:00:00Z", + updated_by="user@example.com", + ) + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_create_with_name_only(self, mock_get_client): + mock_client = Mock() + mock_client.create_namespace.return_value = self.mock_namespace + mock_get_client.return_value = mock_client + + result = self.runner.invoke(self.group, ['create', '--name', 'New Namespace']) + + assert result.exit_code == 0 + mock_client.create_namespace.assert_called_once_with(name="New Namespace", description=None) + output = json.loads(result.output) + assert output["id"] == "ns-new-123" + assert output["name"] == "New Namespace" + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_create_with_name_and_description(self, mock_get_client): + mock_client = Mock() + mock_client.create_namespace.return_value = self.mock_namespace + mock_get_client.return_value = mock_client + + result = self.runner.invoke(self.group, ['create', '--name', 'New Namespace', '--description', 'A brand new namespace']) + + assert result.exit_code == 0 + mock_client.create_namespace.assert_called_once_with(name="New Namespace", description="A brand new namespace") + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_create_requires_name(self, mock_get_client): + result = self.runner.invoke(self.group, ['create']) + + assert result.exit_code != 0 From 06bc634e6c209f42df46bbc916845a41b9123426 Mon Sep 17 00:00:00 2001 From: Patrick Magee Date: Fri, 13 Mar 2026 15:15:04 -0400 Subject: [PATCH 6/8] [CU-86b8vxxhv] Add namespaces update command with JSON Patch and conflict handling Co-Authored-By: Claude Opus 4.6 --- .../commands/workbench/namespaces/commands.py | 50 +++++++++++ .../workbench_user_service/client.py | 26 +++++- .../namespaces/test_namespace_commands.py | 89 +++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/dnastack/cli/commands/workbench/namespaces/commands.py b/dnastack/cli/commands/workbench/namespaces/commands.py index fdba7228..0b796af6 100644 --- a/dnastack/cli/commands/workbench/namespaces/commands.py +++ b/dnastack/cli/commands/workbench/namespaces/commands.py @@ -5,6 +5,7 @@ from dnastack.cli.commands.utils import MAX_RESULTS_ARG, PAGINATION_PAGE_ARG, PAGINATION_PAGE_SIZE_ARG from dnastack.cli.commands.workbench.utils import get_user_client +from dnastack.http.session import ClientError from dnastack.cli.core.command import formatted_command from dnastack.cli.core.command_spec import ArgumentSpec, ArgumentType, CONTEXT_ARG, SINGLE_ENDPOINT_ID_ARG from dnastack.cli.helpers.exporter import to_json, normalize @@ -183,3 +184,52 @@ def create_namespace(context: Optional[str], client = get_user_client(context, endpoint_id) namespace = client.create_namespace(name=name, description=description) click.echo(to_json(normalize(namespace))) + + @formatted_command( + group=group, + name='update', + specs=[ + ArgumentSpec( + name='namespace_id', + arg_type=ArgumentType.POSITIONAL, + help='The namespace ID to update.', + required=True, + ), + ArgumentSpec( + name='name', + arg_names=['--name'], + help='The new name for the namespace.', + required=False, + ), + ArgumentSpec( + name='description', + arg_names=['--description'], + help='The new description for the namespace.', + required=False, + ), + CONTEXT_ARG, + SINGLE_ENDPOINT_ID_ARG, + ] + ) + def update_namespace(context: Optional[str], + endpoint_id: Optional[str], + namespace_id: str, + name: Optional[str], + description: Optional[str]): + """ + Update an existing namespace + + docs: https://docs.omics.ai/products/command-line-interface/reference/workbench/namespaces-update + """ + + if name is None and description is None: + raise click.UsageError("Specify at least one of --name or --description.") + + client = get_user_client(context, endpoint_id) + try: + namespace = client.update_namespace(namespace_id=namespace_id, name=name, description=description) + except ClientError as e: + if e.response.status_code == 409: + raise click.ClickException("Namespace was modified by another user. Please retry.") + raise + click.echo(to_json(normalize(namespace))) diff --git a/dnastack/client/workbench/workbench_user_service/client.py b/dnastack/client/workbench/workbench_user_service/client.py index 4dcc0732..ba79189a 100644 --- a/dnastack/client/workbench/workbench_user_service/client.py +++ b/dnastack/client/workbench/workbench_user_service/client.py @@ -17,7 +17,7 @@ NamespaceCreateRequest, ) from dnastack.common.tracing import Span -from dnastack.http.session import HttpSession +from dnastack.http.session import HttpSession, JsonPatch class NamespaceListResultLoader(WorkbenchResultLoader): @@ -133,6 +133,30 @@ def create_namespace(self, name: str, description: Optional[str] = None) -> Name ) return Namespace(**response.json()) + def update_namespace(self, + namespace_id: str, + name: Optional[str] = None, + description: Optional[str] = None) -> Namespace: + """Update a namespace using JSON Patch. Auto-fetches ETag for optimistic locking.""" + patches = [] + if name is not None: + patches.append(JsonPatch(op='replace', path='/name', value=name).dict()) + if description is not None: + patches.append(JsonPatch(op='replace', path='/description', value=description).dict()) + + with self.create_http_session() as session: + get_response = session.get( + urljoin(self.endpoint.url, f'namespaces/{namespace_id}') + ) + etag = (get_response.headers.get('etag') or '').strip('"') + + patch_response = session.json_patch( + urljoin(self.endpoint.url, f'namespaces/{namespace_id}'), + headers={'If-Match': etag}, + json=patches, + ) + return Namespace(**patch_response.json()) + def list_namespace_members(self, namespace_id: str, list_options: Optional[BaseListOptions] = None, diff --git a/tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py b/tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py index 84e252d5..27e1e083 100644 --- a/tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py +++ b/tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py @@ -5,6 +5,7 @@ from dnastack.cli.commands.workbench.namespaces.commands import init_namespace_commands from dnastack.client.workbench.workbench_user_service.models import Namespace +from dnastack.http.session import ClientError class TestGetActiveCommand: @@ -169,3 +170,91 @@ def test_create_requires_name(self, mock_get_client): result = self.runner.invoke(self.group, ['create']) assert result.exit_code != 0 + + +class TestUpdateCommand: + """Tests for the update namespace CLI command.""" + + def setup_method(self): + self.runner = CliRunner() + self.group = Group() + init_namespace_commands(self.group) + + self.mock_namespace = Namespace( + id="ns-existing-456", + name="Updated Namespace", + description="Updated description", + created_at="2026-03-13T00:00:00Z", + created_by="user@example.com", + updated_at="2026-03-13T12:00:00Z", + updated_by="user@example.com", + ) + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_update_name_only(self, mock_get_client): + mock_client = Mock() + mock_client.update_namespace.return_value = self.mock_namespace + mock_get_client.return_value = mock_client + + result = self.runner.invoke(self.group, ['update', 'ns-existing-456', '--name', 'Updated Namespace']) + + assert result.exit_code == 0 + mock_client.update_namespace.assert_called_once_with( + namespace_id="ns-existing-456", name="Updated Namespace", description=None + ) + output = json.loads(result.output) + assert output["id"] == "ns-existing-456" + assert output["name"] == "Updated Namespace" + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_update_description_only(self, mock_get_client): + mock_client = Mock() + mock_client.update_namespace.return_value = self.mock_namespace + mock_get_client.return_value = mock_client + + result = self.runner.invoke(self.group, ['update', 'ns-existing-456', '--description', 'Updated description']) + + assert result.exit_code == 0 + mock_client.update_namespace.assert_called_once_with( + namespace_id="ns-existing-456", name=None, description="Updated description" + ) + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_update_both_name_and_description(self, mock_get_client): + mock_client = Mock() + mock_client.update_namespace.return_value = self.mock_namespace + mock_get_client.return_value = mock_client + + result = self.runner.invoke(self.group, ['update', 'ns-existing-456', '--name', 'Updated Namespace', '--description', 'Updated description']) + + assert result.exit_code == 0 + mock_client.update_namespace.assert_called_once_with( + namespace_id="ns-existing-456", name="Updated Namespace", description="Updated description" + ) + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_update_requires_at_least_one_flag(self, mock_get_client): + result = self.runner.invoke(self.group, ['update', 'ns-existing-456']) + + assert result.exit_code != 0 + assert "at least one" in result.output.lower() + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_update_requires_namespace_id(self, mock_get_client): + result = self.runner.invoke(self.group, ['update', '--name', 'Test']) + + assert result.exit_code != 0 + + @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') + def test_update_handles_409_conflict(self, mock_get_client): + mock_client = Mock() + mock_response = Mock() + mock_response.status_code = 409 + mock_response.text = "Conflict" + mock_client.update_namespace.side_effect = ClientError(mock_response) + mock_get_client.return_value = mock_client + + result = self.runner.invoke(self.group, ['update', 'ns-existing-456', '--name', 'Conflict Test']) + + assert result.exit_code != 0 + assert "modified by another user" in result.output.lower() From e0a980e9a34b8907cab108fde09b89162ae32043 Mon Sep 17 00:00:00 2001 From: Patrick Magee Date: Fri, 13 Mar 2026 15:28:20 -0400 Subject: [PATCH 7/8] [CU-86b8vxxhv] Remove implementation plan from tracked files --- .../2026-03-13-namespace-create-update.md | 741 ------------------ 1 file changed, 741 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-13-namespace-create-update.md diff --git a/docs/superpowers/plans/2026-03-13-namespace-create-update.md b/docs/superpowers/plans/2026-03-13-namespace-create-update.md deleted file mode 100644 index a18463dd..00000000 --- a/docs/superpowers/plans/2026-03-13-namespace-create-update.md +++ /dev/null @@ -1,741 +0,0 @@ -# Namespace Create & Update CLI Commands Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add `namespaces create` and `namespaces update` commands to the dnastack-client CLI. - -**Architecture:** Two new client methods on `WorkbenchUserClient` backed by `POST /namespaces` and `PATCH /namespaces/{id}`. Two new CLI commands registered in `init_namespace_commands`. Uses existing `JsonPatch` model and `session.json_patch()` for the update flow. Product docs for both commands. - -**Tech Stack:** Python, Click, Pydantic v2, requests - -**Spec:** `docs/superpowers/specs/2026-03-13-namespace-create-update-design.md` - -**ClickUp:** [CU-86b8vxxhv](https://app.clickup.com/t/86b8vxxhv) - -**Pyenv:** Run `eval "$(pyenv init -)" && pyenv activate dnastack-client` before any Python/make/commit command. - ---- - -## File Structure - -| Action | File | Responsibility | -|--------|------|----------------| -| Modify | `dnastack/client/workbench/workbench_user_service/models.py` | Add `NamespaceCreateRequest` model | -| Modify | `dnastack/client/workbench/workbench_user_service/client.py` | Add `create_namespace`, `update_namespace` methods | -| Modify | `dnastack/cli/commands/workbench/namespaces/commands.py` | Add `create` and `update` CLI commands | -| Modify | `tests/unit/client/workbench/workbench_user_service/test_namespace_models.py` | Add model serialization tests | -| Modify | `tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py` | Add CLI command tests | -| Create | `@dnastack-product-docs/docs/cli/reference/workbench/namespaces-create.md` | Create command reference doc | -| Create | `@dnastack-product-docs/docs/cli/reference/workbench/namespaces-update.md` | Update command reference doc | -| Modify | `@dnastack-product-docs/docs/SUMMARY.md` | Add sidebar entries for new docs | - -`@dnastack-product-docs` = `/Users/patrickmagee/development/dnastack/dnastack-product-docs` - ---- - -## Chunk 1: Model and Client Layer - -### Task 1: Add NamespaceCreateRequest model - -**Files:** -- Modify: `dnastack/client/workbench/workbench_user_service/models.py` -- Modify: `tests/unit/client/workbench/workbench_user_service/test_namespace_models.py` - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/unit/client/workbench/workbench_user_service/test_namespace_models.py`: - -```python -from dnastack.client.workbench.workbench_user_service.models import ( - # ... existing imports ... - NamespaceCreateRequest, -) - - -class TestNamespaceCreateRequestSerialization: - """Test suite for NamespaceCreateRequest model.""" - - def test_serializes_with_name_and_description(self): - req = NamespaceCreateRequest(name="My Namespace", description="A test namespace") - data = req.dict(exclude_none=True) - assert data == {"name": "My Namespace", "description": "A test namespace"} - - def test_serializes_with_name_only(self): - req = NamespaceCreateRequest(name="My Namespace") - data = req.dict(exclude_none=True) - assert data == {"name": "My Namespace"} - assert "description" not in data - - def test_serializes_with_explicit_none_description(self): - req = NamespaceCreateRequest(name="My Namespace", description=None) - data = req.dict(exclude_none=True) - assert data == {"name": "My Namespace"} - assert "description" not in data -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/client/workbench/workbench_user_service/test_namespace_models.py::TestNamespaceCreateRequestSerialization -v` - -Expected: FAIL with `ImportError: cannot import name 'NamespaceCreateRequest'` - -- [ ] **Step 3: Add the NamespaceCreateRequest model** - -Add to `dnastack/client/workbench/workbench_user_service/models.py`, after the `AddMemberRequest` class: - -```python -class NamespaceCreateRequest(BaseModel): - name: str - description: Optional[str] = None -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/client/workbench/workbench_user_service/test_namespace_models.py::TestNamespaceCreateRequestSerialization -v` - -Expected: 3 PASSED - -- [ ] **Step 5: Run full model test suite to verify no regressions** - -Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/client/workbench/workbench_user_service/test_namespace_models.py -v` - -Expected: All existing tests still PASS - -- [ ] **Step 6: Commit** - -```bash -eval "$(pyenv init -)" && pyenv activate dnastack-client -git add dnastack/client/workbench/workbench_user_service/models.py tests/unit/client/workbench/workbench_user_service/test_namespace_models.py -git commit -m "[CU-86b8vxxhv] Add NamespaceCreateRequest model with tests" -``` - ---- - -### Task 2: Add create_namespace client method - -**Files:** -- Modify: `dnastack/client/workbench/workbench_user_service/client.py` -- Modify: `tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py`. First update the imports at the top of the file: - -```python -import json -from unittest.mock import Mock, patch -from click import Group -from click.testing import CliRunner - -from dnastack.cli.commands.workbench.namespaces.commands import init_namespace_commands -from dnastack.client.workbench.workbench_user_service.models import Namespace -``` - -Then add the test class: - -```python -class TestCreateCommand: - """Tests for the create namespace CLI command.""" - - def setup_method(self): - self.runner = CliRunner() - self.group = Group() - init_namespace_commands(self.group) - - self.mock_namespace = Namespace( - id="ns-new-123", - name="New Namespace", - description="A brand new namespace", - created_at="2026-03-13T00:00:00Z", - created_by="user@example.com", - updated_at="2026-03-13T00:00:00Z", - updated_by="user@example.com", - ) - - @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') - def test_create_with_name_only(self, mock_get_client): - mock_client = Mock() - mock_client.create_namespace.return_value = self.mock_namespace - mock_get_client.return_value = mock_client - - result = self.runner.invoke(self.group, ['create', '--name', 'New Namespace']) - - assert result.exit_code == 0 - mock_client.create_namespace.assert_called_once_with(name="New Namespace", description=None) - output = json.loads(result.output) - assert output["id"] == "ns-new-123" - assert output["name"] == "New Namespace" - - @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') - def test_create_with_name_and_description(self, mock_get_client): - mock_client = Mock() - mock_client.create_namespace.return_value = self.mock_namespace - mock_get_client.return_value = mock_client - - result = self.runner.invoke(self.group, ['create', '--name', 'New Namespace', '--description', 'A brand new namespace']) - - assert result.exit_code == 0 - mock_client.create_namespace.assert_called_once_with(name="New Namespace", description="A brand new namespace") - - @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') - def test_create_requires_name(self, mock_get_client): - result = self.runner.invoke(self.group, ['create']) - - assert result.exit_code != 0 -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py::TestCreateCommand -v` - -Expected: FAIL (no 'create' command registered) - -- [ ] **Step 3: Add create_namespace client method** - -Add to `dnastack/client/workbench/workbench_user_service/client.py`. - -First update the imports at the top: - -```python -from dnastack.client.workbench.workbench_user_service.models import ( - WorkbenchUser, - Namespace, - NamespaceListResponse, - NamespaceMember, - NamespaceMemberListResponse, - AddMemberRequest, - NamespaceCreateRequest, -) -``` - -Then add the method to `WorkbenchUserClient`, after the `set_active_namespace` method: - -```python - def create_namespace(self, name: str, description: Optional[str] = None) -> Namespace: - """Create a new namespace.""" - body = NamespaceCreateRequest(name=name, description=description) - with self.create_http_session() as session: - response = session.post( - urljoin(self.endpoint.url, 'namespaces'), - json=body.dict(exclude_none=True), - ) - return Namespace(**response.json()) -``` - -- [ ] **Step 4: Add the create CLI command** - -Add to `dnastack/cli/commands/workbench/namespaces/commands.py`, inside `init_namespace_commands`, after the `set_active_namespace` command: - -```python - @formatted_command( - group=group, - name='create', - specs=[ - ArgumentSpec( - name='name', - arg_names=['--name'], - help='The name of the namespace to create.', - required=True, - ), - ArgumentSpec( - name='description', - arg_names=['--description'], - help='A description for the namespace. Defaults to the namespace name if omitted.', - required=False, - ), - CONTEXT_ARG, - SINGLE_ENDPOINT_ID_ARG, - ] - ) - def create_namespace(context: Optional[str], - endpoint_id: Optional[str], - name: str, - description: Optional[str]): - """ - Create a new namespace - - docs: https://docs.omics.ai/products/command-line-interface/reference/workbench/namespaces-create - """ - - client = get_user_client(context, endpoint_id) - namespace = client.create_namespace(name=name, description=description) - click.echo(to_json(normalize(namespace))) -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py::TestCreateCommand -v` - -Expected: 3 PASSED - -- [ ] **Step 6: Run full CLI test suite to verify no regressions** - -Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/cli/commands/workbench/namespaces/ -v` - -Expected: All existing tests still PASS - -- [ ] **Step 7: Commit** - -```bash -eval "$(pyenv init -)" && pyenv activate dnastack-client -git add dnastack/client/workbench/workbench_user_service/client.py dnastack/cli/commands/workbench/namespaces/commands.py tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py -git commit -m "[CU-86b8vxxhv] Add namespaces create command and client method" -``` - ---- - -### Task 3: Add update_namespace client method and CLI command - -**Files:** -- Modify: `dnastack/client/workbench/workbench_user_service/client.py` -- Modify: `dnastack/cli/commands/workbench/namespaces/commands.py` -- Modify: `tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py` - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py`: - -```python -from dnastack.http.session import ClientError - - -class TestUpdateCommand: - """Tests for the update namespace CLI command.""" - - def setup_method(self): - self.runner = CliRunner() - self.group = Group() - init_namespace_commands(self.group) - - self.mock_namespace = Namespace( - id="ns-existing-456", - name="Updated Namespace", - description="Updated description", - created_at="2026-03-13T00:00:00Z", - created_by="user@example.com", - updated_at="2026-03-13T12:00:00Z", - updated_by="user@example.com", - ) - - @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') - def test_update_name_only(self, mock_get_client): - mock_client = Mock() - mock_client.update_namespace.return_value = self.mock_namespace - mock_get_client.return_value = mock_client - - result = self.runner.invoke(self.group, ['update', 'ns-existing-456', '--name', 'Updated Namespace']) - - assert result.exit_code == 0 - mock_client.update_namespace.assert_called_once_with( - namespace_id="ns-existing-456", name="Updated Namespace", description=None - ) - output = json.loads(result.output) - assert output["id"] == "ns-existing-456" - assert output["name"] == "Updated Namespace" - - @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') - def test_update_description_only(self, mock_get_client): - mock_client = Mock() - mock_client.update_namespace.return_value = self.mock_namespace - mock_get_client.return_value = mock_client - - result = self.runner.invoke(self.group, ['update', 'ns-existing-456', '--description', 'Updated description']) - - assert result.exit_code == 0 - mock_client.update_namespace.assert_called_once_with( - namespace_id="ns-existing-456", name=None, description="Updated description" - ) - - @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') - def test_update_both_name_and_description(self, mock_get_client): - mock_client = Mock() - mock_client.update_namespace.return_value = self.mock_namespace - mock_get_client.return_value = mock_client - - result = self.runner.invoke(self.group, ['update', 'ns-existing-456', '--name', 'Updated Namespace', '--description', 'Updated description']) - - assert result.exit_code == 0 - mock_client.update_namespace.assert_called_once_with( - namespace_id="ns-existing-456", name="Updated Namespace", description="Updated description" - ) - - @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') - def test_update_requires_at_least_one_flag(self, mock_get_client): - result = self.runner.invoke(self.group, ['update', 'ns-existing-456']) - - assert result.exit_code != 0 - assert "at least one" in result.output.lower() - - @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') - def test_update_requires_namespace_id(self, mock_get_client): - result = self.runner.invoke(self.group, ['update', '--name', 'Test']) - - assert result.exit_code != 0 - - @patch('dnastack.cli.commands.workbench.namespaces.commands.get_user_client') - def test_update_handles_409_conflict(self, mock_get_client): - mock_client = Mock() - mock_response = Mock() - mock_response.status_code = 409 - mock_response.text = "Conflict" - mock_client.update_namespace.side_effect = ClientError(mock_response) - mock_get_client.return_value = mock_client - - result = self.runner.invoke(self.group, ['update', 'ns-existing-456', '--name', 'Conflict Test']) - - assert result.exit_code != 0 - assert "modified by another user" in result.output.lower() -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py::TestUpdateCommand -v` - -Expected: FAIL (no 'update' command registered) - -- [ ] **Step 3: Add update_namespace client method** - -Add to `dnastack/client/workbench/workbench_user_service/client.py`. First update the import at the top: - -```python -from dnastack.http.session import HttpSession, JsonPatch -``` - -Then add the method to `WorkbenchUserClient`, after the `create_namespace` method: - -```python - def update_namespace(self, - namespace_id: str, - name: Optional[str] = None, - description: Optional[str] = None) -> Namespace: - """Update a namespace using JSON Patch. Auto-fetches ETag for optimistic locking.""" - patches = [] - if name is not None: - patches.append(JsonPatch(op='replace', path='/name', value=name).dict()) - if description is not None: - patches.append(JsonPatch(op='replace', path='/description', value=description).dict()) - - with self.create_http_session() as session: - get_response = session.get( - urljoin(self.endpoint.url, f'namespaces/{namespace_id}') - ) - etag = (get_response.headers.get('etag') or '').strip('"') - - patch_response = session.json_patch( - urljoin(self.endpoint.url, f'namespaces/{namespace_id}'), - headers={'If-Match': etag}, - json=patches, - ) - return Namespace(**patch_response.json()) -``` - -- [ ] **Step 4: Add the update CLI command** - -Add to `dnastack/cli/commands/workbench/namespaces/commands.py`. First add the import at the top: - -```python -from dnastack.http.session import ClientError -``` - -Then add inside `init_namespace_commands`, after the `create_namespace` command: - -```python - @formatted_command( - group=group, - name='update', - specs=[ - ArgumentSpec( - name='namespace_id', - arg_type=ArgumentType.POSITIONAL, - help='The namespace ID to update.', - required=True, - ), - ArgumentSpec( - name='name', - arg_names=['--name'], - help='The new name for the namespace.', - required=False, - ), - ArgumentSpec( - name='description', - arg_names=['--description'], - help='The new description for the namespace.', - required=False, - ), - CONTEXT_ARG, - SINGLE_ENDPOINT_ID_ARG, - ] - ) - def update_namespace(context: Optional[str], - endpoint_id: Optional[str], - namespace_id: str, - name: Optional[str], - description: Optional[str]): - """ - Update an existing namespace - - docs: https://docs.omics.ai/products/command-line-interface/reference/workbench/namespaces-update - """ - - if name is None and description is None: - raise click.UsageError("Specify at least one of --name or --description.") - - client = get_user_client(context, endpoint_id) - try: - namespace = client.update_namespace(namespace_id=namespace_id, name=name, description=description) - except ClientError as e: - if e.response.status_code == 409: - raise click.ClickException("Namespace was modified by another user. Please retry.") - raise - click.echo(to_json(normalize(namespace))) -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py::TestUpdateCommand -v` - -Expected: 6 PASSED - -- [ ] **Step 6: Run full test suite to verify no regressions** - -Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && uv run pytest tests/unit/cli/commands/workbench/namespaces/ tests/unit/client/workbench/workbench_user_service/ -v` - -Expected: All tests PASS - -- [ ] **Step 7: Lint** - -Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && make lint` - -Expected: No violations - -- [ ] **Step 8: Commit** - -```bash -eval "$(pyenv init -)" && pyenv activate dnastack-client -git add dnastack/client/workbench/workbench_user_service/client.py dnastack/cli/commands/workbench/namespaces/commands.py tests/unit/cli/commands/workbench/namespaces/test_namespace_commands.py -git commit -m "[CU-86b8vxxhv] Add namespaces update command with JSON Patch and conflict handling" -``` - ---- - -## Chunk 2: Product Documentation - -### Task 4: Add namespaces-create.md product doc - -**Files:** -- Create: `/Users/patrickmagee/development/dnastack/dnastack-product-docs/docs/cli/reference/workbench/namespaces-create.md` - -- [ ] **Step 1: Create the doc file** - -```markdown ---- -description: Create a new namespace ---- - -# namespaces create - -## Synopsis - -```shell -omics workbench namespaces create - --name NAME - [--description TEXT] -``` - -## Description - -Create a new namespace. The authenticated user is automatically added as an ADMIN of the new namespace. - -If no description is provided, the API defaults the description to the namespace name. - -## Examples - -### Create a namespace - -```shell -omics workbench namespaces create --name "My Research Lab" -``` - -### Create a namespace with a description - -```shell -omics workbench namespaces create \ - --name "My Research Lab" \ - --description "Shared workspace for genomics research" -``` - -## Flags: - -### `--name`=`NAME` - -**Required.** The name of the namespace to create. - -### `--description`=`TEXT` - -A description for the namespace. If omitted, defaults to the namespace name. -``` - -- [ ] **Step 2: Verify file exists and is well-formed** - -Run: `head -5 /Users/patrickmagee/development/dnastack/dnastack-product-docs/docs/cli/reference/workbench/namespaces-create.md` - -Expected: YAML frontmatter with `description: Create a new namespace` - ---- - -### Task 5: Add namespaces-update.md product doc - -**Files:** -- Create: `/Users/patrickmagee/development/dnastack/dnastack-product-docs/docs/cli/reference/workbench/namespaces-update.md` - -- [ ] **Step 1: Create the doc file** - -```markdown ---- -description: Update an existing namespace ---- - -# namespaces update - -## Synopsis - -```shell -omics workbench namespaces update ID - [--name NAME] - [--description TEXT] -``` - -## Description - -Update the name or description of an existing namespace. At least one of `--name` or `--description` must be provided. - -The command uses optimistic locking to prevent conflicting updates. If the namespace was modified by another user between when it was read and when the update is applied, the command returns an error and the update must be retried. - -## Examples - -### Update a namespace name - -```shell -omics workbench namespaces update bcd869ca-8a06-4426-a94d-43f9d91e937d \ - --name "Renamed Lab" -``` - -### Update a namespace description - -```shell -omics workbench namespaces update bcd869ca-8a06-4426-a94d-43f9d91e937d \ - --description "Updated workspace description" -``` - -### Update both name and description - -```shell -omics workbench namespaces update bcd869ca-8a06-4426-a94d-43f9d91e937d \ - --name "Renamed Lab" \ - --description "Updated workspace description" -``` - -## Positional Arguments: - -### `ID` - -**Required.** The ID of the namespace to update. The namespace ID can be retrieved from the [namespaces list](namespaces-list.md) command. - -## Flags: - -### `--name`=`NAME` - -The new name for the namespace. - -### `--description`=`TEXT` - -The new description for the namespace. -``` - -- [ ] **Step 2: Verify file exists and is well-formed** - -Run: `head -5 /Users/patrickmagee/development/dnastack/dnastack-product-docs/docs/cli/reference/workbench/namespaces-update.md` - -Expected: YAML frontmatter with `description: Update an existing namespace` - ---- - -### Task 6: Add SUMMARY.md entries - -**Files:** -- Modify: `/Users/patrickmagee/development/dnastack/dnastack-product-docs/docs/SUMMARY.md` - -- [ ] **Step 1: Add entries after the existing namespace commands block** - -Insert the following two lines after the `* [namespaces describe]` entry (line 151) and before the `* [namespaces members list]` entry (line 152): - -``` - * [namespaces create](cli/reference/workbench/namespaces-create.md) - * [namespaces update](cli/reference/workbench/namespaces-update.md) -``` - -The namespace section should then read: - -``` - * [namespaces get-active](cli/reference/workbench/namespaces-get-active.md) - * [namespaces set-active](cli/reference/workbench/namespaces-set-active.md) - * [namespaces get-default](cli/reference/workbench/namespaces-get-default.md) - * [namespaces list](cli/reference/workbench/namespaces-list.md) - * [namespaces describe](cli/reference/workbench/namespaces-describe.md) - * [namespaces create](cli/reference/workbench/namespaces-create.md) - * [namespaces update](cli/reference/workbench/namespaces-update.md) - * [namespaces members list](cli/reference/workbench/namespaces-members-list.md) - * [namespaces members add](cli/reference/workbench/namespaces-members-add.md) - * [namespaces members remove](cli/reference/workbench/namespaces-members-remove.md) -``` - -- [ ] **Step 2: Commit product docs changes** - -Note: This commit is in the `dnastack-product-docs` repo, not `dnastack-client`. Create a branch there too. - -```bash -cd /Users/patrickmagee/development/dnastack/dnastack-product-docs -git checkout -b add_namespace_create_update_docs-CU-86b8vxxhv -git add docs/cli/reference/workbench/namespaces-create.md docs/cli/reference/workbench/namespaces-update.md docs/SUMMARY.md -git commit -m "[CU-86b8vxxhv] Add namespace create and update CLI reference docs" -cd /Users/patrickmagee/development/dnastack/dnastack-client -``` - ---- - -## Chunk 3: Final Verification - -### Task 7: Full verification pass - -- [ ] **Step 1: Run linter** - -Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && make lint` - -Expected: No violations - -- [ ] **Step 2: Run full unit test suite** - -Run: `eval "$(pyenv init -)" && pyenv activate dnastack-client && make test-unit` - -Expected: All tests PASS - -- [ ] **Step 3: Verify git log** - -Run: `git log --oneline add_namespace_create_update_commands-CU-86b8vxxhv --not main` - -Expected: Commits for design spec, CLAUDE.md update, model, create command, update command - -- [ ] **Step 4: Review checklist** - -Verify: -- [ ] `NamespaceCreateRequest` model exists with tests -- [ ] `create_namespace` client method exists -- [ ] `update_namespace` client method exists with JsonPatch and ETag handling -- [ ] `create` CLI command registered in `init_namespace_commands` with docs URL in docstring -- [ ] `update` CLI command registered with positional ID, --name, --description, 409 handling, docs URL in docstring -- [ ] CLI validation: update with no flags raises UsageError -- [ ] Unit tests for both create and update commands -- [ ] Product docs created for both commands with correct format -- [ ] SUMMARY.md updated -- [ ] All commits have `[CU-86b8vxxhv]` prefix -- [ ] No regressions in existing namespace commands From 5807e74c8f6be4e72a157144d0c3e64b02ec4e5d Mon Sep 17 00:00:00 2001 From: Patrick Magee Date: Fri, 13 Mar 2026 15:41:08 -0400 Subject: [PATCH 8/8] [CU-86b8vxxhv] Add ETag validation and empty-patch guard to update_namespace Co-Authored-By: Claude Opus 4.6 --- dnastack/client/workbench/workbench_user_service/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dnastack/client/workbench/workbench_user_service/client.py b/dnastack/client/workbench/workbench_user_service/client.py index ba79189a..e8a8a0b2 100644 --- a/dnastack/client/workbench/workbench_user_service/client.py +++ b/dnastack/client/workbench/workbench_user_service/client.py @@ -144,11 +144,15 @@ def update_namespace(self, if description is not None: patches.append(JsonPatch(op='replace', path='/description', value=description).dict()) + if not patches: + raise ValueError("At least one of name or description must be provided.") + with self.create_http_session() as session: get_response = session.get( urljoin(self.endpoint.url, f'namespaces/{namespace_id}') ) etag = (get_response.headers.get('etag') or '').strip('"') + assert etag, f'GET namespaces/{namespace_id} does not provide ETag. Unable to update the namespace.' patch_response = session.json_patch( urljoin(self.endpoint.url, f'namespaces/{namespace_id}'),