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
288 changes: 156 additions & 132 deletions MATLAB_MAPPING.md

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions PYTHON_PORTING_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# MATLAB to Python Porting Rules

## 1. The Core Philosophy: Lead-Follow Architecture

The MATLAB codebase is the **Source of Truth**. The Python version is a "faithful mirror." When a conflict arises between "Pythonic" style and MATLAB symmetry, **symmetry wins**.

## 2. Function & Variable Naming (The "Strict Mirror" Rule)

Do **not** attempt to "translate" MATLAB names into Python PEP 8 (`snake_case`).

- **Exact Match:** Every function name in Python must be an identical string match to the MATLAB function name.
- **Case Sensitivity:** If the MATLAB function is `ListAllDocuments`, the Python function must be `ListAllDocuments`. If the MATLAB function is `get_dataset_id`, the Python function must be `get_dataset_id`.
- **No Aliasing:** Do not create `snake_case` aliases unless explicitly requested. The user should be able to copy-paste function names between environments.

## 3. Namespace and Directory Structure

MATLAB namespaces (`+` packages) must be mapped 1:1 to Python packages and modules to ensure discoverability.

- **Hierarchy:** Every MATLAB `+namespace` folder must become a Python directory containing an `__init__.py`.
- **File Mapping:** If a MATLAB function exists as `+ndi/+fun/+X/Y.m`, the Python equivalent must be located at `ndi/fun/X/Y.py`.
- **Sub-modules:** For functions inside a namespace that aren't in their own file, group them into a `.py` file named after the MATLAB namespace level.

## 4. Input Validation: Pydantic is Mandatory

To replicate the robustness of the MATLAB `arguments` block, use Pydantic for all public-facing API functions.

- **Decorator:** Use the `@pydantic.validate_call` decorator on all functions.
- **Type Mirroring:**
- MATLAB `double` or `numeric` → Python `float` or `int`
- MATLAB `char` or `string` → Python `str`
- MATLAB `{member1, member2}` → Python `Literal["member1", "member2"]`
- **Coercion:** Allow Pydantic's default behavior of casting (e.g., allowing a string `"1"` or integer `1` to satisfy a `bool` type).

## 5. Error Handling

- If a MATLAB function throws an error for a specific condition, the Python version must raise a corresponding Exception (`ValueError`, `TypeError`, or a custom `NDIError`).
- The goal is to ensure that a user providing bad input gets a **"Hard Fail"** at the function entry point in both languages.

## 6. Documentation (Docstring Symmetry)

- Include the original MATLAB documentation in the Python docstring.
- Note any Python-specific requirements (like specific library dependencies) at the bottom of the docstring.
42 changes: 18 additions & 24 deletions src/ndi/cloud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@

config = login('user@example.com', 'password')
client = CloudClient(config)
ndi.cloud.api.datasets.get_dataset(dataset_id, client=client)
ndi.cloud.api.datasets.getDataset(dataset_id, client=client)

# Option 2: Auto-client from environment variables (no client needed)
# Set NDI_CLOUD_USERNAME, NDI_CLOUD_PASSWORD (or NDI_CLOUD_TOKEN)
ndi.cloud.api.datasets.get_dataset(dataset_id)
ndi.cloud.api.datasets.getDataset(dataset_id)

All ``ndi.cloud.api.*`` functions accept an optional ``client`` keyword
parameter. If omitted, a client is built automatically from environment
Expand All @@ -28,12 +28,12 @@

from .auth import (
authenticate,
change_password,
changePassword,
login,
logout,
resend_confirmation,
reset_password,
verify_user,
resendConfirmation,
resetPassword,
verifyUser,
)
from .config import CloudConfig
from .exceptions import (
Expand All @@ -56,15 +56,15 @@
"authenticate",
"login",
"logout",
"change_password",
"reset_password",
"verify_user",
"resend_confirmation",
"changePassword",
"resetPassword",
"verifyUser",
"resendConfirmation",
# Top-level convenience functions (mirror MATLAB ndi.cloud.*)
"download_dataset",
"upload_dataset",
"sync_dataset",
"upload_single_file",
"downloadDataset",
"uploadDataset",
"syncDataset",
"uploadSingleFile",
"fetch_cloud_file",
]

