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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **Users in several NDI Cloud organizations** (mirroring VH-Lab/NDI-matlab#1037).
Login no longer keeps only the first organization: it caches the user's
whole list -- id, name and upload permission -- as JSON in
`NDI_CLOUD_ORGANIZATIONS`, and `logout` clears it. New
`ndi.cloud.internal.getOrganizations`, `setOrganizations`,
`normalizeOrganizations`, `chooseOrganization` and `userIsAdmin`, and
`ndi.cloud.ui.dialog.organizationLabels` / `selectOrganization`.
`chooseOrganization` uses a named organization, else the only one that
qualifies, else asks (on a terminal, or through a supplied chooser), else
raises `CloudOrganizationError` listing the organizations by name and id.
`createDataset` and `uploadDataset` take `organization_name` /
`organization_id`; `listDatasets()` with no organization lists every
organization's datasets, tagged with `organizationId` and
`organizationName`. File uploads now go to the organization that owns the
dataset rather than the login's first organization.

- **A double-click installer for end users** in `install/`: `Install NDI.command`
(macOS), `Install NDI.cmd` (Windows), and the scripts behind them,
`install-ndi.sh` (Linux and macOS) and `install-ndi.ps1` (Windows), which
Expand Down Expand Up @@ -123,6 +139,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Changed

- **No single NDI Cloud organization any more** (VH-Lab/NDI-matlab#1037).
`CloudConfig.org_id` and `NDI_CLOUD_ORGANIZATION_ID` are gone, and
`ndi.cloud.auth.getActiveToken` returns the token only. The second output of
`ndi.cloud.auth.authenticate` is deprecated: it is the user's only
organization, or `""` when there are several. Code that read
`client.config.org_id` should call `ndi.cloud.internal.chooseOrganization`.

- **`napariViewGEF` and `napariViewLightsheet` are safe to start from MATLAB
by themselves.** They now go through `_ndi_launch`, a stdlib-only module
beside the package: started with `NDI_FROM_MATLAB=1` (or with MATLAB's
Expand Down
119 changes: 81 additions & 38 deletions src/ndi/cloud/api/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,6 @@
_Client = Annotated[CloudClient | None, SkipValidation()]


def _resolve_org_id(org_id: str | None, client: CloudClient) -> str:
"""Return *org_id* if given, otherwise pull it from client config."""
if org_id:
return org_id
resolved = getattr(client, "config", None)
if resolved and getattr(resolved, "org_id", ""):
return resolved.org_id
raise ValueError(
"org_id is required but was not provided and could not be "
"resolved from the client config. Either pass org_id explicitly "
"or set NDI_CLOUD_ORGANIZATION_ID in the environment."
)


@_auto_client
@validate_call(config=VALIDATE_CONFIG)
def getDataset(dataset_id: CloudId, *, client: _Client = None) -> dict[str, Any]:
Expand All @@ -49,17 +35,33 @@ def createDataset(
name: str = "",
description: str = "",
*,
organization_name: str = "",
client: _Client = None,
**kwargs: Any,
) -> dict[str, Any]:
"""POST /organizations/{organizationId}/datasets

If *org_id* is omitted it is resolved from the client's config
(populated automatically during login), matching MATLAB behaviour.
MATLAB equivalent: ndi.cloud.api.datasets.createDataset

Creates a new dataset in one of the organizations the current user
belongs to. Name the organization with *org_id* or *organization_name*
(mutually exclusive). If neither is given and the user may upload to
exactly one organization, that one is used; otherwise the user is asked
to choose (when standard input is a terminal) or a
:class:`~ndi.cloud.exceptions.CloudOrganizationError` lists the
organizations. See :func:`ndi.cloud.internal.chooseOrganization`.
"""
org_id = _resolve_org_id(org_id, client)
if not name:
raise ValueError("name is required")
from ..internal import chooseOrganization

org_id, _ = chooseOrganization(
organization_name,
org_id or "",
require_upload=True,
prompt_string="Create the new dataset in which organization?",
client=client,
)
body: dict[str, Any] = {"name": name}
if description:
body["description"] = description
Expand Down Expand Up @@ -112,6 +114,9 @@ def deleteDataset(
)


_MAX_PAGES = 1000


@_auto_client
def listDatasets(
org_id: str | None = None,
Expand All @@ -122,39 +127,77 @@ def listDatasets(
) -> dict[str, Any]:
"""GET /organizations/{organizationId}/datasets?page=&pageSize=

If *org_id* is omitted it is resolved from the client's config
(populated automatically during login), matching MATLAB behaviour.
"""
org_id = _resolve_org_id(org_id, client)
return client.get(
"/organizations/{organizationId}/datasets",
params={"page": page, "pageSize": page_size},
organizationId=org_id,
)
MATLAB equivalent: ndi.cloud.api.datasets.listDatasets

With *org_id*, retrieves one page of the datasets within that
organization.

_MAX_PAGES = 1000


@_auto_client
def listAllDatasets(org_id: str | None = None, *, client: _Client = None) -> APIResponse:
"""Auto-paginate through all datasets for an organisation.

If *org_id* is omitted it is resolved from the client's config
(populated automatically during login), matching MATLAB behaviour.
Without *org_id*, retrieves ALL datasets in ALL of the organizations the
current user belongs to (:func:`ndi.cloud.internal.getOrganizations`).
NDI Cloud only lists datasets per organization, so this queries each
organization in turn and fetches every page; *page* and *page_size* are
ignored in this mode. Each returned dataset is tagged with the keys
``organizationId`` and ``organizationName`` of the organization it was
listed under, and the answer's ``page`` is 1 while ``pageSize`` and
``totalNumber`` are the number of datasets returned.
"""
org_id = _resolve_org_id(org_id, client)
if org_id:
return client.get(
"/organizations/{organizationId}/datasets",
params={"page": page, "pageSize": page_size},
organizationId=org_id,
)

from ..internal import getOrganizations

datasets: list[dict[str, Any]] = []
for organization in getOrganizations(client=client):
for dataset in _listOrganizationDatasets(organization["id"], client):
tagged = dict(dataset)
tagged.setdefault("organizationId", organization["id"])
tagged["organizationName"] = organization["name"]
datasets.append(tagged)
answer = {
"totalNumber": len(datasets),
"page": 1,
"pageSize": len(datasets),
"datasets": datasets,
}
return APIResponse(answer, success=True, status_code=200, url="")


def _listOrganizationDatasets(org_id: str, client: CloudClient) -> list[dict[str, Any]]:
"""Every dataset of one organization, fetching page after page."""
all_datasets: list[dict[str, Any]] = []
page = 1
while page <= _MAX_PAGES:
result = listDatasets(org_id, page=page, client=client)
datasets = result.get("datasets", [])
datasets = result.get("datasets", []) or []
all_datasets.extend(datasets)
total = result.get("totalNumber", 0)
if len(all_datasets) >= total or not datasets:
break
page += 1
return APIResponse(all_datasets, success=True, status_code=200, url="")
return all_datasets


@_auto_client
def listAllDatasets(org_id: str | None = None, *, client: _Client = None) -> APIResponse:
"""Auto-paginate through all datasets for an organisation.

Without *org_id*, lists every dataset in every organization the user
belongs to, tagged as :func:`listDatasets` does.
"""
if not org_id:
return APIResponse(
listDatasets(client=client).get("datasets", []),
success=True,
status_code=200,
url="",
)
return APIResponse(
_listOrganizationDatasets(org_id, client), success=True, status_code=200, url=""
)


@_auto_client
Expand Down
30 changes: 20 additions & 10 deletions src/ndi/cloud/api/ndi_matlab_python_bridge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ infrastructure:
Python-only Pydantic validators (CloudId, NonEmptyStr, PageNumber,
PageSize, Scope, FilePath). MATLAB uses arguments blocks for validation.

- name: _resolve_org_id
type: internal_helper
python_path: "ndi/cloud/api/datasets.py"
decision_log: "Python-only helper to resolve org_id from client config."

- name: _coerce_search_structure
type: internal_helper
python_path: "ndi/cloud/api/documents.py"
Expand Down Expand Up @@ -71,7 +66,7 @@ functions:

- name: createDataset
matlab_path: "+ndi/+cloud/+api/+datasets/createDataset.m"
matlab_last_sync_hash: "9b75c0fe"
matlab_last_sync_hash: "293ec411"
python_path: "ndi/cloud/api/datasets.py"
input_arguments:
- name: org_id
Expand All @@ -86,13 +81,22 @@ functions:
type_matlab: "char"
type_python: "str"
default: "''"
- name: organization_name
type_matlab: "char"
type_python: "str"
default: "''"
- name: client
type_python: "_Client"
default: "None"
output_arguments:
- name: dataset
type_python: "dict[str, Any]"
decision_log: "Exact match."
decision_log: >
2026-10-18: synchronized with NDI-matlab 293ec411 (VH-Lab/NDI-matlab#1037). The organization is
named with org_id (MATLAB's organizationID) or organization_name, else
chosen by chooseOrganization with RequireUpload -- never taken from the
client config, which no longer holds one. The Python-only
_resolve_org_id helper that read it is gone.

- name: updateDataset
matlab_path: "+ndi/+cloud/+api/+datasets/updateDataset.m"
Expand Down Expand Up @@ -134,7 +138,7 @@ functions:

- name: listDatasets
matlab_path: "+ndi/+cloud/+api/+datasets/listDatasets.m"
matlab_last_sync_hash: "587ebc54"
matlab_last_sync_hash: "293ec411"
python_path: "ndi/cloud/api/datasets.py"
input_arguments:
- name: org_id
Expand All @@ -155,7 +159,12 @@ functions:
output_arguments:
- name: result
type_python: "dict[str, Any]"
decision_log: "Exact match."
decision_log: >
2026-10-18: synchronized with NDI-matlab 293ec411 (VH-Lab/NDI-matlab#1037). With org_id, one page
of that organization, as before. Without, every page of every
organization the user belongs to, each dataset tagged with
organizationId and organizationName (NDI Cloud only lists per
organization, so this loops client-side, as in MATLAB).

- name: listAllDatasets
matlab_path: "N/A"
Expand All @@ -172,7 +181,8 @@ functions:
type_python: "APIResponse"
decision_log: >
Python-only auto-paginator that wraps listDatasets.
No direct MATLAB equivalent.
No direct MATLAB equivalent. Without org_id it lists every
organization the user belongs to, as listDatasets does.

- name: getPublished
matlab_path: "+ndi/+cloud/+api/+datasets/getPublished.m"
Expand Down
Loading
Loading