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 diff --git a/dnastack/cli/commands/workbench/namespaces/commands.py b/dnastack/cli/commands/workbench/namespaces/commands.py index 1fd38eb2..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 @@ -149,3 +150,86 @@ 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))) + + @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 3334986f..e8a8a0b2 100644 --- a/dnastack/client/workbench/workbench_user_service/client.py +++ b/dnastack/client/workbench/workbench_user_service/client.py @@ -14,9 +14,10 @@ NamespaceMember, NamespaceMemberListResponse, AddMemberRequest, + NamespaceCreateRequest, ) from dnastack.common.tracing import Span -from dnastack.http.session import HttpSession +from dnastack.http.session import HttpSession, JsonPatch class NamespaceListResultLoader(WorkbenchResultLoader): @@ -122,6 +123,44 @@ 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 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()) + + 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}'), + 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/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/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 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..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: @@ -119,3 +120,141 @@ 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 + + +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() 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