nv-ingest-api 25.4.2__py3-none-any.whl → 25.6.1__py3-none-any.whl

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.

Potentially problematic release.


This version of nv-ingest-api might be problematic. Click here for more details.

Files changed (46) hide show
  1. nv_ingest_api/internal/extract/docx/docx_extractor.py +3 -3
  2. nv_ingest_api/internal/extract/docx/engines/docxreader_helpers/docxreader.py +142 -86
  3. nv_ingest_api/internal/extract/html/__init__.py +3 -0
  4. nv_ingest_api/internal/extract/html/html_extractor.py +84 -0
  5. nv_ingest_api/internal/extract/image/chart_extractor.py +3 -3
  6. nv_ingest_api/internal/extract/image/image_extractor.py +5 -5
  7. nv_ingest_api/internal/extract/image/image_helpers/common.py +1 -1
  8. nv_ingest_api/internal/extract/image/infographic_extractor.py +1 -1
  9. nv_ingest_api/internal/extract/image/table_extractor.py +2 -2
  10. nv_ingest_api/internal/extract/pdf/engines/nemoretriever.py +2 -2
  11. nv_ingest_api/internal/extract/pdf/engines/pdfium.py +1 -1
  12. nv_ingest_api/internal/extract/pptx/engines/pptx_helper.py +214 -188
  13. nv_ingest_api/internal/extract/pptx/pptx_extractor.py +6 -9
  14. nv_ingest_api/internal/primitives/nim/model_interface/text_embedding.py +35 -38
  15. nv_ingest_api/internal/primitives/nim/model_interface/yolox.py +7 -1
  16. nv_ingest_api/internal/primitives/nim/nim_client.py +17 -9
  17. nv_ingest_api/internal/primitives/tracing/tagging.py +20 -16
  18. nv_ingest_api/internal/schemas/extract/extract_chart_schema.py +1 -1
  19. nv_ingest_api/internal/schemas/extract/extract_html_schema.py +34 -0
  20. nv_ingest_api/internal/schemas/extract/extract_infographic_schema.py +1 -1
  21. nv_ingest_api/internal/schemas/extract/extract_pdf_schema.py +1 -1
  22. nv_ingest_api/internal/schemas/extract/extract_table_schema.py +1 -1
  23. nv_ingest_api/internal/schemas/message_brokers/message_broker_client_schema.py +26 -12
  24. nv_ingest_api/internal/schemas/meta/ingest_job_schema.py +34 -23
  25. nv_ingest_api/internal/schemas/transform/transform_text_embedding_schema.py +11 -10
  26. nv_ingest_api/internal/schemas/transform/transform_text_splitter_schema.py +9 -7
  27. nv_ingest_api/internal/store/image_upload.py +1 -0
  28. nv_ingest_api/internal/transform/embed_text.py +75 -52
  29. nv_ingest_api/internal/transform/split_text.py +9 -3
  30. nv_ingest_api/util/__init__.py +3 -0
  31. nv_ingest_api/util/exception_handlers/converters.py +1 -1
  32. nv_ingest_api/util/exception_handlers/decorators.py +309 -51
  33. nv_ingest_api/util/image_processing/processing.py +1 -1
  34. nv_ingest_api/util/logging/configuration.py +15 -8
  35. nv_ingest_api/util/pdf/pdfium.py +2 -2
  36. nv_ingest_api/util/schema/__init__.py +3 -0
  37. nv_ingest_api/util/service_clients/redis/__init__.py +3 -0
  38. nv_ingest_api/util/service_clients/redis/redis_client.py +1 -1
  39. nv_ingest_api/util/service_clients/rest/rest_client.py +2 -2
  40. nv_ingest_api/util/system/__init__.py +0 -0
  41. nv_ingest_api/util/system/hardware_info.py +430 -0
  42. {nv_ingest_api-25.4.2.dist-info → nv_ingest_api-25.6.1.dist-info}/METADATA +2 -1
  43. {nv_ingest_api-25.4.2.dist-info → nv_ingest_api-25.6.1.dist-info}/RECORD +46 -41
  44. {nv_ingest_api-25.4.2.dist-info → nv_ingest_api-25.6.1.dist-info}/WHEEL +1 -1
  45. {nv_ingest_api-25.4.2.dist-info → nv_ingest_api-25.6.1.dist-info}/licenses/LICENSE +0 -0
  46. {nv_ingest_api-25.4.2.dist-info → nv_ingest_api-25.6.1.dist-info}/top_level.txt +0 -0