Expand All @@ -74,19 +74,13 @@
# when requests is not installed.

_LAZY_IMPORTS = {
# Python-style (primary)
"APIResponse": ("client", "APIResponse"),
"CloudClient": ("client", "CloudClient"),
"download_dataset": ("orchestration", "download_dataset"),
"upload_dataset": ("orchestration", "upload_dataset"),
"sync_dataset": ("orchestration", "sync_dataset"),
"upload_single_file": ("upload", "upload_single_file"),
"downloadDataset": ("orchestration", "downloadDataset"),
"uploadDataset": ("orchestration", "uploadDataset"),
"syncDataset": ("orchestration", "syncDataset"),
"uploadSingleFile": ("upload", "uploadSingleFile"),
"fetch_cloud_file": ("filehandler", "fetch_cloud_file"),
# MATLAB-style aliases (for users migrating from MATLAB)
"downloadDataset": ("orchestration", "download_dataset"),
"uploadDataset": ("orchestration", "upload_dataset"),
"syncDataset": ("orchestration", "sync_dataset"),
"uploadSingleFile": ("upload", "upload_single_file"),
}


Expand Down
24 changes: 12 additions & 12 deletions src/ndi/cloud/admin/crossref.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class CrossrefConstants:
CONSTANTS = CrossrefConstants()


