datatoolpack 0.5.2__tar.gz → 0.7.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datatoolpack
3
- Version: 0.5.2
3
+ Version: 0.7.0
4
4
  Summary: Official Python SDK for the AutoData ML data preparation pipeline API
5
5
  Home-page: https://autodata.datatoolpack.com
6
6
  Author: AutoData Team
@@ -1,4 +1,4 @@
1
1
  from .client import AutoDataClient, AutoDataError
2
2
 
3
- __version__ = "0.5.2"
3
+ __version__ = "0.7.0"
4
4
  __all__ = ["AutoDataClient", "AutoDataError", "__version__"]
@@ -24,6 +24,8 @@ import time
24
24
  import zipfile
25
25
  from typing import Dict, List, Optional, Union
26
26
 
27
+ import requests
28
+
27
29
 
28
30
  try:
29
31
  import requests
@@ -212,6 +214,7 @@ class AutoDataClient:
212
214
  auto_download: bool = True,
213
215
  output_preferences: Optional[List[str]] = None,
214
216
  compressed: bool = True,
217
+ return_dataframes: bool = False,
215
218
  ) -> Dict:
216
219
  """Upload a data file and start the AutoData pipeline.
217
220
 
@@ -305,6 +308,12 @@ class AutoDataClient:
305
308
  output_preferences=output_preferences,
306
309
  compressed=compressed,
307
310
  )
311
+ # New in 0.7.0: return the output files as DataFrames so callers can
312
+ # skip the "download to disk, then pd.read_csv()" two-step. Gated on
313
+ # an opt-in flag to keep the default light (no pandas import).
314
+ if return_dataframes:
315
+ final = dict(final) # don't mutate the cached result object
316
+ final["dataframes"] = self.get_dataframes(session_id, names=output_preferences)
308
317
  return final
309
318
 
310
319
  def get_status(self, session_id: str) -> Dict:
@@ -344,16 +353,34 @@ class AutoDataClient:
344
353
  return False
345
354
 
346
355
  def wait_for_completion(
347
- self, session_id: str, poll_interval: int = 2
356
+ self,
357
+ session_id: str,
358
+ poll_interval: int = 2,
359
+ timeout: Optional[float] = None,
360
+ stall_timeout: float = 600.0,
348
361
  ) -> Dict:
349
362
  """Block until a session reaches *completed*, *error*, or *cancelled*.
350
363
 
351
364
  Prints progress updates to stdout.
352
365
 
366
+ Args:
367
+ session_id: Session to wait for.
368
+ poll_interval: Seconds between status polls (default 2).
369
+ timeout: Hard ceiling in seconds for the whole wait. ``None``
370
+ disables. Raises ``AutoDataError`` if exceeded.
371
+ stall_timeout: If progress (percent + message) doesn't change for
372
+ this many seconds AND status is still 'unknown' /
373
+ 'processing', treat as a stalled worker and raise.
374
+ Default 10 minutes.
375
+
353
376
  Raises:
354
- AutoDataError: On processing failure or cancellation.
377
+ AutoDataError: On processing failure, cancellation, timeout, or
378
+ detected stall.
355
379
  """
356
380
  print(f"Waiting for session {session_id}…")
381
+ start = time.monotonic()
382
+ last_change = start
383
+ last_signature: tuple = ()
357
384
  while True:
358
385
  data = self.get_status(session_id)
359
386
  status = data.get("status", "unknown")
@@ -363,11 +390,30 @@ class AutoDataClient:
363
390
  if status == "completed":
364
391
  print(f"\r✓ Completed ({pct}%): {msg} ")
365
392
  return self.get_result(session_id)
366
- elif status == "error":
393
+ if status == "error":
367
394
  raise AutoDataError(f"Processing failed: {msg}")
368
- elif status == "cancelled":
395
+ if status == "cancelled":
369
396
  raise AutoDataError("Processing was cancelled")
370
397
 
398
+ # Detect stalled workers: status stays 'unknown'/'processing' with
399
+ # no progress change. Prevents infinite loops when cpu_worker died.
400
+ sig = (status, pct, msg)
401
+ now = time.monotonic()
402
+ if sig != last_signature:
403
+ last_signature = sig
404
+ last_change = now
405
+ elif now - last_change > stall_timeout:
406
+ raise AutoDataError(
407
+ f"Processing stalled: no progress for {stall_timeout:.0f}s "
408
+ f"(last status={status!r}, message={msg!r}). "
409
+ "The worker may have crashed — please retry."
410
+ )
411
+
412
+ if timeout is not None and now - start > timeout:
413
+ raise AutoDataError(
414
+ f"wait_for_completion timed out after {timeout:.0f}s"
415
+ )
416
+
371
417
  print(f"\r {pct:3d}% — {msg[:60]:<60}", end="", flush=True)
372
418
  time.sleep(poll_interval)
373
419
 
@@ -411,16 +457,32 @@ class AutoDataClient:
411
457
  "POST",
412
458
  self._url(f"/download-archive/{session_id}"),
413
459
  json=body,
414
- timeout=max(self.timeout, 300),
415
- stream=True,
460
+ timeout=max(self.timeout, 900),
416
461
  )
417
462
  self._raise_for_error(r)
463
+ content = r.content or b""
464
+ if not content:
465
+ raise AutoDataError(
466
+ f"Server returned an empty archive for session {session_id} "
467
+ "(the outputs may have expired or failed to generate — please retry the run)."
468
+ )
418
469
  try:
419
- z = zipfile.ZipFile(io.BytesIO(r.content))
470
+ z = zipfile.ZipFile(io.BytesIO(content))
471
+ members = z.namelist()
472
+ if not members:
473
+ raise AutoDataError(
474
+ f"Archive for session {session_id} is empty "
475
+ "(no output files found on the server)."
476
+ )
420
477
  z.extractall(download_path)
421
- print(f"Extracted results to {download_path}")
478
+ print(f"Extracted {len(members)} file(s) to {download_path}")
422
479
  except zipfile.BadZipFile:
423
- raise AutoDataError("Server returned an invalid ZIP archive")
480
+ # Surface the body head to aid debugging — often an HTML error page.
481
+ preview = content[:200].decode("utf-8", errors="replace")
482
+ raise AutoDataError(
483
+ f"Server returned an invalid ZIP archive "
484
+ f"(Content-Type={r.headers.get('Content-Type')!r}, head={preview!r})"
485
+ )
424
486
  else:
425
487
  result = self.get_result(session_id)
426
488
  for f in result.get("files", []):
@@ -434,12 +496,26 @@ class AutoDataClient:
434
496
 
435
497
  return os.path.abspath(download_path)
436
498
 
437
- def download_file(self, url: str, output_path: str) -> None:
499
+ def download_file(
500
+ self,
501
+ url: str,
502
+ output_path: str,
503
+ timeout: Optional[float] = None,
504
+ retries: int = 2,
505
+ ) -> None:
438
506
  """Download a single file by URL (absolute or server-relative).
439
507
 
508
+ Validates Content-Length when the server provides it, and retries on
509
+ transient network errors. Partial downloads are detected and raised
510
+ rather than silently succeeding.
511
+
440
512
  Args:
441
513
  url: Full URL or path starting with ``/``.
442
514
  output_path: Local file path to write to.