@@ -150,7 +150,7 @@ def extract_tables_and_charts_yolox(
150
150
  min_score=YOLOX_MIN_SCORE,
151
151
  final_thresh=YOLOX_FINAL_SCORE,
152
152
  trace_info=trace_info,
153
- stage_name="pdf_content_extractor",
153
+ stage_name="pdf_extraction",
154
154
  )
155
155
 
156
156
  # Process results: iterate over each image's inference output.
@@ -9,6 +9,7 @@ from enum import Enum
9
9
 
10
10
 
11
11
  class LogLevel(str, Enum):
12
+ DEFAULT = "DEFAULT"
12
13
  DEBUG = "DEBUG"
13
14
  INFO = "INFO"
14
15
  WARNING = "WARNING"
@@ -16,16 +17,22 @@ class LogLevel(str, Enum):
16
17
  CRITICAL = "CRITICAL"
17
18
 
18
19
 
19
- def configure_logging(logger, level_name):
20
- """
21
- Parameters:
22
- - level_name (str): The name of the logging level (e.g., "DEBUG", "INFO").
20
+ def configure_logging(level_name: str) -> None:
23
21
  """
22
+ Configures global logging.
24
23
 
25
- numeric_level = getattr(logging, level_name, None)
24
+ Parameters
25
+ ----------
26
+ level_name : str
27
+ The name of the logging level (e.g., "DEBUG", "INFO").
28
+ """
29
+ numeric_level = getattr(logging, level_name.upper(), None)
26
30
  if not isinstance(numeric_level, int):
27
31
  raise ValueError(f"Invalid log level: {level_name}")
28
32
 
29
- logging.StreamHandler(sys.stdout)
30
- logging.basicConfig(level=numeric_level, format="%(asctime)s - %(levelname)s - %(message)s")
31
- logger.setLevel(numeric_level)
33
+ logging.basicConfig(
34
+ level=numeric_level,
35
+ format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
36
+ stream=sys.stdout,
37
+ force=True, # <- reconfigures even if basicConfig was called earlier (Python 3.8+)
38
+ )
@@ -119,7 +119,7 @@ def pdfium_try_get_bitmap_as_numpy(image_obj) -> np.ndarray:
119
119
  return img_array
120
120
 
121
121
 
122
- @traceable_func(trace_name="pdf_content_extractor::pdfium_pages_to_numpy")
122
+ @traceable_func(trace_name="pdf_extraction::pdfium_pages_to_numpy")
123
123
  def pdfium_pages_to_numpy(
124
124
  pages: List[pdfium.PdfPage],
125
125
  render_dpi: int = 300,
@@ -394,7 +394,7 @@ def extract_image_like_objects_from_pdfium_page(page, merge=True, **kwargs):
394
394
  try:
395
395
  original_images, _ = pdfium_pages_to_numpy(
396
396
  [page], # A batch with a single image.
397
- render_dpi=72, # dpi = 72 is equivalent to scale = 1.
397
+ render_dpi=300, # dpi = 72 is equivalent to scale = 1.
398
398
  rotation=rotation, # Without rotation, coordinates from page.get_pos() will not match.
399
399
  )
400
400
  image_bboxes = extract_merged_images_from_pdfium_page(page, merge=merge, **kwargs)
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2024-25, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2024-25, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -446,7 +446,7 @@ class RedisClient(MessageBrokerClientBase):
446
446
  current_time: float = time.monotonic()
447
447
  elapsed_time: float = current_time - start_time
448
448
  if elapsed_time > timeout:
449
- logger.warning(f"Overall timeout ({timeout}s) exceeded for non-destructive fetch of '{channel_name}'.")
449
+ logger.debug(f"Overall timeout ({timeout}s) exceeded for non-destructive fetch of '{channel_name}'.")
450
450
  if expected_count:
451
451
  raise TimeoutError(
452
452
  f"Timeout collecting fragments for {channel_name}. "
@@ -312,7 +312,7 @@ class RestClient(MessageBrokerClientBase):
312
312
 
313
313
  while True:
314
314
  result: Optional[Any] = None
315
- trace_id: Optional[str] = None
315
+ trace_id: Optional[str] = job_id
316
316
  response_code: int = -1
317
317
 
318
318
  try:
@@ -470,7 +470,7 @@ class RestClient(MessageBrokerClientBase):
470
470
  f"Requires a requests.Session compatible API."
471
471
  )
472
472
  except requests.exceptions.RequestException as err:
473
- logger.warning(
473
+ logger.debug(
474
474
  f"RequestException submitting job: {err}. Attempting retry ({retries + 1}/{self._max_retries})..."
475
475
  )
476
476
  try:
File without changes
@@ -0,0 +1,430 @@
1
+ import logging
2
+ import os
3
+ import platform
4
+ from typing import Optional, Dict, Any, Tuple
5
+
6
+ # Try importing psutil, but don't make it a hard requirement if only cgroups are needed
7
+ try:
8
+ import psutil
9
+ except ImportError:
10
+ psutil = None
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # --- Cgroup Constants ---
15
+ CGROUP_V1_CPU_DIR = "/sys/fs/cgroup/cpu"
16
+ CGROUP_V1_CPUACCT_DIR = "/sys/fs/cgroup/cpuacct" # Sometimes usage is here
17
+ CGROUP_V2_CPU_FILE = "/sys/fs/cgroup/cpu.max" # Standard path in v2 unified hierarchy
18
+
19
+
20
+ class SystemResourceProbe:
21
+ """
22
+ Detects the effective CPU core count available to the current process,
23
+ optionally applying a weighting factor for hyperthreads (SMT).
24
+
25
+ It attempts to reconcile information from:
26
+ 1. Linux Cgroup v2 CPU limits (cpu.max)
27
+ 2. Linux Cgroup v1 CPU limits (cpu.cfs_quota_us, cpu.cfs_period_us)
28
+ 3. OS scheduler affinity (os.sched_getaffinity)
29
+ 4. OS reported CPU counts (psutil.cpu_count for logical/physical)
30
+
31
+ Prioritizes Cgroup quota limits. If the limit is based on core count
32
+ (affinity/OS), it applies hyperthreading weight if psutil provides
33
+ physical/logical counts.
34
+ """
35
+
36
+ def __init__(self, hyperthread_weight: float = 0.75):
37
+ """
38
+ Initializes the detector and performs the detection.
39
+
40
+ Parameters
41
+ ----------
42
+ hyperthread_weight : float, optional
43
+ The performance weighting factor for hyperthreads (0.0 to 1.0).
44
+ A value of 1.0 treats hyperthreads the same as physical cores.
45
+ A value of 0.5 suggests a hyperthread adds 50% extra performance.
46
+ Requires psutil to be installed and report physical cores.
47
+ Defaults to 0.75.
48
+
49
+ Note: the default value of 0.75 is a heuristic and may not be optimal
50
+ for all situations. It is where parallel pdf decomposition efficiency
51
+ is observed to begin rolling off.
52
+ """
53
+ if not (0.0 <= hyperthread_weight <= 1.0):
54
+ raise ValueError("hyperthread_weight must be between 0.0 and 1.0")
55
+
56
+ self.hyperthread_weight: float = hyperthread_weight if psutil else 1.0 # Force 1.0 if psutil missing
57
+ if not psutil and hyperthread_weight != 1.0:
58
+ logger.warning("psutil not found. Hyperthreading weight ignored (effectively 1.0).")
59
+
60
+ # OS Info
61
+ self.os_logical_cores: Optional[int] = None
62
+ self.os_physical_cores: Optional[int] = None
63
+ self.os_sched_affinity_cores: Optional[int] = None
64
+
65
+ # Cgroup Info
66
+ self.cgroup_type: Optional[str] = None
67
+ self.cgroup_quota_cores: Optional[float] = None
68
+ self.cgroup_period_us: Optional[int] = None
69
+ self.cgroup_shares: Optional[int] = None
70
+ self.cgroup_usage_percpu_us: Optional[list[int]] = None
71
+ self.cgroup_usage_total_us: Optional[int] = None
72
+
73
+ # --- Result ---
74
+ # Raw limit before potential weighting
75
+ self.raw_limit_value: Optional[float] = None
76
+ self.raw_limit_method: str = "unknown"
77
+ # Final potentially weighted result
78
+ self.effective_cores: Optional[float] = None
79
+ self.detection_method: str = "unknown" # Method for the final effective_cores
80
+
81
+ self._detect()
82
+
83
+ @staticmethod
84
+ def _read_file_int(path: str) -> Optional[int]:
85
+ """Safely reads an integer from a file."""
86
+ try:
87
+ if os.path.exists(path):
88
+ with open(path, "r") as f:
89
+ content = f.read().strip()
90
+ if content:
91
+ return int(content)
92
+ except (IOError, ValueError, PermissionError) as e:
93
+ logger.debug(f"Failed to read or parse int from {path}: {e}")
94
+ return None
95
+
96
+ @staticmethod
97
+ def _read_file_str(path: str) -> Optional[str]:
98
+ """Safely reads a string from a file."""
99
+ try:
100
+ if os.path.exists(path):
101
+ with open(path, "r") as f:
102
+ return f.read().strip()
103
+ except (IOError, PermissionError) as e:
104
+ logger.debug(f"Failed to read string from {path}: {e}")
105
+ return None
106
+
107
+ def _read_cgroup_v1(self) -> bool:
108
+ """Attempts to read Cgroup v1 CPU limits."""
109
+ if not os.path.exists(CGROUP_V1_CPU_DIR):
110
+ logger.debug(f"Cgroup v1 CPU dir not found: {CGROUP_V1_CPU_DIR}")
111
+ return False
112
+
113
+ logger.debug(f"Checking Cgroup v1 limits in {CGROUP_V1_CPU_DIR}")
114
+ quota_us = self._read_file_int(os.path.join(CGROUP_V1_CPU_DIR, "cpu.cfs_quota_us"))
115
+ period_us = self._read_file_int(os.path.join(CGROUP_V1_CPU_DIR, "cpu.cfs_period_us"))
116
+ shares = self._read_file_int(os.path.join(CGROUP_V1_CPU_DIR, "cpu.shares"))
117
+
118
+ # Check cpuacct for usage stats if dir exists
119
+ if os.path.exists(CGROUP_V1_CPUACCT_DIR):
120
+ usage_total = self._read_file_int(os.path.join(CGROUP_V1_CPUACCT_DIR, "cpuacct.usage"))
121
+ usage_percpu_str = self._read_file_str(os.path.join(CGROUP_V1_CPUACCT_DIR, "cpuacct.usage_percpu"))
122
+ if usage_percpu_str:
123
+ try:
124
+ self.cgroup_usage_percpu_us = [int(x) for x in usage_percpu_str.split()]
125
+ except ValueError:
126
+ logger.warning("Could not parse cpuacct.usage_percpu")
127
+ if usage_total is not None:
128
+ self.cgroup_usage_total_us = usage_total
129
+
130
+ if quota_us is not None and period_us is not None:
131
+ self.cgroup_type = "v1"
132
+ self.cgroup_period_us = period_us
133
+ self.cgroup_shares = shares # May be None if file doesn't exist/readable
134
+
135
+ if quota_us > 0 and period_us > 0:
136
+ self.cgroup_quota_cores = quota_us / period_us
137
+ logger.info(
138
+ f"Cgroup v1 quota detected: {quota_us} us / {period_us} us = {self.cgroup_quota_cores:.2f}"
139
+ f" effective cores"
140
+ )
141
+ return True
142
+ elif quota_us == -1:
143
+ logger.info("Cgroup v1 quota detected: Unlimited (-1)")
144
+ # No quota limit, but we know it's cgroup v1
145
+ return True # Return true because we identified the type
146
+ else:
147
+ logger.warning(f"Cgroup v1 quota/period values invalid? Quota: {quota_us}, Period: {period_us}")
148
+
149
+ elif shares is not None: # If only shares are readable, still note it's v1
150
+ self.cgroup_type = "v1"
151
+ self.cgroup_shares = shares
152
+ logger.info(f"Cgroup v1 shares detected: {shares} (no quota found)")
153
+ return True
154
+
155
+ return False
156
+
157
+ def _read_cgroup_v2(self) -> bool:
158
+ """Attempts to read Cgroup v2 CPU limits."""
159
+ if not os.path.exists(CGROUP_V2_CPU_FILE):
160
+ logger.debug(f"Cgroup v2 cpu.max file not found: {CGROUP_V2_CPU_FILE}")
161
+ return False
162
+
163
+ logger.debug(f"Checking Cgroup v2 limits in {CGROUP_V2_CPU_FILE}")
164
+ content = self._read_file_str(CGROUP_V2_CPU_FILE)
165
+ if content:
166
+ self.cgroup_type = "v2"
167
+ parts = content.split()
168
+ if len(parts) == 2:
169
+ quota_str, period_str = parts
170
+ try:
171
+ period_us = int(period_str)
172
+ self.cgroup_period_us = period_us
173
+ if quota_str == "max":
174
+ logger.info("Cgroup v2 quota detected: Unlimited ('max')")
175
+ return True # Identified type, no quota limit
176
+ else:
177
+ quota_us = int(quota_str)
178
+ if quota_us > 0 and period_us > 0:
179
+ self.cgroup_quota_cores = quota_us / period_us
180
+ logger.info(
181
+ f"Cgroup v2 quota detected: {quota_us} us / {period_us}"
182
+ f" us = {self.cgroup_quota_cores:.2f} effective cores"
183
+ )
184
+ return True
185
+ else:
186
+ logger.warning(
187
+ f"Cgroup v2 quota/period values invalid? Quota: {quota_us}, Period: {period_us}"
188
+ )
189
+
190
+ except ValueError:
191
+ logger.warning(f"Could not parse Cgroup v2 cpu.max content: '{content}'")
192
+ else:
193
+ logger.warning(f"Unexpected format in Cgroup v2 cpu.max: '{content}'")
194
+ return False
195
+
196
+ @staticmethod
197
+ def _get_os_affinity() -> Optional[int]:
198
+ """Gets CPU count via os.sched_getaffinity."""
199
+ if platform.system() != "Linux":
200
+ logger.debug("os.sched_getaffinity is Linux-specific.")
201
+ return None
202
+ try:
203
+ # sched_getaffinity exists on Linux
204
+ affinity = os.sched_getaffinity(0) # 0 for current process
205
+ count = len(affinity)
206
+ if count > 0:
207
+ logger.info(f"Detected {count} cores via os.sched_getaffinity.")
208
+ return count
209
+ else:
210
+ logger.warning("os.sched_getaffinity(0) returned 0 or empty set.")
211
+ return None
212
+ except AttributeError:
213
+ logger.debug("os.sched_getaffinity not available on this platform/Python version.")
214
+ return None
215
+ except OSError as e:
216
+ logger.warning(f"Could not get affinity: {e}")
217
+ return None
218
+
219
+ @staticmethod
220
+ def _get_os_cpu_counts() -> Tuple[Optional[int], Optional[int]]:
221
+ """Gets logical and physical CPU counts using psutil or os.cpu_count."""
222
+ logical = None
223
+ physical = None
224
+ source = "unknown"
225
+
226
+ if psutil:
227
+ try:
228
+ logical = psutil.cpu_count(logical=True)
229
+ physical = psutil.cpu_count(logical=False)
230
+ source = "psutil"
231
+ if not logical:
232
+ logical = None # Ensure None if psutil returns 0/None
233
+ if not physical:
234
+ physical = None
235
+ except Exception as e:
236
+ logger.warning(f"psutil.cpu_count failed: {e}. Falling back to os.cpu_count.")
237
+ logical, physical = None, None # Reset before fallback
238
+
239
+ if logical is None: # Fallback if psutil failed or not installed
240
+ try:
241
+ logical = os.cpu_count()
242
+ source = "os.cpu_count"
243
+ # os.cpu_count doesn't usually provide physical count, leave as None
244
+ except NotImplementedError:
245
+ logger.error("os.cpu_count() is not implemented on this system.")
246
+ except Exception as e:
247
+ logger.error(f"os.cpu_count() failed: {e}")
248
+
249
+ if logical:
250
+ logger.info(f"Detected {logical} logical cores via {source}.")
251
+ if physical:
252
+ logger.info(f"Detected {physical} physical cores via {source}.")
253
+
254
+ return logical, physical
255
+
256
+ # --- Weighting Function ---
257
+ def _apply_hyperthread_weight(self, logical_limit: int) -> float:
258
+ """
259
+ Applies hyperthreading weight to an integer logical core limit.
260
+
261
+ Parameters
262
+ ----------
263
+ logical_limit : int
264
+ The maximum number of logical cores allowed (e.g., from affinity or OS count).
265
+
266
+ Returns
267
+ -------
268
+ float
269
+ The estimated effective core performance based on weighting.
270
+ Returns logical_limit if weighting cannot be applied.
271
+ """
272
+ P = self.os_physical_cores
273
+ # Weighting requires knowing both physical and logical counts
274
+ if P is not None and P > 0 and self.os_logical_cores is not None:
275
+ # Apply the heuristic: P physical cores + (N-P) hyperthreads * weight
276
+ # Ensure N is capped by the actual number of logical cores available
277
+ N = min(logical_limit, self.os_logical_cores)
278
+
279
+ physical_part = min(N, P)
280
+ hyperthread_part = max(0, N - P)
281
+
282
+ weighted_cores = (physical_part * 1.0) + (hyperthread_part * self.hyperthread_weight)
283
+
284
+ if weighted_cores != N: # Log only if weighting changes the value
285
+ logger.info(
286
+ f"Applying hyperthread weight ({self.hyperthread_weight:.2f}) to "
287
+ f"logical limit {logical_limit} (System: {P}P/{self.os_logical_cores}L): "
288
+ f"Effective weighted cores = {weighted_cores:.2f}"
289
+ )
290
+ else:
291
+ logger.debug(
292
+ f"Hyperthread weighting ({self.hyperthread_weight:.2f}) applied to "
293
+ f"logical limit {logical_limit} (System: {P}P/{self.os_logical_cores}L), "
294
+ f"but result is still {weighted_cores:.2f} (e.g., limit <= physical or weight=1.0)"
295
+ )
296
+ return weighted_cores
297
+ else:
298
+ # Cannot apply weighting
299
+ if self.hyperthread_weight != 1.0: # Only warn if weighting was requested
300
+ if not psutil:
301
+ # Already warned about missing psutil during init
302
+ pass
303
+ elif P is None:
304
+ logger.warning("Cannot apply hyperthread weight: Physical core count not available.")
305
+ else: # L must be missing
306
+ logger.warning("Cannot apply hyperthread weight: Logical core count not available.")
307
+
308
+ logger.debug(f"Skipping hyperthread weight calculation for logical limit {logical_limit}.")
309
+ return float(logical_limit) # Return the original limit as float
310
+
311
+ def _detect(self):
312
+ """Performs the detection sequence and applies weighting."""
313
+ logger.debug("Starting effective core count detection...")
314
+
315
+ # 1. Get OS level counts first
316
+ self.os_logical_cores, self.os_physical_cores = self._get_os_cpu_counts()
317
+
318
+ # 2. Try Cgroup v2
319
+ cgroup_detected = self._read_cgroup_v2()
320
+
321
+ # 3. Try Cgroup v1 if v2 not found or didn't yield quota
322
+ if not cgroup_detected or (self.cgroup_type == "v2" and self.cgroup_quota_cores is None):
323
+ cgroup_detected = self._read_cgroup_v1()
324
+
325
+ # 4. Get OS Affinity
326
+ self.os_sched_affinity_cores = self._get_os_affinity()
327
+
328
+ # --- 5. Determine the RAW Limit (before weighting) ---
329
+ raw_limit = float("inf")
330
+ raw_method = "unknown"
331
+
332
+ # Priority 1: Cgroup Quota
333
+ if self.cgroup_quota_cores is not None and self.cgroup_quota_cores > 0:
334
+ raw_limit = min(raw_limit, self.cgroup_quota_cores)
335
+ raw_method = f"cgroup_{self.cgroup_type}_quota"
336
+ logger.debug(f"Raw limit set by Cgroup Quota: {self.cgroup_quota_cores:.2f}")
337
+
338
+ # Priority 2: Scheduler Affinity
339
+ if self.os_sched_affinity_cores is not None and self.os_sched_affinity_cores > 0:
340
+ affinity_limit = float(self.os_sched_affinity_cores)
341
+ if affinity_limit < raw_limit:
342
+ raw_limit = affinity_limit
343
+ raw_method = "sched_affinity"
344
+ logger.debug(f"Raw limit updated by Sched Affinity: {affinity_limit}")
345
+ elif raw_method.startswith("cgroup"):
346
+ logger.debug(
347
+ f"Sched Affinity limit ({affinity_limit}) not stricter than Cgroup Quota ({raw_limit:.2f})."
348
+ )
349
+
350
+ # Priority 3: OS Logical Cores
351
+ if raw_limit == float("inf"): # If no cgroup quota or affinity was found/applied
352
+ if self.os_logical_cores is not None and self.os_logical_cores > 0:
353
+ raw_limit = float(self.os_logical_cores)
354
+ raw_method = "os_logical_count"
355
+ logger.debug(f"Raw limit set by OS Logical Core count: {self.os_logical_cores}")
356
+ else:
357
+ # Absolute fallback
358
+ logger.warning("Could not determine any CPU core limit. Defaulting raw limit to 1.0.")
359
+ raw_limit = 1.0
360
+ raw_method = "fallback_default"
361
+
362
+ self.raw_limit_value = raw_limit
363
+ self.raw_limit_method = raw_method
364
+ logger.info(f"Raw CPU limit determined: {self.raw_limit_value:.2f} (Method: {self.raw_limit_method})")
365
+
366
+ # --- 6. Apply Weighting (if applicable) ---
367
+ final_effective_cores = raw_limit
368
+ final_method = raw_method
369
+
370
+ # Apply weighting ONLY if the raw limit is NOT from a cgroup quota
371
+ # AND the limit is an integer (or effectively integer) core count
372
+ if not raw_method.startswith("cgroup_"):
373
+ # Check if raw_limit is effectively an integer
374
+ if abs(raw_limit - round(raw_limit)) < 1e-9 and raw_limit > 0:
375
+ logical_limit_int = int(round(raw_limit))
376
+ weighted_value = self._apply_hyperthread_weight(logical_limit_int)
377
+ final_effective_cores = weighted_value
378
+ # Update method if weighting was actually applied and changed the value
379
+ if abs(weighted_value - raw_limit) > 1e-9:
380
+ final_method = f"{raw_method}_weighted"
381
+ else:
382
+ # Keep original method name if weighting didn't change result
383
+ final_method = raw_method
384
+
385
+ else: # Raw limit was affinity/os count but not an integer? Should be rare.
386
+ logger.debug(
387
+ f"Raw limit method '{raw_method}' is not cgroup quota, "
388
+ f"but value {raw_limit:.2f} is not integer. Skipping weighting."
389
+ )
390
+
391
+ elif raw_method.startswith("cgroup_"):
392
+ logger.debug("Raw limit is from Cgroup quota. Using quota value directly (skipping SMT weighting).")
393
+
394
+ self.effective_cores = final_effective_cores
395
+ self.detection_method = final_method # The method for the final value
396
+
397
+ logger.info(
398
+ f"Effective CPU core limit determined: {self.effective_cores:.2f} " f"(Method: {self.detection_method})"
399
+ )
400
+
401
+ def get_effective_cores(self) -> Optional[float]:
402
+ """Returns the primary result: the effective core limit, potentially weighted."""
403
+ return self.effective_cores
404
+
405
+ def get_details(self) -> Dict[str, Any]:
406
+ """Returns a dictionary with all detected information."""
407
+ # Calculate full system weighted potential for info
408
+ os_weighted_cores = None
409
+ if self.os_physical_cores and self.os_logical_cores:
410
+ # Use weighting func with the total logical cores as the limit
411
+ os_weighted_cores = self._apply_hyperthread_weight(self.os_logical_cores)
412
+
413
+ return {
414
+ "effective_cores": self.effective_cores,
415
+ "detection_method": self.detection_method,
416
+ "raw_limit_value": self.raw_limit_value,
417
+ "raw_limit_method": self.raw_limit_method,
418
+ "hyperthread_weight_applied": self.hyperthread_weight,
419
+ "os_logical_cores": self.os_logical_cores,
420
+ "os_physical_cores": self.os_physical_cores,
421
+ "os_weighted_potential": os_weighted_cores, # Full system potential weighted
422
+ "os_sched_affinity_cores": self.os_sched_affinity_cores,
423
+ "cgroup_type": self.cgroup_type,
424
+ "cgroup_quota_cores": self.cgroup_quota_cores,
425
+ "cgroup_period_us": self.cgroup_period_us,
426
+ "cgroup_shares": self.cgroup_shares,
427
+ "cgroup_usage_total_us": self.cgroup_usage_total_us,
428
+ "cgroup_usage_percpu_us": self.cgroup_usage_percpu_us,
429
+ "platform": platform.system(),
430
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nv-ingest-api
3
- Version: 25.4.2
3
+ Version: 25.6.1
4
4
  Summary: Python module with core document ingestion functions.
5
5
  Author-email: Jeremy Dyer <jdyer@nvidia.com>
6
6
  License: Apache License
@@ -213,6 +213,7 @@ Classifier: License :: OSI Approved :: MIT License
213
213
  Classifier: Operating System :: OS Independent
214
214
  Description-Content-Type: text/markdown
215
215
  License-File: LICENSE
216
+ Requires-Dist: backoff==2.2.1
216
217
  Requires-Dist: pandas>=2.0
217
218
  Requires-Dist: pydantic>2.0.0
218
219
  Requires-Dist: pydantic-settings>2.0.0