diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d592d1..160d801e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## lifebit-ai/cloudos-cli: changelog +## v2.79.0 (2026-01-26) + +### Feat + +- Adds `--api-docs` flag to `cloudos datasets ls` command to document API usage + ## v2.78.0 (2026-01-13) ### Feat diff --git a/cloudos_cli/__main__.py b/cloudos_cli/__main__.py index 1eab8bfd..595c9b3b 100644 --- a/cloudos_cli/__main__.py +++ b/cloudos_cli/__main__.py @@ -3195,6 +3195,9 @@ def run_bash_array_job(ctx, 'Details contains "Type", "Owner", "Size", "Last Updated", ' + '"Virtual Name", "Storage Path".'), is_flag=True) +@click.option('--api-docs', + help='Display the CloudOS API endpoints called by this command with curl examples.', + is_flag=True) @click.option('--output-format', help=('The desired display for the output, either directly in standard output or saved as file. ' + 'Default=stdout.'), @@ -3217,21 +3220,40 @@ def list_files(ctx, profile, path, details, + api_docs, output_format, output_basename): - """List contents of a path within a CloudOS workspace dataset.""" + """Lists the dataset information within the CloudOS platform. + + Examples: + cloudos datasets ls --project-name my-project + cloudos datasets ls --project-name my-project --path Data + cloudos datasets ls --project-name my-project --path Data/results --details + cloudos datasets ls --project-name my-project --api-docs + """ verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) - datasets = Datasets( - cloudos_url=cloudos_url, - apikey=apikey, - workspace_id=workspace_id, - project_name=project_name, - verify=verify_ssl, - cromwell_token=None - ) + # Initialize API call tracker if --api-docs is enabled + from cloudos_cli.datasets.datasets import APICallTracker + tracker = None + if api_docs: + tracker = APICallTracker( + cloudos_url=cloudos_url, + workspace_id=workspace_id, + verify=verify_ssl + ) try: + datasets = Datasets( + cloudos_url=cloudos_url, + apikey=apikey, + workspace_id=workspace_id, + project_name=project_name, + verify=verify_ssl, + cromwell_token=None, + api_docs_tracker=tracker + ) + result = datasets.list_folder_content(path) contents = result.get("contents") or result.get("datasets", []) @@ -3262,7 +3284,7 @@ def list_files(ctx, type_ = "file (user uploaded)" else: type_ = "file (virtual copy)" - + user = item.get("user", {}) if isinstance(user, dict): name = user.get("name", "").strip() @@ -3373,6 +3395,10 @@ def list_files(ctx, else: console.print(item['name']) + # Display API documentation if requested + if api_docs and tracker: + click.echo(tracker.get_documentation()) + except Exception as e: raise ValueError(f"Failed to list files for project '{project_name}'. {str(e)}") @@ -4364,4 +4390,4 @@ def link_command(ctx, else: logger.error(e) click.echo(click.style(f"Error: {e}", fg='red'), err=True) - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/cloudos_cli/_version.py b/cloudos_cli/_version.py index f705aee1..3126b4f0 100644 --- a/cloudos_cli/_version.py +++ b/cloudos_cli/_version.py @@ -1 +1 @@ -__version__ = '2.78.0' +__version__ = '2.79.0' diff --git a/cloudos_cli/datasets/datasets.py b/cloudos_cli/datasets/datasets.py index 6fd87959..edd30877 100644 --- a/cloudos_cli/datasets/datasets.py +++ b/cloudos_cli/datasets/datasets.py @@ -3,13 +3,105 @@ """ from dataclasses import dataclass -from typing import Union +from typing import Union, Optional, List, Dict, Any from cloudos_cli.clos import Cloudos from cloudos_cli.utils.errors import BadRequestException from cloudos_cli.utils.requests import retry_requests_get, retry_requests_put, retry_requests_post, retry_requests_delete import json +class APICallTracker: + """Tracks API calls for documentation purposes.""" + + def __init__(self, cloudos_url: str, workspace_id: str, verify: Union[bool, str]): + self.calls: List[Dict[str, Any]] = [] + self.cloudos_url = cloudos_url + self.workspace_id = workspace_id + self.verify = verify + self.project_id: Optional[str] = None + self.project_name: Optional[str] = None + + def track(self, method: str, url: str, purpose: str, extraction_hint: str = ""): + """Track an API call. + + Parameters + ---------- + method : str + HTTP method (GET, POST, PUT, DELETE) + url : str + Full URL of the API endpoint + purpose : str + Human-readable description of what this call does + extraction_hint : str, optional + Instructions on how to extract data from the response for next calls + """ + self.calls.append({ + 'method': method, + 'url': url, + 'purpose': purpose, + 'extraction_hint': extraction_hint + }) + + def get_documentation(self) -> str: + """Generate curl-based API documentation. + + Returns + ------- + str + Formatted API documentation + """ + if not self.calls: + return "No API calls were made." + + # Build requirements section + doc_lines = [] + doc_lines.append("\n" + "="*80) + doc_lines.append("Platform API Instructions") + doc_lines.append("="*80) + doc_lines.append("\n### Requirements") + doc_lines.append(f"workspace-id = {self.workspace_id}") + if self.project_name: + doc_lines.append(f"project-name = {self.project_name}") + doc_lines.append("apikey = ") + + # SSL verification note + if isinstance(self.verify, str): + doc_lines.append(f"ssl-cert = {self.verify}") + elif self.verify is False: + doc_lines.append("ssl-verification = disabled") + + # Build endpoints section + doc_lines.append("\n### Used Endpoints") + for i, call in enumerate(self.calls, 1): + doc_lines.append(f"\n{i}. {call['purpose']}") + # Build curl command + curl_parts = ["curl -X", call['method']] + # Add SSL flag if needed + if isinstance(self.verify, str): + curl_parts.append(f"--cacert {self.verify}") + elif self.verify is False: + curl_parts.append("-k") + # Add headers + curl_parts.append('-H "Content-type: application/json"') + curl_parts.append('-H "apikey: "') + # Add URL + curl_parts.append(f'"{call["url"]}"') + doc_lines.append(" " + " ".join(curl_parts)) + + # Build usage instructions section + doc_lines.append("\n### How to Use Them") + doc_lines.append("\nExecute the curl commands in sequence:") + for i, call in enumerate(self.calls, 1): + if call['extraction_hint']: + doc_lines.append(f"\n{i}. {call['purpose']}") + doc_lines.append(f" {call['extraction_hint']}") + if len(self.calls) == 1 and not self.calls[0]['extraction_hint']: + doc_lines.append("\nExecute the curl command above. The response will contain the list of datasets.") + doc_lines.append("\n" + "="*80 + "\n") + + return "\n".join(doc_lines) + + @dataclass class Datasets(Cloudos): """Class for file explorer. @@ -30,12 +122,23 @@ class Datasets(Cloudos): the SSL certificate file. project_id : string The CloudOS project id for a given project name. + api_docs_tracker : APICallTracker, optional + Tracker for API calls when generating documentation. """ workspace_id: str project_name: str verify: Union[bool, str] = True + api_docs_tracker: Optional[APICallTracker] = None project_id: str = None + def __post_init__(self): + """Post-initialization to set up tracker with project details.""" + # Ensure tracker has project_name and project_id if it exists + if self.api_docs_tracker: + self.api_docs_tracker.project_name = self.project_name + if self.project_id: + self.api_docs_tracker.project_id = self.project_id + @property def project_id(self) -> str: return self._project_id @@ -51,6 +154,11 @@ def project_id(self, v) -> None: else: # Let the user define the value. self._project_id = v + # Update tracker if present (use getattr to avoid AttributeError during initialization) + tracker = getattr(self, 'api_docs_tracker', None) + if tracker and v: + tracker.project_id = v + tracker.project_name = self.project_name def fetch_project_id(self, workspace_id, @@ -74,7 +182,24 @@ def fetch_project_id(self, project_id : string The CloudOS project id for a given project name. """ - return self.get_project_id_from_name(workspace_id, project_name, verify=verify) + # Use getattr to avoid AttributeError if called during initialization + tracker = getattr(self, 'api_docs_tracker', None) + if tracker: + url = f"{self.cloudos_url}/api/v2/projects?teamId={workspace_id}&search={project_name}" + tracker.track( + method="GET", + url=url, + purpose="Resolve project name to project ID", + extraction_hint=f"Extract the '_id' field from the project object where 'name' equals '{project_name}' in the response. Use: jq '.projects[] | select(.name==\"{project_name}\") | ._id'" + ) + + project_id = self.get_project_id_from_name(workspace_id, project_name, verify=verify) + + if tracker: + tracker.project_id = project_id + tracker.project_name = project_name + + return project_id def list_project_content(self): """ @@ -91,14 +216,23 @@ def list_project_content(self): project_id The specific project id """ + url = "{}/api/v2/datasets?projectId={}&teamId={}".format(self.cloudos_url, + self.project_id, + self.workspace_id) + + if self.api_docs_tracker: + self.api_docs_tracker.track( + method="GET", + url=url, + purpose=f"List all top-level datasets in the project '{self.project_name}' (project_id: '{self.project_id}'.", + extraction_hint="The response contains a 'datasets' array with all top-level datasets. Each dataset has '_id', 'name', and other metadata fields. To navigate deeper, extract the '_id' of the desired dataset." + ) + headers = { "Content-type": "application/json", "apikey": self.apikey } - r = retry_requests_get("{}/api/v2/datasets?projectId={}&teamId={}".format(self.cloudos_url, - self.project_id, - self.workspace_id), - headers=headers, verify=self.verify) + r = retry_requests_get(url, headers=headers, verify=self.verify) if r.status_code >= 400: raise BadRequestException(r) raw = r.json() @@ -142,10 +276,20 @@ def list_datasets_content(self, folder_name): folder_id = folder['_id'] if not folder_id: raise ValueError(f"Folder '{folder_name}' not found in project '{self.project_name}'.") - r = retry_requests_get("{}/api/v1/datasets/{}/items?teamId={}".format(self.cloudos_url, - folder_id, - self.workspace_id), - headers=headers, verify=self.verify) + + url = "{}/api/v1/datasets/{}/items?teamId={}".format(self.cloudos_url, + folder_id, + self.workspace_id) + + if self.api_docs_tracker: + self.api_docs_tracker.track( + method="GET", + url=url, + purpose=f"List contents of dataset '{folder_name}' (dataset_id: {folder_id})", + extraction_hint="The response contains 'folders' and 'files' arrays. Folders have '_id', 'name', 'folderType' fields. Files have metadata like 'name', 'sizeInBytes', 'updatedAt'. For deeper navigation, use the folder's '_id' or inspect 'folderType' to determine the next API call." + ) + + r = retry_requests_get(url, headers=headers, verify=self.verify) if r.status_code >= 400: raise BadRequestException(r) return r.json() @@ -172,11 +316,20 @@ def list_s3_folder_content(self, s3_bucket_name, s3_relative_path): "apikey": self.apikey } - r = retry_requests_get("{}/api/v1/data-access/s3/bucket-contents?bucket={}&path={}&teamId={}".format(self.cloudos_url, - s3_bucket_name, - s3_relative_path, - self.workspace_id), - headers=headers, verify=self.verify) + url = "{}/api/v1/data-access/s3/bucket-contents?bucket={}&path={}&teamId={}".format(self.cloudos_url, + s3_bucket_name, + s3_relative_path, + self.workspace_id) + + if self.api_docs_tracker: + self.api_docs_tracker.track( + method="GET", + url=url, + purpose=f"List S3 folder contents (bucket: {s3_bucket_name}, path: {s3_relative_path})", + extraction_hint="The response contains a 'contents' array with objects having 'name', 'path', 'isDir', and 'size' fields. Items where 'isDir' is true are folders; use their 'path' for further navigation." + ) + + r = retry_requests_get(url, headers=headers, verify=self.verify) if r.status_code >= 400: raise BadRequestException(r) raw = r.json() @@ -216,10 +369,19 @@ def list_virtual_folder_content(self, folder_id): "apikey": self.apikey } - r = retry_requests_get("{}/api/v1/folders/virtual/{}/items?teamId={}".format(self.cloudos_url, - folder_id, - self.workspace_id), - headers=headers, verify=self.verify) + url = "{}/api/v1/folders/virtual/{}/items?teamId={}".format(self.cloudos_url, + folder_id, + self.workspace_id) + + if self.api_docs_tracker: + self.api_docs_tracker.track( + method="GET", + url=url, + purpose=f"List virtual folder contents (folder_id: {folder_id})", + extraction_hint="The response contains 'folders' and 'files' arrays similar to dataset contents. Use folder '_id' and 'folderType' to navigate deeper into the structure." + ) + + r = retry_requests_get(url, headers=headers, verify=self.verify) if r.status_code >= 400: raise BadRequestException(r) return r.json() @@ -237,6 +399,14 @@ def list_azure_container_content(self, container_name: str, storage_account_name url += f"?containerName={container_name}&storageAccountName={storage_account_name}" url += f"&path={path}&teamId={self.workspace_id}" + if self.api_docs_tracker: + self.api_docs_tracker.track( + method="GET", + url=url, + purpose=f"List Azure Blob container contents (container: {container_name}, account: {storage_account_name}, path: {path})", + extraction_hint="The response contains a 'contents' array with objects having 'name', 'path', 'isDir', 'size', and 'lastModified' fields. Items where 'isDir' is true are folders." + ) + r = retry_requests_get(url, headers=headers, verify=self.verify) if r.status_code >= 400: raise BadRequestException(r) @@ -362,7 +532,6 @@ def list_folder_content(self, path=None): "files": [file_item], "folders": [] } - # Also check in contents array (for different API response formats) for item in folder_content.get("contents", []): if item["name"] == job_name and not item.get("isDir", True): diff --git a/tests/test_datasets/test_api_docs.py b/tests/test_datasets/test_api_docs.py new file mode 100644 index 00000000..1f6ced4f --- /dev/null +++ b/tests/test_datasets/test_api_docs.py @@ -0,0 +1,260 @@ +import json +import responses +from cloudos_cli.datasets import Datasets +from cloudos_cli.datasets.datasets import APICallTracker +from tests.functions_for_pytest import load_json_file + +# Constants +APIKEY = 'vnoiweur89u2ongs' +CLOUDOS_URL = 'http://cloudos.lifebit.ai' +WORKSPACE_ID = 'lv89ufc838sdig' +PROJECT_NAME = "lifebit-testing" +DATASET_NAME = 'Analyses Results' +FOLDER_PATH = 'AnalysesResults' + +# Files +INPUT_PROJECTS = "tests/test_data/projects.json" +INPUT_DATASETS = "tests/test_data/datasets.json" +INPUT_DATASET_CONTENT = "tests/test_data/dataset_folder_results.json" + + +@responses.activate +def test_api_call_tracker_captures_calls(): + """Test that APICallTracker captures API calls made during dataset listing.""" + # Load mocked data + mock_projects = json.loads(load_json_file(INPUT_PROJECTS)) + mock_datasets = json.loads(load_json_file(INPUT_DATASETS)) + mock_dataset_contents = json.loads(load_json_file(INPUT_DATASET_CONTENT)) + + # Extract IDs + project_id = next((p["_id"] for p in mock_projects["projects"] if p["name"] == PROJECT_NAME), None) + dataset_id = next((d["_id"] for d in mock_datasets["datasets"] if d["name"] == DATASET_NAME), None) + + # Mock endpoints + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v2/projects", + body=json.dumps(mock_projects), + status=200 + ) + + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v2/datasets", + body=json.dumps(mock_datasets), + status=200 + ) + + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v1/datasets/{dataset_id}/items", + body=json.dumps(mock_dataset_contents), + status=200 + ) + + # Create tracker + tracker = APICallTracker( + cloudos_url=CLOUDOS_URL, + workspace_id=WORKSPACE_ID, + verify=True + ) + + # Instantiate Datasets with tracker + datasets = Datasets( + cloudos_url=CLOUDOS_URL, + apikey=APIKEY, + workspace_id=WORKSPACE_ID, + project_name=PROJECT_NAME, + verify=True, + cromwell_token=None, + api_docs_tracker=tracker, + project_id=project_id # Pass project_id to avoid fetch call + ) + + # Execute the listing + datasets.list_folder_content(FOLDER_PATH) + + # Assertions on tracker + assert len(tracker.calls) > 0, "Tracker should have captured API calls" + assert tracker.project_id == project_id, "Tracker should have stored project_id" + assert tracker.project_name == PROJECT_NAME, "Tracker should have stored project_name" + + # Check that first call is for list datasets (not projects, since we passed project_id) + first_call = tracker.calls[0] + assert first_call['method'] == 'GET' + assert 'datasets' in first_call['url'] + assert 'List all top-level datasets' in first_call['purpose'] + + # Check that subsequent calls exist (should have dataset items call) + assert len(tracker.calls) >= 2, "Should have at least 2 API calls (datasets + items)" + + +@responses.activate +def test_api_documentation_generation(): + """Test that API documentation is properly generated from tracked calls.""" + # Load mocked data + mock_projects = json.loads(load_json_file(INPUT_PROJECTS)) + mock_datasets = json.loads(load_json_file(INPUT_DATASETS)) + + # Extract IDs + project_id = next((p["_id"] for p in mock_projects["projects"] if p["name"] == PROJECT_NAME), None) + + # Mock endpoints with exact URLs + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v2/projects?teamId={WORKSPACE_ID}&search={PROJECT_NAME}", + body=json.dumps(mock_projects), + status=200 + ) + + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v2/datasets?projectId={project_id}&teamId={WORKSPACE_ID}", + body=json.dumps(mock_datasets), + status=200 + ) + + # Create tracker + tracker = APICallTracker( + cloudos_url=CLOUDOS_URL, + workspace_id=WORKSPACE_ID, + verify=True + ) + + # Instantiate Datasets with tracker + # Don't pass project_id to trigger the project resolution API call + datasets = Datasets( + cloudos_url=CLOUDOS_URL, + apikey=APIKEY, + workspace_id=WORKSPACE_ID, + project_name=PROJECT_NAME, + verify=True, + cromwell_token=None, + api_docs_tracker=tracker + ) + + # Execute the listing (just top level) + datasets.list_folder_content(None) + + # Generate documentation + docs = tracker.get_documentation() + + # Assertions on documentation content + assert "Platform API Instructions" in docs + assert "Requirements" in docs + assert "Used Endpoints" in docs + assert "How to Use Them" in docs + assert f"workspace-id = {WORKSPACE_ID}" in docs + assert f"project-name = {PROJECT_NAME}" in docs + # project-id should NOT be in requirements since it's derived from the API call + assert "project-id" not in docs or "project-id" not in docs.split("### Used Endpoints")[0] + assert "apikey = " in docs + assert "curl -X GET" in docs + assert CLOUDOS_URL in docs + assert "" in docs, "API key should be masked in documentation" + # Should have at least datasets listing (project resolution may or may not be present) + assert "List all top-level datasets" in docs + + +@responses.activate +def test_api_docs_with_ssl_disabled(): + """Test that API documentation includes -k flag when SSL verification is disabled.""" + # Load mocked data + mock_projects = json.loads(load_json_file(INPUT_PROJECTS)) + mock_datasets = json.loads(load_json_file(INPUT_DATASETS)) + + # Mock endpoints + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v2/projects", + body=json.dumps(mock_projects), + status=200 + ) + + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v2/datasets", + body=json.dumps(mock_datasets), + status=200 + ) + + # Create tracker with SSL disabled + tracker = APICallTracker( + cloudos_url=CLOUDOS_URL, + workspace_id=WORKSPACE_ID, + verify=False + ) + + # Instantiate Datasets with tracker + datasets = Datasets( + cloudos_url=CLOUDOS_URL, + apikey=APIKEY, + workspace_id=WORKSPACE_ID, + project_name=PROJECT_NAME, + verify=False, + cromwell_token=None, + api_docs_tracker=tracker + ) + + # Execute the listing + datasets.list_folder_content(None) + + # Generate documentation + docs = tracker.get_documentation() + + # Assertions + assert "ssl-verification = disabled" in docs + assert "curl -X GET -k" in docs, "Documentation should include -k flag for disabled SSL verification" + + +@responses.activate +def test_api_docs_with_ssl_cert(): + """Test that API documentation includes --cacert flag when SSL cert is provided.""" + # Load mocked data + mock_projects = json.loads(load_json_file(INPUT_PROJECTS)) + mock_datasets = json.loads(load_json_file(INPUT_DATASETS)) + + cert_path = "/path/to/cert.pem" + + # Mock endpoints + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v2/projects", + body=json.dumps(mock_projects), + status=200 + ) + + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v2/datasets", + body=json.dumps(mock_datasets), + status=200 + ) + + # Create tracker with SSL cert + tracker = APICallTracker( + cloudos_url=CLOUDOS_URL, + workspace_id=WORKSPACE_ID, + verify=cert_path + ) + + # Instantiate Datasets with tracker + datasets = Datasets( + cloudos_url=CLOUDOS_URL, + apikey=APIKEY, + workspace_id=WORKSPACE_ID, + project_name=PROJECT_NAME, + verify=cert_path, + cromwell_token=None, + api_docs_tracker=tracker + ) + + # Execute the listing + datasets.list_folder_content(None) + + # Generate documentation + docs = tracker.get_documentation() + + # Assertions + assert f"ssl-cert = {cert_path}" in docs + assert f"--cacert {cert_path}" in docs, "Documentation should include --cacert flag with cert path" diff --git a/tests/test_datasets/test_api_docs_data_path.py b/tests/test_datasets/test_api_docs_data_path.py new file mode 100644 index 00000000..8482845e --- /dev/null +++ b/tests/test_datasets/test_api_docs_data_path.py @@ -0,0 +1,104 @@ +"""Test to verify that project-id resolution is shown when using path Data""" +import json +import responses +from cloudos_cli.datasets import Datasets +from cloudos_cli.datasets.datasets import APICallTracker +from tests.functions_for_pytest import load_json_file + +# Constants +APIKEY = 'vnoiweur89u2ongs' +CLOUDOS_URL = 'http://cloudos.lifebit.ai' +WORKSPACE_ID = 'lv89ufc838sdig' +PROJECT_NAME = "lifebit-testing" +DATASET_NAME = 'Data' + +# Files +INPUT_PROJECTS = "tests/test_data/projects.json" +INPUT_DATASETS = "tests/test_data/datasets.json" +INPUT_DATASET_CONTENT = "tests/test_data/dataset_folder_results.json" + + +@responses.activate +def test_api_docs_shows_project_resolution_for_data_path(): + """Test that when listing 'Data' folder, project resolution is tracked.""" + # Load mocked data + mock_projects = json.loads(load_json_file(INPUT_PROJECTS)) + mock_datasets = json.loads(load_json_file(INPUT_DATASETS)) + mock_dataset_contents = json.loads(load_json_file(INPUT_DATASET_CONTENT)) + + # Extract IDs + project_id = next((p["_id"] for p in mock_projects["projects"] if p["name"] == PROJECT_NAME), None) + dataset_id = next((d["_id"] for d in mock_datasets["datasets"] if d["name"] == DATASET_NAME), "data_id_123") + + # Mock endpoints + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v2/projects?teamId={WORKSPACE_ID}&search={PROJECT_NAME}", + body=json.dumps(mock_projects), + status=200 + ) + + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v2/datasets?projectId={project_id}&teamId={WORKSPACE_ID}", + body=json.dumps(mock_datasets), + status=200 + ) + + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v1/datasets/{dataset_id}/items?teamId={WORKSPACE_ID}", + body=json.dumps(mock_dataset_contents), + status=200 + ) + + # Create tracker + tracker = APICallTracker( + cloudos_url=CLOUDOS_URL, + workspace_id=WORKSPACE_ID, + verify=True + ) + + # Instantiate Datasets with tracker (don't pass project_id to trigger fetch) + datasets = Datasets( + cloudos_url=CLOUDOS_URL, + apikey=APIKEY, + workspace_id=WORKSPACE_ID, + project_name=PROJECT_NAME, + verify=True, + cromwell_token=None, + api_docs_tracker=tracker + ) + + # Execute the listing with "Data" path (the reported issue) + datasets.list_folder_content(DATASET_NAME) + + # Generate documentation + docs = tracker.get_documentation() + + # Assertions + print("\n" + "="*80) + print("Generated API Documentation:") + print(docs) + print("="*80 + "\n") + + # The key assertion: project resolution should be documented! + assert "Resolve project name to project ID" in docs, \ + "Documentation should include project name resolution instructions" + + # Should have at least 3 calls: 1. Resolve project, 2. List datasets, 3. List folder items + assert len(tracker.calls) >= 3, f"Expected at least 3 API calls, but got {len(tracker.calls)}" + + # Verify the first call is project resolution + assert tracker.calls[0]['purpose'] == "Resolve project name to project ID", \ + "First API call should be project name resolution" + + # Verify jq extraction hint is present + assert "jq" in docs, "Documentation should include jq command for extracting project ID" + assert f'select(.name=="{PROJECT_NAME}")' in docs, \ + "Documentation should show how to filter by project name" + + +if __name__ == "__main__": + test_api_docs_shows_project_resolution_for_data_path() + print("\n✓ Test passed! Project resolution is now shown when using --api-docs with path 'Data'")