515
+ timeout: Per-attempt timeout in seconds. Default scales with
516
+ the client's configured timeout; pass an explicit
517
+ value for very large files on slow networks.
518
+ retries: Number of retry attempts on partial / network errors.
443
519
  """
444
520
  if not url.startswith("http"):
445
521
  url = f"{self.base_url}{url}"
@@ -447,14 +523,172 @@ class AutoDataClient:
447
523
  # Ensure parent directory exists
448
524
  os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
449
525
 
526
+ effective_timeout = timeout if timeout is not None else max(self.timeout, 900)
527
+ last_err: Optional[Exception] = None
528
+
529
+ for attempt in range(retries + 1):
530
+ bytes_written = 0
531
+ expected: Optional[int] = None
532
+ try:
533
+ r = self._request(
534
+ "GET", url, stream=True, timeout=effective_timeout
535
+ )
536
+ self._raise_for_error(r)
537
+ # Reject 0-byte responses so the caller notices instead of
538
+ # silently producing an empty file.
539
+ cl = r.headers.get("Content-Length")
540
+ if cl is not None:
541
+ try:
542
+ expected = int(cl)
543
+ except (TypeError, ValueError):
544
+ expected = None
545
+ if expected == 0:
546
+ raise AutoDataError(
547
+ f"Server returned an empty file for {url} "
548
+ "(the output may have failed to generate — please retry the run)."
549
+ )
550
+ with open(output_path, "wb") as fh:
551
+ for chunk in r.iter_content(chunk_size=65536):
552
+ if not chunk:
553
+ continue
554
+ fh.write(chunk)
555
+ bytes_written += len(chunk)
556
+ if expected is not None and bytes_written != expected:
557
+ raise AutoDataError(
558
+ f"Partial download: received {bytes_written} of {expected} bytes"
559
+ )
560
+ print(f"Downloaded to {output_path} ({bytes_written} bytes)")
561
+ return
562
+ except (requests.ConnectionError, requests.Timeout, AutoDataError) as err:
563
+ last_err = err
564
+ if attempt < retries:
565
+ backoff = 2 ** attempt
566
+ print(f"\nDownload attempt {attempt + 1} failed ({err}); retrying in {backoff}s…")
567
+ time.sleep(backoff)
568
+ continue
569
+ raise
570
+ # Unreachable — loop either returns or re-raises
571
+ if last_err:
572
+ raise last_err
573
+
574
+ # ------------------------------------------------------------------
575
+ # DataFrame-native helpers
576
+ # ------------------------------------------------------------------
577
+ # These let callers skip the "download to disk, then pd.read_csv()" two-step.
578
+ # pandas is imported lazily so the SDK stays light when a caller doesn't
579
+ # need it (the package install_requires only `requests`).
580
+
581
+ @staticmethod
582
+ def _require_pandas():
583
+ try:
584
+ import pandas as pd # noqa: F401
585
+ return pd
586
+ except ImportError as e:
587
+ raise AutoDataError(
588
+ "pandas is required for DataFrame helpers — install it with `pip install pandas` "
589
+ "or use download_file()/download_results() to get raw files instead."
590
+ ) from e
591
+
592
+ def get_dataframe(
593
+ self,
594
+ session_id: str,
595
+ output_name: str,
596
+ *,
597
+ timeout: Optional[float] = None,
598
+ **read_csv_kwargs,
599
+ ):
600
+ """Fetch one output file and return it as a pandas DataFrame.
601
+
602
+ Args:
603
+ session_id: Completed processing session.
604
+ output_name: File base name (e.g. ``"dsg_output"``, ``"mdh_output"``)
605
+ or the full filename (``"dsg_output.csv"``). ``.csv``
606
+ is appended if missing.
607
+ timeout: Per-request timeout; defaults to the SDK's normal
608
+ download timeout (15 min).
609
+ read_csv_kwargs: Forwarded to ``pandas.read_csv`` (e.g. ``dtype``,
610
+ ``parse_dates``, ``usecols``).
611
+
612
+ Returns:
613
+ A pandas.DataFrame.
614
+
615
+ Raises:
616
+ AutoDataError: if pandas isn't installed, the file is missing or
617
+ empty (server failed to generate it), or the
618
+ download was truncated.
619
+ """
620
+ pd = self._require_pandas()
621
+ fname = output_name if output_name.lower().endswith(
622
+ (".csv", ".json", ".parquet", ".feather", ".orc")
623
+ ) else f"{output_name}.csv"
624
+ url = self._url(f"/download/{session_id}/{fname}")
625
+ if not url.startswith("http"):
626
+ url = f"{self.base_url}{url}"
450
627
  r = self._request(
451
- "GET", url, stream=True, timeout=max(self.timeout, 300)
628
+ "GET", url, stream=True,
629
+ timeout=timeout if timeout is not None else max(self.timeout, 900),
452
630
  )
453
631
  self._raise_for_error(r)
454
- with open(output_path, "wb") as fh:
455
- for chunk in r.iter_content(chunk_size=65536):
456
- fh.write(chunk)
457
- print(f"Downloaded to {output_path}")
632
+ cl = r.headers.get("Content-Length")
633
+ if cl == "0":
634
+ raise AutoDataError(
635
+ f"Server returned an empty file for {fname} — the output likely "
636
+ "failed to generate. Retry the run or check /result/<session_id>."
637
+ )
638
+ # Buffer into memory — pandas.read_csv needs a seekable stream and
639
+ # output files are bounded by the server-side row ceiling anyway.
640
+ body = r.content
641
+ if not body:
642
+ raise AutoDataError(f"Server returned 0 bytes for {fname}")
643
+ import io as _io
644
+ buf = _io.BytesIO(body)
645
+ low = fname.lower()
646
+ if low.endswith(".parquet"):
647
+ return pd.read_parquet(buf)
648
+ if low.endswith(".feather"):
649
+ return pd.read_feather(buf)
650
+ if low.endswith(".orc"):
651
+ return pd.read_orc(buf)
652
+ if low.endswith(".json"):
653
+ return pd.read_json(buf, **read_csv_kwargs)
654
+ return pd.read_csv(buf, **read_csv_kwargs)
655
+
656
+ def get_dataframes(
657
+ self,
658
+ session_id: str,
659
+ names: Optional[List[str]] = None,
660
+ *,
661
+ timeout: Optional[float] = None,
662
+ ) -> Dict:
663
+ """Fetch multiple output files and return a ``{name: DataFrame}`` dict.
664
+
665
+ Args:
666
+ session_id: Completed processing session.
667
+ names: Output base names to fetch. ``None`` means every file
668
+ the server lists for the session (skipping PDF
669
+ reports and .json metadata files, which aren't
670
+ naturally a DataFrame).
671
+ timeout: Per-file timeout; see ``get_dataframe``.
672
+
673
+ Returns:
674
+ Dict of ``{file_stem: pandas.DataFrame}``.
675
+ """
676
+ self._require_pandas()
677
+ if names is None:
678
+ result = self.get_result(session_id)
679
+ names = []
680
+ for f in result.get("files", []):
681
+ fn = f.get("name", "")
682
+ low = fn.lower()
683
+ if not low or low.endswith((".pdf", ".json", ".md")):
684
+ continue
685
+ names.append(fn)
686
+ out: Dict = {}
687
+ for n in names:
688
+ # Strip the extension so callers can access via the logical name.
689
+ key = n.rsplit(".", 1)[0] if "." in n else n
690
+ out[key] = self.get_dataframe(session_id, n, timeout=timeout)
691
+ return out
458
692
 
459
693
  # ------------------------------------------------------------------
460
694
  # Account / API key management
@@ -591,6 +825,7 @@ class AutoDataClient:
591
825
  compressed: bool = True,
592
826
  incremental: bool = False,
593
827
  type_casts: Optional[Dict[str, str]] = None,
828
+ return_dataframes: bool = False,
594
829
  ) -> Dict:
595
830
  """Run the AutoData pipeline on data from a connector source.
596
831
 
@@ -691,6 +926,9 @@ class AutoDataClient:
691
926
  output_preferences=output_preferences,
692
927
  compressed=compressed,
693
928
  )
929
+ if return_dataframes:
930
+ final = dict(final)
931
+ final["dataframes"] = self.get_dataframes(session_id, names=output_preferences)
694
932
  return final
695
933
 
696
934
  def write_output(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datatoolpack
3
- Version: 0.5.2
3
+ Version: 0.7.0
4
4
  Summary: Official Python SDK for the AutoData ML data preparation pipeline API
5
5
  Home-page: https://autodata.datatoolpack.com
6
6
  Author: AutoData Team
@@ -7,7 +7,7 @@ with open(os.path.join(here, "README.md"), encoding="utf-8") as f:
7
7
 
8
8
  setup(
9
9
  name="datatoolpack",
10
- version="0.5.2",
10
+ version="0.7.0",
11
11
  description="Official Python SDK for the AutoData ML data preparation pipeline API",
12
12
  long_description=long_description,
13
13
  long_description_content_type="text/markdown",
File without changes
File without changes