intelliseq-iflow 0.2.7__tar.gz → 0.2.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/PKG-INFO +1 -1
  2. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/pyproject.toml +1 -1
  3. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/__init__.py +1 -1
  4. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/api.py +14 -2
  5. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/commands/auth.py +0 -3
  6. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/commands/config.py +1 -7
  7. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/commands/files.py +16 -12
  8. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/commands/runs.py +20 -13
  9. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/config.py +24 -0
  10. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/curl.py +17 -9
  11. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/.gitignore +0 -0
  12. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/README.md +0 -0
  13. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/auth.py +0 -0
  14. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/cli.py +0 -0
  15. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/commands/__init__.py +0 -0
  16. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/commands/orders.py +0 -0
  17. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/src/iflow/commands/pipelines.py +0 -0
  18. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/tests/__init__.py +0 -0
  19. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/tests/conftest.py +0 -0
  20. {intelliseq_iflow-0.2.7 → intelliseq_iflow-0.2.9}/tests/test_cli_workflow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: intelliseq-iflow
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: CLI tool for iFlow - genomic data management and workflow execution
5
5
  Project-URL: Homepage, https://intelliseq.com
6
6
  Project-URL: Documentation, https://docs.iflow.intelliseq.com
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "intelliseq-iflow"
3
- version = "0.2.7"
3
+ version = "0.2.9"
4
4
  description = "CLI tool for iFlow - genomic data management and workflow execution"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """iseq-flow - CLI tool for IntelliSeq Flow cloud file management."""
2
2
 
3
- __version__ = "0.2.7"
3
+ __version__ = "0.2.9"
@@ -98,6 +98,14 @@ class RunOutput:
98
98
  description: str | None = None
99
99
 
100
100
 
101
+ @dataclass
102
+ class RunOutputsResponse:
103
+ """Response from run outputs endpoint."""
104
+
105
+ outputs: list[RunOutput]
106
+ message: str | None = None # Informational message (e.g., why outputs may be empty)
107
+
108
+
101
109
  # Miner service models
102
110
 
103
111
 
@@ -480,14 +488,14 @@ class ComputeAPIClient:
480
488
  data = await self._request("GET", f"/api/v1/runs/by-order/{order_id}")
481
489
  return [self._parse_run(r) for r in data]
482
490
 
483
- async def get_run_outputs(self, run_id: str) -> list[RunOutput]:
491
+ async def get_run_outputs(self, run_id: str) -> RunOutputsResponse:
484
492
  """Get semantic outputs for a run.
485
493
 
486
494
  Parses metadata.json and maps to semantic names from meta.json.
487
495
  Only available for WDL runs that have completed.
488
496
  """
489
497
  data = await self._request("GET", f"/api/v1/runs/{run_id}/outputs")