def create_batch_submission(
def createDoiBatchSubmission(
dataset_metadata: dict[str, Any],
doi: str,
) -> str:
Expand Down Expand Up @@ -114,7 +114,7 @@ def create_batch_submission(
return tostring(root, encoding="unicode", xml_declaration=True)


def convert_to_crossref(dataset_metadata: dict[str, Any]) -> dict[str, Any]:
def convertCloudDatasetToCrossrefDataset(dataset_metadata: dict[str, Any]) -> dict[str, Any]:
"""Convert NDI dataset metadata to Crossref-compatible format.

Args:
Expand All @@ -126,16 +126,16 @@ def convert_to_crossref(dataset_metadata: dict[str, Any]) -> dict[str, Any]:
return {
"title": dataset_metadata.get("name", ""),
"description": dataset_metadata.get("description", ""),
"contributors": convert_contributors(dataset_metadata),
"contributors": convertContributors(dataset_metadata),
"doi_prefix": CONSTANTS.DOI_PREFIX,
"database_title": CONSTANTS.DATABASE_TITLE,
"resource_url": (
f"{CONSTANTS.DATASET_BASE_URL}" f"{dataset_metadata.get('cloud_dataset_id', '')}"
),
"date": convert_dataset_date(dataset_metadata),
"funding": convert_funding(dataset_metadata),
"license": convert_license(dataset_metadata),
"related_publications": convert_related_publications(dataset_metadata),
"date": convertDatasetDate(dataset_metadata),
"funding": convertFunding(dataset_metadata),
"license": convertLicense(dataset_metadata),
"related_publications": convertRelatedPublications(dataset_metadata),
}


Expand All @@ -144,7 +144,7 @@ def convert_to_crossref(dataset_metadata: dict[str, Any]) -> dict[str, Any]:
# ---------------------------------------------------------------------------


def convert_contributors(
def convertContributors(
dataset_metadata: dict[str, Any],
) -> list[dict[str, Any]]:
"""Convert contributor list to Crossref PersonName format.
Expand Down Expand Up @@ -177,7 +177,7 @@ def convert_contributors(
return result


def convert_dataset_date(
def convertDatasetDate(
dataset_metadata: dict[str, Any],
) -> dict[str, str]:
"""Convert dataset timestamps to Crossref date format.
Expand Down Expand Up @@ -205,7 +205,7 @@ def _parse_date(ts: str) -> dict[str, str]:
}


def convert_funding(
def convertFunding(
dataset_metadata: dict[str, Any],
) -> list[dict[str, str]]:
"""Convert funding information to Crossref FrProgram format.
Expand All @@ -222,7 +222,7 @@ def convert_funding(
]


def convert_license(
def convertLicense(
dataset_metadata: dict[str, Any],
) -> dict[str, str]:
"""Convert license information to Crossref AiProgram format.
Expand Down Expand Up @@ -260,7 +260,7 @@ def convert_license(
return {"name": normalized or name, "url": url} if name else {}


def convert_related_publications(
def convertRelatedPublications(
dataset_metadata: dict[str, Any],
) -> list[dict[str, Any]]:
"""Convert associated publications to Crossref RelProgram format.
Expand Down
14 changes: 7 additions & 7 deletions src/ndi/cloud/admin/doi.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
from typing import TYPE_CHECKING, Any

from ..client import _auto_client
from .crossref import CONSTANTS, create_batch_submission
from .crossref import CONSTANTS, createDoiBatchSubmission

if TYPE_CHECKING:
from ..client import CloudClient


def create_new_doi(prefix: str = "") -> str:
def createNewDOI(prefix: str = "") -> str:
"""Generate a new unique DOI string.

Args:
Expand All @@ -34,7 +34,7 @@ def create_new_doi(prefix: str = "") -> str:


@_auto_client
def register_dataset_doi(
def registerDatasetDOI(
cloud_dataset_id: str,
use_test: bool = False,
*,
Expand Down Expand Up @@ -62,14 +62,14 @@ def register_dataset_doi(
from ..exceptions import CloudError

# Fetch metadata
metadata = ds_api.get_dataset(cloud_dataset_id, client=client)
metadata = ds_api.getDataset(cloud_dataset_id, client=client)
metadata["cloud_dataset_id"] = cloud_dataset_id

# Generate DOI
doi = create_new_doi()
doi = createNewDOI()

# Build XML
xml = create_batch_submission(metadata, doi)
xml = createDoiBatchSubmission(metadata, doi)

# Submit to Crossref
deposit_url = CONSTANTS.TEST_DEPOSIT_URL if use_test else CONSTANTS.DEPOSIT_URL
Expand Down Expand Up @@ -102,7 +102,7 @@ def register_dataset_doi(
raise CloudError(f"Crossref submission failed: {exc}") from exc


def check_submission(
def checkSubmission(
filename: str,
data_type: str = "result",
use_test: bool = False,
Expand Down
12 changes: 6 additions & 6 deletions src/ndi/cloud/api/compute.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

@_auto_client
@validate_call(config=VALIDATE_CONFIG)
def start_session(
def startSession(
pipeline_id: NonEmptyStr,
input_params: dict[str, Any] | None = None,
*,
Expand All @@ -36,14 +36,14 @@ def start_session(

@_auto_client
@validate_call(config=VALIDATE_CONFIG)
def get_session_status(session_id: NonEmptyStr, *, client: _Client = None) -> dict[str, Any]:
def getSessionStatus(session_id: NonEmptyStr, *, client: _Client = None) -> dict[str, Any]:
"""GET /compute/{sessionId} -- Get session status."""
return client.get("/compute/{sessionId}", sessionId=session_id)


@_auto_client
@validate_call(config=VALIDATE_CONFIG)
def trigger_stage(
def triggerStage(
session_id: NonEmptyStr,
stage_id: NonEmptyStr,
*,
Expand All @@ -59,7 +59,7 @@ def trigger_stage(

@_auto_client
@validate_call(config=VALIDATE_CONFIG)
def finalize_session(session_id: NonEmptyStr, *, client: _Client = None) -> dict[str, Any]:
def finalizeSession(session_id: NonEmptyStr, *, client: _Client = None) -> dict[str, Any]:
"""POST /compute/{sessionId}/finalize"""
return client.post(
"/compute/{sessionId}/finalize",
Expand All @@ -69,14 +69,14 @@ def finalize_session(session_id: NonEmptyStr, *, client: _Client = None) -> dict

@_auto_client
@validate_call(config=VALIDATE_CONFIG)
def abort_session(session_id: NonEmptyStr, *, client: _Client = None) -> bool:
def abortSession(session_id: NonEmptyStr, *, client: _Client = None) -> bool:
"""POST /compute/{sessionId}/abort"""
client.post("/compute/{sessionId}/abort", sessionId=session_id)
return True


@_auto_client
def list_sessions(*, client: _Client = None) -> APIResponse:
def listSessions(*, client: _Client = None) -> APIResponse:
"""GET /compute -- List all compute sessions."""
result = client.get("/compute")
# Handle both APIResponse (has .data) and raw dict/list from mocks
Expand Down
Loading
Loading