490
- return [
498
+ outputs = [
491
499
  RunOutput(
492
500
  name=o["name"],
493
501
  display_name=o["display_name"],
@@ -497,6 +505,10 @@ class ComputeAPIClient:
497
505
  )
498
506
  for o in data.get("outputs", [])
499
507
  ]
508
+ return RunOutputsResponse(
509
+ outputs=outputs,
510
+ message=data.get("message"),
511
+ )
500
512
 
501
513
  def _parse_run(self, data: dict, project_id: str | None = None) -> Run:
502
514
  """Parse run data from API response.
@@ -68,9 +68,6 @@ async def auto_configure_after_login(
68
68
  p = projects[0]
69
69
  set_project_context(p.id, p.name, p.org_id, p.org_name, p.bucket_name)
70
70
  console.print(f"[dim]Project:[/dim] {p.name} (auto-selected)")
71
- if p.bucket_name:
72
- bucket = normalize_bucket_name(p.bucket_name)
73
- console.print(f"[dim]Bucket:[/dim] gs://{bucket}/")
74
71
  elif len(projects) > 1:
75
72
  console.print(f"[dim]Found {len(projects)} projects.[/dim]")
76
73
  console.print("Run [cyan]iflow config select-project[/cyan] to choose one.")
@@ -39,9 +39,6 @@ def show_config():
39
39
  console.print(f" Organization: [cyan]{settings.org_name or 'Unknown'}[/cyan]")
40
40
  console.print(f" Project: [cyan]{settings.project_name or 'Unknown'}[/cyan]")
41
41
  console.print(f" Project ID: [dim]{settings.project_id}[/dim]")
42
- if settings.bucket_name:
43
- bucket = normalize_bucket_name(settings.bucket_name)
44
- console.print(f" Bucket: [cyan]gs://{bucket}/[/cyan]")
45
42
  console.print()
46
43
  else:
47
44
  console.print("[yellow]No default project set.[/yellow]")
@@ -258,12 +255,9 @@ def select_project(project_id: str | None, curl: bool):
258
255
  console.print(f"[green]Selected project:[/green] {selected.name}")
259
256
  console.print(f" Organization: {selected.org_name}")
260
257
  console.print(f" Project ID: {selected.id}")
261
- if selected.bucket_name:
262
- bucket = normalize_bucket_name(selected.bucket_name)
263
- console.print(f" Bucket: gs://{bucket}/")
264
258
  console.print()
265
259
  console.print("You can now run commands without specifying -p/--project.")
266
- console.print("File paths will be relative to your project bucket.")
260
+ console.print("File paths are relative to your project.")
267
261
 
268
262
  except APIError as e:
269
263
  console.print(f"[red]Error:[/red] {e}")
@@ -56,10 +56,11 @@ def list_files(project: str | None, path: str, curl: bool):
56
56
  try:
57
57
  result = asyncio.run(_list())
58
58
 
59
- # Display bucket info
60
- console.print(f"[dim]Bucket:[/dim] {result.bucket_name}")
59
+ # Display current path (relative to project)
61
60
  if result.path:
62
61
  console.print(f"[dim]Path:[/dim] {result.path}")
62
+ else:
63
+ console.print(f"[dim]Path:[/dim] /")
63
64
  console.print()
64
65
 
65
66
  if not result.folders and not result.files:
@@ -157,7 +158,10 @@ def download(
157
158
  def _download_by_path(project_id: str, path: str, output: str | None, curl: bool):
158
159
  """Download file by direct GCS path."""
159
160
  if curl:
160
- print(files_curl("GET", f"/files/download?path={path}", project_id))
161
+ print(files_curl("GET", "/files/download-url", project_id, params={"path": path}))
162
+ print("\n# Then download with curl or wget:")
163
+ print(f"# curl -o '{Path(path).name}' '<url>'")
164
+ print(f"# wget -O '{Path(path).name}' '<url>'")
161
165
  return
162
166
 
163
167
  async def _download():
@@ -240,14 +244,15 @@ def _download_by_output(
240
244
 
241
245
  # 4. Get outputs for this run
242
246
  console.print("[dim]Getting run outputs...[/dim]")
243
- outputs = await compute_client.get_run_outputs(latest_run.id)
244
- if not outputs:
245
- raise APIError("No outputs found for run (may not be a WDL run)")
247
+ response = await compute_client.get_run_outputs(latest_run.id)
248
+ if not response.outputs:
249
+ msg = response.message or "may not be a WDL run"
250
+ raise APIError(f"No outputs found for run ({msg})")
246
251
 
247
252
  # 5. Find matching output
248
- matching = [o for o in outputs if o.name == output_name]
253
+ matching = [o for o in response.outputs if o.name == output_name]
249
254
  if not matching:
250
- available = ", ".join(o.name for o in outputs)
255
+ available = ", ".join(o.name for o in response.outputs)
251
256
  raise APIError(f"Output '{output_name}' not found. Available: {available}")
252
257
 
253
258
  output_info = matching[0]
@@ -329,13 +334,12 @@ def upload(project: str | None, file: str, remote_path: str, curl: bool):
329
334
  if curl:
330
335
  print(files_curl(
331
336
  "POST",
332
- "/files/upload",
337
+ "/files/upload-url",
333
338
  project_id,
334
339
  data={"path": final_remote_path, "content_type": content_type},
335
340
  ))
336
- print("\n# Then upload to the returned signed URL:")
337
- print(f"# curl -X PUT -H 'Content-Type: {content_type}' \\")
338
- print(f"# --data-binary @{file} '<signed_url>'")
341
+ print("\n# Then upload to the signed URL from response:")
342
+ print(f"# curl -X PUT -H 'Content-Type: {content_type}' --data-binary @{file} '<url>'")
339
343
  return
340
344
 
341
345
  async def _upload():
@@ -6,7 +6,7 @@ import time
6
6
  import click
7
7
 
8
8
  from iflow.api import APIError, ComputeAPIClient
9
- from iflow.config import require_project, resolve_gcs_path
9
+ from iflow.config import require_project, resolve_gcs_path, to_relative_path
10
10
  from iflow.curl import compute_curl
11
11
 
12
12
 
@@ -114,7 +114,7 @@ def last_run(
114
114
  click.echo(run.name)
115
115
  elif show_output:
116
116
  if run.output_path:
117
- click.echo(run.output_path)
117
+ click.echo(to_relative_path(run.output_path))
118
118
  else:
119
119
  click.echo("", err=True) # Empty if no output
120
120
  raise SystemExit(1)
@@ -124,7 +124,7 @@ def last_run(
124
124
  click.echo(f" ID: {run.id}")
125
125
  click.echo(f" Status: {run.status}")
126
126
  if run.output_path:
127
- click.echo(f" Output: {run.output_path}")
127
+ click.echo(f" Output: {to_relative_path(run.output_path)}")
128
128
 
129
129
  except APIError as e:
130
130
  click.echo(f"Error: {e}", err=True)
@@ -293,7 +293,7 @@ def run_status(run_id: str, curl: bool):
293
293
  click.echo(f" Finished: {run.finished_at}")
294
294
 
295
295
  if run.output_path:
296
- click.echo(f" Output: {run.output_path}")
296
+ click.echo(f" Output: {to_relative_path(run.output_path)}")
297
297
  if run.error_message:
298
298
  click.echo(f" Error: {run.error_message}")
299
299
 
@@ -354,24 +354,31 @@ def run_outputs(run_id: str, download: str | None, output: str | None, curl: boo
354
354
  iflow runs outputs RUN_ID -d annotated_vcf_gz
355
355
  iflow runs outputs RUN_ID -d top20_tsv -o results.tsv
356
356
  """
357
- if curl and not download:
357
+ if curl:
358
+ # Show curl command regardless of download flag
358
359
  print(compute_curl("GET", f"/runs/{run_id}/outputs"))
360
+ if download:
361
+ print("\n# To download a specific output, use the path from the response above with:")
362
+ print("# iflow files download <path> --curl")
359
363
  return
360
364
 
361
365
  async def _outputs():
362
366
  client = ComputeAPIClient()
363
367
  try:
364
- outputs = await client.get_run_outputs(run_id)
368
+ response = await client.get_run_outputs(run_id)
365
369
 
366
- if not outputs:
367
- click.echo("No outputs found (run may not have completed or is not a WDL run).")
370
+ if not response.outputs:
371
+ if response.message:
372
+ click.echo(f"No outputs: {response.message}")
373
+ else:
374
+ click.echo("No outputs found.")
368
375
  return
369
376
 
370
377
  if download:
371
378
  # Find output by name
372
- matching = [o for o in outputs if o.name == download]
379
+ matching = [o for o in response.outputs if o.name == download]
373
380
  if not matching:
374
- available = ", ".join(o.name for o in outputs)
381
+ available = ", ".join(o.name for o in response.outputs)
375
382
  click.echo(f"Error: Output '{download}' not found.", err=True)
376
383
  click.echo(f"Available outputs: {available}", err=True)
377
384
  raise SystemExit(1)
@@ -414,8 +421,8 @@ def run_outputs(run_id: str, download: str | None, output: str | None, curl: boo
414
421
  click.echo(f"{'NAME':<20} {'TYPE':<10} {'PATH'}")
415
422
  click.echo("-" * 80)
416
423
 
417
- for out in outputs:
418
- path = out.path or "-"
424
+ for out in response.outputs:
425
+ path = to_relative_path(out.path) or "-"
419
426
  click.echo(f"{out.name:<20} {out.type:<10} {path}")
420
427
 
421
428
  except APIError as e:
@@ -472,7 +479,7 @@ async def _watch_run(client: ComputeAPIClient, run_id: str, interval: int = 10):
472
479
  if run.status == "succeeded":
473
480
  click.echo("Run completed successfully!")
474
481
  if run.output_path:
475
- click.echo(f"Output: {run.output_path}")
482
+ click.echo(f"Output: {to_relative_path(run.output_path)}")
476
483
  elif run.status == "failed":
477
484
  click.echo(f"Run failed: {run.error_message or 'Unknown error'}")
478
485
  else:
@@ -210,6 +210,30 @@ def resolve_gcs_path(path: str) -> str:
210
210
  return f"gs://{bucket}/{path}"
211
211
 
212
212
 
213
+ def to_relative_path(path: str | None) -> str | None:
214
+ """Convert a full GCS/S3 path to a path relative to project bucket.
215
+
216
+ Strips gs://bucket/ or s3://bucket/ prefix, returning just the path.
217
+ Used for user-facing display where bucket context is implicit.
218
+
219
+ Examples:
220
+ gs://my-bucket/results/file.vcf -> results/file.vcf
221
+ s3://bucket/data/sample.fastq -> data/sample.fastq
222
+ results/file.vcf -> results/file.vcf (already relative)
223
+ """
224
+ if not path:
225
+ return None
226
+ if path.startswith("gs://"):
227
+ # Format: gs://bucket/path/to/file
228
+ parts = path[5:].split("/", 1)
229
+ return parts[1] if len(parts) > 1 else ""
230
+ if path.startswith("s3://"):
231
+ # Format: s3://bucket/path/to/file
232
+ parts = path[5:].split("/", 1)
233
+ return parts[1] if len(parts) > 1 else ""
234
+ return path # Already relative
235
+
236
+
213
237
  def require_project(project_option: str | None) -> str:
214
238
  """Get project ID from option or config, raise if not available."""
215
239
  if project_option:
@@ -27,11 +27,11 @@ def generate_curl(
27
27
  Returns:
28
28
  Complete curl command string
29
29
  """
30
- parts = ["curl", "-s"]
30
+ parts = ["curl -s"]
31
31
 
32
32
  # Method
33
33
  if method != "GET":
34
- parts.extend(["-X", method])
34
+ parts.append(f"-X {method}")
35
35
 
36
36
  # Add query params to URL
37
37
  if params:
@@ -51,13 +51,16 @@ def generate_curl(
51
51
  if data and method in ("POST", "PUT", "PATCH"):
52
52
  all_headers["Content-Type"] = "application/json"
53
53
 
54
+ # Combine -H flag with argument to prevent line-break issues
54
55
  for key, value in all_headers.items():
55
- parts.extend(["-H", f"{key}: {value}"])
56
+ parts.append(f"-H '{key}: {value}'")
56
57
 
57
- # JSON body
58
+ # JSON body - combine -d flag with argument
58
59
  if data:
59
- json_str = json.dumps(data, indent=2)
60
- parts.extend(["-d", json_str])
60
+ json_str = json.dumps(data)
61
+ # Escape single quotes in JSON and wrap in single quotes
62
+ json_escaped = json_str.replace("'", "'\\''")
63
+ parts.append(f"-d '{json_escaped}'")
61
64
 
62
65
  # URL (quoted)
63
66
  parts.append(shlex.quote(url))
@@ -86,11 +89,16 @@ def files_curl(
86
89
  data: dict[str, Any] | None = None,
87
90
  params: dict[str, str] | None = None,
88
91
  ) -> str:
89
- """Generate curl for file-service."""
92
+ """Generate curl for file-service.
93
+
94
+ File-service endpoints use /api/v1/files/... with project_id as query param.
95
+ """
90
96
  settings = get_settings()
91
- url = f"{settings.file_url}/api/v1/projects/{project_id}{endpoint}"
97
+ url = f"{settings.file_url}/api/v1{endpoint}"
92
98
  headers = {"X-Project-ID": project_id}
93
- return generate_curl(method, url, headers, data, params)
99
+ # Add project_id to query params
100
+ all_params = {"project_id": project_id, **(params or {})}
101
+ return generate_curl(method, url, headers, data, all_params)
94
102
 
95
103
 
96
104
  def compute_curl(