clams-python 1.3.3__tar.gz → 1.5.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.
Files changed (38) hide show
  1. {clams_python-1.3.3/clams_python.egg-info → clams_python-1.5.0}/PKG-INFO +2 -2
  2. clams_python-1.5.0/VERSION +1 -0
  3. {clams_python-1.3.3 → clams_python-1.5.0}/clams/app/__init__.py +291 -14
  4. {clams_python-1.3.3 → clams_python-1.5.0}/clams/appmetadata/__init__.py +39 -19
  5. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/metadata.py.template +3 -0
  6. {clams_python-1.3.3 → clams_python-1.5.0}/clams/restify/__init__.py +57 -2
  7. clams_python-1.5.0/clams/ver/__init__.py +1 -0
  8. {clams_python-1.3.3 → clams_python-1.5.0/clams_python.egg-info}/PKG-INFO +2 -2
  9. {clams_python-1.3.3 → clams_python-1.5.0}/clams_python.egg-info/requires.txt +1 -1
  10. {clams_python-1.3.3 → clams_python-1.5.0}/requirements.txt +1 -1
  11. {clams_python-1.3.3 → clams_python-1.5.0}/tests/test_clamsapp.py +105 -14
  12. clams_python-1.3.3/VERSION +0 -1
  13. clams_python-1.3.3/clams/ver/__init__.py +0 -1
  14. {clams_python-1.3.3 → clams_python-1.5.0}/LICENSE +0 -0
  15. {clams_python-1.3.3 → clams_python-1.5.0}/MANIFEST.in +0 -0
  16. {clams_python-1.3.3 → clams_python-1.5.0}/README.md +0 -0
  17. {clams_python-1.3.3 → clams_python-1.5.0}/clams/__init__.py +0 -0
  18. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/__init__.py +0 -0
  19. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/.dockerignore.template +0 -0
  20. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/.gitignore.template +0 -0
  21. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/Containerfile.template +0 -0
  22. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/LICENSE.template +0 -0
  23. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/README.md.template +0 -0
  24. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/app.py.template +0 -0
  25. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/cli.py.template +0 -0
  26. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/requirements.txt.template +0 -0
  27. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/gha/for-clams-team.md.template +0 -0
  28. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/gha/workflows/issue-apps-project.yml.template +0 -0
  29. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/gha/workflows/issue-assign.yml.template +0 -0
  30. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/gha/workflows/issue-close.yml.template +0 -0
  31. {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/gha/workflows/publish.yml.template +0 -0
  32. {clams_python-1.3.3 → clams_python-1.5.0}/clams/serve/__init__.py +0 -0
  33. {clams_python-1.3.3 → clams_python-1.5.0}/clams_python.egg-info/SOURCES.txt +0 -0
  34. {clams_python-1.3.3 → clams_python-1.5.0}/clams_python.egg-info/dependency_links.txt +0 -0
  35. {clams_python-1.3.3 → clams_python-1.5.0}/clams_python.egg-info/entry_points.txt +0 -0
  36. {clams_python-1.3.3 → clams_python-1.5.0}/clams_python.egg-info/top_level.txt +0 -0
  37. {clams_python-1.3.3 → clams_python-1.5.0}/setup.cfg +0 -0
  38. {clams_python-1.3.3 → clams_python-1.5.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clams-python
3
- Version: 1.3.3
3
+ Version: 1.5.0
4
4
  Summary: A collection of APIs to develop CLAMS app for python
5
5
  Home-page: https://clams.ai
6
6
  Author: Brandeis Lab for Linguistics and Computation
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
14
14
  Requires-Python: >=3.10
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
- Requires-Dist: mmif-python==1.1.2
17
+ Requires-Dist: mmif-python==1.3.1
18
18
  Requires-Dist: Flask>=2
19
19
  Requires-Dist: Flask-RESTful>=0.3.9
20
20
  Requires-Dist: gunicorn>=20
@@ -0,0 +1 @@
1
+ 1.5.0
@@ -13,14 +13,29 @@ __all__ = ['ClamsApp']
13
13
  from typing import Union, Any, Optional, Dict, List, Tuple
14
14
 
15
15
  from mmif import Mmif, Document, DocumentTypes, View
16
+ from mmif.utils.video_document_helper import (
17
+ SamplingMode, SAMPLING_MODE_DESCRIPTIONS, SAMPLING_MODE_DEFAULT,
18
+ _sampling_mode,
19
+ )
20
+ from mmif.utils.workflow_helper import generate_param_hash # pytype: disable=import-error
16
21
  from clams.appmetadata import AppMetadata, real_valued_primitives, python_type, map_param_kv_delimiter
17
22
 
18
23
  logging.basicConfig(
19
- level=logging.WARNING,
24
+ level=getattr(logging, os.environ.get('CLAMS_LOGLEVEL', 'WARNING').upper(), logging.WARNING),
20
25
  format="%(asctime)s %(name)s %(levelname)-8s %(thread)d %(message)s",
21
26
  datefmt="%Y-%m-%d %H:%M:%S")
22
27
 
23
28
 
29
+ _sampling_mode_choices = [m.value for m in SamplingMode]
30
+ _sampling_mode_description = (
31
+ 'Sampling mode for TimeFrame annotations. '
32
+ 'Has no effect when the app does not process TimeFrames. '
33
+ + ' '.join(
34
+ f'"{m.value}" {SAMPLING_MODE_DESCRIPTIONS[m]}'
35
+ for m in SamplingMode
36
+ )
37
+ )
38
+
24
39
  falsy_values = [
25
40
  'False',
26
41
  'false',
@@ -47,13 +62,26 @@ class ClamsApp(ABC):
47
62
  'description': 'The JSON body of the HTTP response will be re-formatted with 2-space indentation',
48
63
  },
49
64
  {
50
- 'name': 'runningTime', 'type': 'boolean', 'choices': None, 'default': False, 'multivalued': False,
65
+ 'name': 'runningTime', 'type': 'boolean', 'choices': None, 'default': True, 'multivalued': False,
51
66
  'description': 'The running time of the app will be recorded in the view metadata',
52
67
  },
53
68
  {
54
69
  'name': 'hwFetch', 'type': 'boolean', 'choices': None, 'default': False, 'multivalued': False,
55
70
  'description': 'The hardware information (architecture, GPU and vRAM) will be recorded in the view metadata',
56
71
  },
72
+ # tfSamplingMode is universal (not per-app) because it controls
73
+ # how vdh.extract_frames_by_mode() selects frames from TimeFrames.
74
+ # The value is intercepted in annotate() and pushed into a
75
+ # contextvars.ContextVar so that any vdh call inside _annotate()
76
+ # picks it up automatically — app developers never need to handle
77
+ # this parameter themselves.
78
+ {
79
+ 'name': 'tfSamplingMode', 'type': 'string',
80
+ 'choices': _sampling_mode_choices,
81
+ 'default': SAMPLING_MODE_DEFAULT.value,
82
+ 'multivalued': False,
83
+ 'description': _sampling_mode_description,
84
+ },
57
85
  ]
58
86
 
59
87
  # this key is used to store users' raw input params in the parameter dict
@@ -76,7 +104,7 @@ class ClamsApp(ABC):
76
104
 
77
105
  self.metadata_param_caster = ParameterCaster(self.metadata_param_spec)
78
106
  self.annotate_param_caster = ParameterCaster(self.annotate_param_spec)
79
- self.logger = logging.getLogger(self.metadata.identifier)
107
+ self.logger = logging.getLogger(str(self.metadata.identifier))
80
108
 
81
109
  def appmetadata(self, **kwargs: List[str]) -> str:
82
110
  """
@@ -137,6 +165,7 @@ class ClamsApp(ABC):
137
165
  """
138
166
  if not isinstance(mmif, Mmif):
139
167
  mmif = Mmif(mmif)
168
+ existing_view_ids = {view.id for view in mmif.views}
140
169
  issued_warnings = []
141
170
  for key in runtime_params:
142
171
  if key not in self.annotate_param_spec:
@@ -146,35 +175,52 @@ class ClamsApp(ABC):
146
175
  refined = self._refine_params(**runtime_params)
147
176
  self.logger.debug(f"Refined parameters: {refined}")
148
177
  pretty = refined.get('pretty', False)
178
+ sampling_mode_str = refined.pop('tfSamplingMode', None)
179
+ if sampling_mode_str is not None:
180
+ _sampling_mode.set(SamplingMode(sampling_mode_str))
149
181
  t = datetime.now()
150
182
  with warnings.catch_warnings(record=True) as ws:
151
- annotated = self._annotate(mmif, **refined)
183
+ annotated, cuda_profiler = self._profile_cuda_memory(self._annotate)(mmif, **refined)
152
184
  if ws:
153
185
  issued_warnings.extend(ws)
154
186
  if issued_warnings:
155
187
  warnings_view = annotated.new_view()
156
188
  self.sign_view(warnings_view, refined)
157
189
  warnings_view.metadata.warnings = issued_warnings
158
- td = datetime.now() - t
190
+ run_id = datetime.now()
191
+ td = run_id - t
159
192
  runningTime = refined.get('runningTime', False)
160
193
  hwFetch = refined.get('hwFetch', False)
161
194
  runtime_recs = {}
162
195
  if hwFetch:
196
+ import multiprocessing
163
197
  import platform, shutil, subprocess
164
- runtime_recs['architecture'] = platform.machine()
165
- # runtime_recs['processor'] = platform.processor() # this only works on Windows
198
+ runtime_recs['cpu'] = f"{platform.machine()}, {multiprocessing.cpu_count()} cores"
166
199
  runtime_recs['cuda'] = []
167
- if shutil.which('nvidia-smi'):
200
+ # Use cuda_profiler data if available, otherwise fallback to nvidia-smi
201
+ if cuda_profiler:
202
+ for gpu_name, mem_info in cuda_profiler.items():
203
+ total_str = self._cuda_memory_to_str(mem_info['total'])
204
+ available_str = self._cuda_memory_to_str(mem_info['available_before'])
205
+ peak_str = self._cuda_memory_to_str(mem_info['peak'])
206
+ runtime_recs['cuda'].append(
207
+ f"{gpu_name}, {total_str} total, {available_str} available, {peak_str} peak used"
208
+ )
209
+ elif shutil.which('nvidia-smi'):
168
210
  for gpu in subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader'],
169
211
  stdout=subprocess.PIPE).stdout.decode('utf-8').strip().split('\n'):
170
212
  name, mem = gpu.split(', ')
171
- runtime_recs['cuda'].append(f'{name} ({mem})')
213
+ runtime_recs['cuda'].append(self._cuda_device_name_concat(name, mem))
172
214
  for annotated_view in annotated.views:
173
- if annotated_view.metadata.app == self.metadata.identifier:
215
+ if annotated_view.id not in existing_view_ids and annotated_view.metadata.app == str(self.metadata.identifier):
216
+ annotated_view.metadata.timestamp = run_id
217
+ profiling_data = {}
174
218
  if runningTime:
175
- annotated_view.metadata.set_additional_property('appRunningTime', str(td))
219
+ profiling_data['runningTime'] = str(td)
176
220
  if len(runtime_recs) > 0:
177
- annotated_view.metadata.set_additional_property('appRunningHardware', runtime_recs)
221
+ profiling_data['hardware'] = runtime_recs
222
+ if profiling_data:
223
+ annotated_view.metadata.set_additional_property('appProfiling', profiling_data)
178
224
 
179
225
  return annotated.serialize(pretty=pretty, sanitize=True)
180
226
 
@@ -252,7 +298,7 @@ class ClamsApp(ABC):
252
298
  :param view: a view to sign
253
299
  :param runtime_conf: runtime configuration of the app as k-v pairs
254
300
  """
255
- view.metadata.app = self.metadata.identifier
301
+ view.metadata.app = str(self.metadata.identifier)
256
302
  params_map = {p.name: p for p in self.metadata.parameters}
257
303
  if self._RAW_PARAMS_KEY in runtime_conf:
258
304
  for k, v in runtime_conf.items():
@@ -287,7 +333,7 @@ class ClamsApp(ABC):
287
333
  mmif = Mmif(mmif)
288
334
  error_view: Optional[View] = None
289
335
  for view in reversed(mmif.views):
290
- if view.metadata.app == self.metadata.identifier:
336
+ if view.metadata.app == str(self.metadata.identifier):
291
337
  error_view = view
292
338
  break
293
339
  if error_view is None:
@@ -321,6 +367,237 @@ class ClamsApp(ABC):
321
367
  # (https://github.com/clamsproject/mmif/issues/150) , here is a good place for additional check for
322
368
  # file integrity
323
369
 
370
+ @staticmethod
371
+ def _cuda_memory_to_str(mem) -> str:
372
+ mib = mem / (1024 * 1024)
373
+ return f"{mib:.0f} MiB" # No decimal places
374
+
375
+ @staticmethod
376
+ def _cuda_device_name_concat(name, mem):
377
+ if type(mem) in (bytes, int):
378
+ mem = ClamsApp._cuda_memory_to_str(mem)
379
+ return f"{name}, With {mem}"
380
+
381
+ def _get_profile_path(self, param_hash: str) -> pathlib.Path:
382
+ """
383
+ Get filesystem path for memory profile file.
384
+
385
+ Profile files are stored in a per-app directory under user's cache.
386
+
387
+ :param param_hash: Hash of parameters from :func:`mmif.utils.cli.describe.generate_param_hash`
388
+ :return: Path to the profile file
389
+ """
390
+ # Sanitize app identifier for filesystem use
391
+ app_id = str(self.metadata.identifier).replace('/', '-').replace(':', '-')
392
+ cache_base = pathlib.Path(os.environ.get('XDG_CACHE_HOME', pathlib.Path.home() / '.cache'))
393
+ cache_dir = cache_base / 'clams' / 'memory_profiles' / app_id
394
+ return cache_dir / f"memory_{param_hash}.json"
395
+
396
+ @staticmethod
397
+ def _get_available_vram() -> int:
398
+ """
399
+ Get currently available VRAM in bytes (GPU-wide, across all processes).
400
+
401
+ Uses nvidia-smi to get actual available memory, not just current process.
402
+
403
+ :return: Available VRAM in bytes, or 0 if unavailable
404
+ """
405
+ try:
406
+ import subprocess
407
+ import shutil
408
+ if shutil.which('nvidia-smi'):
409
+ # Get free memory from nvidia-smi (reports GPU-wide, not per-process)
410
+ result = subprocess.run(
411
+ ['nvidia-smi', '--query-gpu=memory.free', '--format=csv,noheader,nounits', '-i', '0'],
412
+ capture_output=True, text=True, timeout=5
413
+ )
414
+ if result.returncode == 0 and result.stdout.strip():
415
+ free_mb = float(result.stdout.strip())
416
+ return int(free_mb * 1024 * 1024) # Convert MB to bytes
417
+ except Exception:
418
+ pass
419
+
420
+ # Fallback to torch (only sees current process memory)
421
+ try:
422
+ import torch # pytype: disable=import-error
423
+ if not torch.cuda.is_available():
424
+ return 0
425
+
426
+ device = torch.cuda.current_device()
427
+ total = torch.cuda.get_device_properties(device).total_memory
428
+ used = max(torch.cuda.memory_allocated(device),
429
+ torch.cuda.memory_reserved(device))
430
+ return total - used
431
+ except Exception:
432
+ return 0
433
+
434
+ def _record_vram_usage(self, parameters: dict, peak_bytes: int) -> None:
435
+ """
436
+ Record peak memory usage to profile file.
437
+
438
+ Uses atomic write (temp + rename) to avoid corruption from
439
+ concurrent writes. Only updates if new value is higher.
440
+
441
+ Profile files are JSON containing:
442
+ - peak_bytes: Peak VRAM usage by the torch process
443
+ - parameters: Original parameters for human readability
444
+
445
+ :param parameters: Request parameters (for hash and recording)
446
+ :param peak_bytes: Measured peak VRAM usage
447
+ """
448
+ import json
449
+
450
+ if peak_bytes <= 0:
451
+ return
452
+
453
+ param_hash = generate_param_hash(parameters)
454
+ profile_path = self._get_profile_path(param_hash)
455
+
456
+ try:
457
+ profile_path.parent.mkdir(parents=True, exist_ok=True)
458
+
459
+ # Check if we should update
460
+ should_write = True
461
+ if profile_path.exists():
462
+ try:
463
+ existing_data = json.loads(profile_path.read_text())
464
+ existing = existing_data.get('peak_bytes', 0)
465
+ if peak_bytes <= existing:
466
+ should_write = False # Existing value is sufficient
467
+ else:
468
+ self.logger.debug(
469
+ f"Updating peak memory for {param_hash}: "
470
+ f"{self._cuda_memory_to_str(existing)} -> {self._cuda_memory_to_str(peak_bytes)}"
471
+ )
472
+ except (ValueError, IOError, json.JSONDecodeError):
473
+ pass # Corrupted file, overwrite
474
+
475
+ if should_write:
476
+ # Prepare profile data with original parameters for readability
477
+ # Filter out internal keys and non-serializable values
478
+ clean_params = {
479
+ k: v for k, v in parameters.items()
480
+ if k != self._RAW_PARAMS_KEY and not k.startswith('#')
481
+ }
482
+ profile_data = {
483
+ 'peak_bytes': peak_bytes,
484
+ 'parameters': clean_params
485
+ }
486
+
487
+ # Atomic write: write to temp, then rename
488
+ temp_path = profile_path.with_suffix('.tmp')
489
+ temp_path.write_text(json.dumps(profile_data, indent=2))
490
+ temp_path.rename(profile_path) # Atomic on POSIX
491
+
492
+ self.logger.info(
493
+ f"Recorded peak memory for {param_hash}: "
494
+ f"{self._cuda_memory_to_str(peak_bytes)}"
495
+ )
496
+ except Exception as e:
497
+ self.logger.warning(f"Failed to record memory profile: {e}")
498
+
499
+ @staticmethod
500
+ def _profile_cuda_memory(func):
501
+ """
502
+ Decorator for profiling CUDA memory usage and managing VRAM availability.
503
+
504
+ This decorator:
505
+ 1. Checks VRAM requirements before execution (if conditions met)
506
+ 2. Rejects requests if insufficient VRAM
507
+ 3. Records peak memory usage after execution
508
+ 4. Calls empty_cache() for cleanup
509
+
510
+ :param func: The function to wrap (typically _annotate)
511
+ :return: Decorated function that returns (result, cuda_profiler)
512
+ where cuda_profiler is dict with "<GPU_NAME>, <GPU_TOTAL_MEMORY>" keys
513
+ and dict values containing 'available_before' and 'peak' memory in bytes
514
+ """
515
+ def wrapper(*args, **kwargs):
516
+ # Get the ClamsApp instance from the bound method
517
+ app_instance = getattr(func, '__self__', None)
518
+
519
+ cuda_profiler = {}
520
+ torch_available = False
521
+ cuda_available = False
522
+ device_count = 0
523
+ available_before = {}
524
+
525
+ try:
526
+ import torch # pytype: disable=import-error
527
+ torch_available = True
528
+ cuda_available = torch.cuda.is_available()
529
+ device_count = torch.cuda.device_count()
530
+ except ImportError:
531
+ pass
532
+
533
+ # Capture available VRAM before execution and reset stats
534
+ if torch_available and cuda_available:
535
+ for device_id in range(device_count):
536
+ device_id_str = f'cuda:{device_id}'
537
+ # Get GPU-wide available memory via nvidia-smi
538
+ try:
539
+ import subprocess
540
+ import shutil
541
+ if shutil.which('nvidia-smi'):
542
+ result = subprocess.run(
543
+ ['nvidia-smi', '--query-gpu=memory.free',
544
+ '--format=csv,noheader,nounits', '-i', str(device_id)],
545
+ capture_output=True, text=True, timeout=5
546
+ )
547
+ if result.returncode == 0 and result.stdout.strip():
548
+ free_mb = float(result.stdout.strip())
549
+ available_before[device_id] = int(free_mb * 1024 * 1024)
550
+ else:
551
+ # Fallback to torch (process-specific)
552
+ total = torch.cuda.get_device_properties(device_id_str).total_memory
553
+ allocated = torch.cuda.memory_allocated(device_id_str)
554
+ available_before[device_id] = total - allocated
555
+ else:
556
+ # Fallback to torch (process-specific)
557
+ total = torch.cuda.get_device_properties(device_id_str).total_memory
558
+ allocated = torch.cuda.memory_allocated(device_id_str)
559
+ available_before[device_id] = total - allocated
560
+ except Exception:
561
+ # Fallback to torch (process-specific)
562
+ total = torch.cuda.get_device_properties(device_id_str).total_memory
563
+ allocated = torch.cuda.memory_allocated(device_id_str)
564
+ available_before[device_id] = total - allocated
565
+ # Reset peak memory stats for all devices
566
+ torch.cuda.reset_peak_memory_stats('cuda')
567
+
568
+ try:
569
+ result = func(*args, **kwargs)
570
+
571
+ # Record peak memory usage
572
+ total_peak = 0
573
+ if torch_available and cuda_available and device_count > 0:
574
+ for device_id in range(device_count):
575
+ device_id_str = f'cuda:{device_id}'
576
+ peak_memory = torch.cuda.max_memory_allocated(device_id_str)
577
+ total_peak = max(total_peak, peak_memory)
578
+ gpu_name = torch.cuda.get_device_name(device_id_str)
579
+ gpu_total_memory = torch.cuda.get_device_properties(device_id_str).total_memory
580
+ cuda_profiler[gpu_name] = {
581
+ 'total': gpu_total_memory,
582
+ 'available_before': available_before.get(device_id, 0),
583
+ 'peak': peak_memory
584
+ }
585
+
586
+ # Record peak memory for future requests (if GPU app)
587
+ gpu_app = (
588
+ hasattr(app_instance, 'metadata') and
589
+ getattr(app_instance.metadata, 'est_gpu_mem_min', 0) > 0
590
+ )
591
+ if gpu_app and total_peak > 0:
592
+ app_instance._record_vram_usage(kwargs, total_peak)
593
+
594
+ return result, cuda_profiler
595
+ finally:
596
+ if torch_available and cuda_available:
597
+ torch.cuda.empty_cache()
598
+
599
+ return wrapper
600
+
324
601
  @staticmethod
325
602
  @contextmanager
326
603
  def open_document_location(document: Union[str, Document], opener: Any = open, **openerargs):
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import os
2
3
  import shutil
3
4
  import subprocess
@@ -131,11 +132,6 @@ class Output(_BaseModel):
131
132
  def __init__(self, **kwargs):
132
133
  super().__init__(**kwargs)
133
134
 
134
- @pydantic.field_validator('at_type', mode='after') # because pydantic v2 doesn't auto-convert url to string
135
- @classmethod
136
- def stringify(cls, val):
137
- return str(val)
138
-
139
135
  @pydantic.field_validator('at_type', mode='before')
140
136
  @classmethod
141
137
  def at_type_must_be_str(cls, v):
@@ -295,6 +291,10 @@ class AppMetadata(pydantic.BaseModel):
295
291
  None,
296
292
  description="(optional) Version of an analyzer software, if the app is working as a wrapper for one. "
297
293
  )
294
+ analyzer_versions: Optional[Dict[str, str]] = pydantic.Field(
295
+ None,
296
+ description="(optional) Map of analyzer IDs to their versions, for apps that wrap multiple models or families."
297
+ )
298
298
  app_license: str = pydantic.Field(
299
299
  ...,
300
300
  description="License information of the app."
@@ -352,9 +352,20 @@ class AppMetadata(pydantic.BaseModel):
352
352
  "a package name and its version in the string value at the minimum (e.g., ``clams-python==1.2.3``)."
353
353
  )
354
354
  more: Optional[Dict[str, str]] = pydantic.Field(
355
- None,
355
+ None,
356
356
  description="(optional) A string-to-string map that can be used to store any additional metadata of the app."
357
357
  )
358
+ est_gpu_mem_min: int = pydantic.Field(
359
+ 0,
360
+ description="(optional) Minimum GPU memory required to run the app, in megabytes (MB). "
361
+ "Set to 0 (default) if the app does not use GPU."
362
+ )
363
+ est_gpu_mem_typ: int = pydantic.Field(
364
+ 0,
365
+ description="(optional) Typical GPU memory usage for default parameters, in megabytes (MB). "
366
+ "Must be equal or larger than est_gpu_mem_min. "
367
+ "Set to 0 (default) if the app does not use GPU."
368
+ )
358
369
 
359
370
  model_config = {
360
371
  'title': 'CLAMS AppMetadata',
@@ -364,13 +375,26 @@ class AppMetadata(pydantic.BaseModel):
364
375
  }
365
376
 
366
377
  @pydantic.model_validator(mode='after')
367
- @classmethod
368
- def assign_versions(cls, data):
369
- if data.app_version == '':
370
- data.app_version = generate_app_version()
371
- if data.mmif_version == '':
372
- data.mmif_version = get_mmif_specver()
373
- return data
378
+ def assign_versions(self):
379
+ if self.app_version == '':
380
+ self.app_version = generate_app_version()
381
+ if self.mmif_version == '':
382
+ self.mmif_version = get_mmif_specver()
383
+ return self
384
+
385
+ @pydantic.model_validator(mode='after')
386
+ def validate_gpu_memory(self):
387
+ import warnings
388
+ if self.est_gpu_mem_typ > 0 and self.est_gpu_mem_min > 0:
389
+ if self.est_gpu_mem_typ < self.est_gpu_mem_min:
390
+ warnings.warn(
391
+ f"est_gpu_mem_typ ({self.est_gpu_mem_typ} MB) is less than "
392
+ f"est_gpu_mem_min ({self.est_gpu_mem_min} MB). "
393
+ f"Setting est_gpu_mem_typ to {self.est_gpu_mem_min} MB.",
394
+ UserWarning
395
+ )
396
+ self.est_gpu_mem_typ = self.est_gpu_mem_min
397
+ return self
374
398
 
375
399
  @pydantic.field_validator('identifier', mode='before')
376
400
  @classmethod
@@ -379,11 +403,7 @@ class AppMetadata(pydantic.BaseModel):
379
403
  suffix = generate_app_version()
380
404
  return '/'.join(map(lambda x: x.strip('/'), filter(None, (prefix, val, suffix))))
381
405
 
382
- @pydantic.field_validator('url', 'identifier', mode='after') # because pydantic v2 doesn't auto-convert url to string
383
- @classmethod
384
- def stringify(cls, val):
385
- return str(val)
386
-
406
+
387
407
  def _check_input_duplicate(self, a_input):
388
408
  for elem in self.input:
389
409
  if isinstance(elem, list):
@@ -504,4 +524,4 @@ class AppMetadata(pydantic.BaseModel):
504
524
 
505
525
 
506
526
  if __name__ == '__main__':
507
- print(AppMetadata.schema_json(indent=2))
527
+ print(json.dumps(AppMetadata.model_json_schema(), indent=2))
@@ -39,6 +39,9 @@ def appmetadata() -> AppMetadata:
39
39
  # this trick can also be useful (replace ANALYZER_NAME with the pypi dist name)
40
40
  analyzer_version=[l.strip().rsplit('==')[-1] for l in open(pathlib.Path(__file__).parent / 'requirements.txt').readlines() if re.match(r'^ANALYZER_NAME==', l)][0],
41
41
  analyzer_license="", # short name for a software license
42
+ # GPU memory estimates (in MB). Set to 0 if the app does not use GPU.
43
+ est_gpu_mem_min=0, # estimated memory usage with minimal computation parameters
44
+ est_gpu_mem_typ=0, # estimated memory usage with default parameters, must be >= est_gpu_mem_min
42
45
  )
43
46
  # and then add I/O specifications: an app must have at least one input and one output
44
47
  metadata.add_input(DocumentTypes.Document)
@@ -42,14 +42,55 @@ class Restifier(object):
42
42
  def serve_production(self, **options):
43
43
  """
44
44
  Runs the CLAMS app as a flask webapp, using a production-ready web server (gunicorn, https://docs.gunicorn.org/en/stable/#).
45
-
45
+
46
46
  :param options: any additional options to pass to the web server.
47
47
  """
48
48
  import gunicorn.app.base
49
49
  import multiprocessing
50
+ import os
50
51
 
51
52
  def number_of_workers():
52
- return (multiprocessing.cpu_count() * 2) + 1 # +1 to make sure at least two workers are running
53
+ # Allow override via environment variable
54
+ if 'CLAMS_GUNICORN_WORKERS' in os.environ:
55
+ return int(os.environ['CLAMS_GUNICORN_WORKERS'])
56
+
57
+ cpu_workers = (multiprocessing.cpu_count() * 2) + 1
58
+
59
+ # Get GPU memory requirement from app metadata
60
+ # Use est_gpu_mem_typ (typical usage) for worker calculation
61
+ try:
62
+ metadata = self.cla.metadata
63
+ gpu_mem_mb = metadata.est_gpu_mem_typ # typical usage determines how many workers fit
64
+ except Exception:
65
+ gpu_mem_mb = 0
66
+
67
+ if gpu_mem_mb <= 0:
68
+ return cpu_workers
69
+
70
+ # Calculate workers based on total VRAM of the first CUDA device (no other GPUs are considered for now)
71
+ # Use nvidia-smi instead of torch to avoid initializing CUDA in parent process before fork
72
+ try:
73
+ import subprocess
74
+ import shutil
75
+ if shutil.which('nvidia-smi'):
76
+ result = subprocess.run(
77
+ ['nvidia-smi', '--query-gpu=memory.total', '--format=csv,noheader,nounits', '-i', '0'],
78
+ capture_output=True, text=True, timeout=5
79
+ )
80
+ if result.returncode == 0 and result.stdout.strip():
81
+ total_vram_mb = float(result.stdout.strip())
82
+ vram_workers = max(1, int(total_vram_mb // gpu_mem_mb))
83
+ workers = min(vram_workers, cpu_workers)
84
+ self.cla.logger.info(
85
+ f"GPU detected: {total_vram_mb:.0f} MB VRAM, "
86
+ f"app requires {gpu_mem_mb} MB, "
87
+ f"using {workers} workers (max {vram_workers} by VRAM, {cpu_workers} by CPU)"
88
+ )
89
+ return workers
90
+ except Exception:
91
+ pass
92
+
93
+ return cpu_workers
53
94
 
54
95
  class ProductionApplication(gunicorn.app.base.BaseApplication):
55
96
 
@@ -58,9 +99,16 @@ class Restifier(object):
58
99
  'bind': f'{host}:{port}',
59
100
  'workers': number_of_workers(),
60
101
  'threads': 2,
102
+ # disable timeout for long-running GPU workloads (default 30s is too short)
103
+ 'timeout': 0,
61
104
  # because the default is 'None'
62
105
  'accesslog': '-',
63
106
  # errorlog, however, is redirected to stderr by default since 19.2, so no need to set
107
+ # log level is warning by default
108
+ 'loglevel': os.environ.get('CLAMS_LOGLEVEL', 'warning').lower(),
109
+ # default to 1 to free GPU memory after each request
110
+ # developers can override via serve_production(max_requests=N) for single-model apps
111
+ 'max_requests': 1,
64
112
  }
65
113
  self.options.update(options)
66
114
  self.application = app
@@ -75,6 +123,13 @@ class Restifier(object):
75
123
  def load(self):
76
124
  return self.application
77
125
 
126
+ # Log max_requests setting
127
+ max_req = options.get('max_requests', 1) # default is 1, meaning workers are killed after each request
128
+ if max_req == 0:
129
+ self.cla.logger.info("Worker recycling: disabled (workers persist)")
130
+ else:
131
+ self.cla.logger.info(f"Worker recycling: after {max_req} request(s)")
132
+
78
133
  ProductionApplication(self.flask_app, self.host, self.port, **options).run()
79
134
 
80
135
  def serve_development(self, **options):
@@ -0,0 +1 @@
1
+ __version__ = "1.5.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clams-python
3
- Version: 1.3.3
3
+ Version: 1.5.0
4
4
  Summary: A collection of APIs to develop CLAMS app for python
5
5
  Home-page: https://clams.ai
6
6
  Author: Brandeis Lab for Linguistics and Computation
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
14
14
  Requires-Python: >=3.10
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
- Requires-Dist: mmif-python==1.1.2
17
+ Requires-Dist: mmif-python==1.3.1
18
18
  Requires-Dist: Flask>=2
19
19
  Requires-Dist: Flask-RESTful>=0.3.9
20
20
  Requires-Dist: gunicorn>=20
@@ -1,4 +1,4 @@
1
- mmif-python==1.1.2
1
+ mmif-python==1.3.1
2
2
  Flask>=2
3
3
  Flask-RESTful>=0.3.9
4
4
  gunicorn>=20
@@ -1,4 +1,4 @@
1
- mmif-python==1.1.2
1
+ mmif-python==1.3.1
2
2
 
3
3
  Flask>=2
4
4
  Flask-RESTful>=0.3.9
@@ -85,7 +85,7 @@ class ExampleClamsApp(clams.app.ClamsApp):
85
85
  class TestClamsApp(unittest.TestCase):
86
86
 
87
87
  def setUp(self):
88
- self.appmetadataschema = json.loads(AppMetadata.schema_json())
88
+ self.appmetadataschema = AppMetadata.model_json_schema()
89
89
  self.app = ExampleClamsApp()
90
90
  self.in_mmif = ExampleInputMMIF.get_mmif()
91
91
 
@@ -234,12 +234,12 @@ class TestClamsApp(unittest.TestCase):
234
234
  m = Mmif(self.in_mmif)
235
235
  v1 = m.new_view()
236
236
  self.app.sign_view(v1, {})
237
- self.assertEqual(v1.metadata.app, self.app.metadata.identifier)
237
+ self.assertEqual(v1.metadata.app, str(self.app.metadata.identifier))
238
238
  self.assertEqual(len(v1.metadata.parameters), 0)
239
239
  v2 = m.new_view()
240
240
  args2 = {'undefined_param1': ['value1']} # values are lists as our restifier uses `to_dict(flat=False)`
241
241
  self.app.sign_view(v2, self.app._refine_params(**args2))
242
- self.assertEqual(v2.metadata.app, self.app.metadata.identifier)
242
+ self.assertEqual(v2.metadata.app, str(self.app.metadata.identifier))
243
243
  self.assertEqual(len(v2.metadata.parameters), 1)
244
244
  self.assertFalse(clams.ClamsApp._RAW_PARAMS_KEY in v2.metadata.appConfiguration)
245
245
  for param in self.app.metadata.parameters:
@@ -253,14 +253,14 @@ class TestClamsApp(unittest.TestCase):
253
253
  args3 = {'undefined_param1': ['value1']}
254
254
  self.app.sign_view(v3, self.app._refine_params(**args3))
255
255
  self.assertEqual(len(v3.metadata.parameters), 1)
256
- self.assertEqual(len(v3.metadata.appConfiguration), 5) # universal (pretty, runningtime, hwfetch) + `raise_error` in metadata.py + `multivalued_param`
256
+ self.assertEqual(len(v3.metadata.appConfiguration), 6) # universal (pretty, runningTime, hwFetch, tfSamplingMode) + `raise_error` in metadata.py + `multivalued_param`
257
257
  self.assertEqual(v3.metadata.appConfiguration['multivalued_param'], [])
258
258
  v4 = m.new_view()
259
259
  multiple_values = ['value1', 'value2']
260
260
  args4 = {'multivalued_param': multiple_values}
261
261
  self.app.sign_view(v4, self.app._refine_params(**args4))
262
262
  self.assertEqual(len(v4.metadata.parameters), 1)
263
- self.assertEqual(len(v4.metadata.appConfiguration), 5)
263
+ self.assertEqual(len(v4.metadata.appConfiguration), 6)
264
264
  self.assertEqual(len(v4.metadata.parameters['multivalued_param']), len(str(multiple_values)))
265
265
 
266
266
  def test_annotate(self):
@@ -271,19 +271,41 @@ class TestClamsApp(unittest.TestCase):
271
271
  out_mmif = Mmif(out_mmif)
272
272
  self.assertEqual(len(out_mmif.views), 2)
273
273
  for v in out_mmif.views:
274
- if v.metadata.app == self.app.metadata.identifier:
274
+ if v.metadata.app == str(self.app.metadata.identifier):
275
275
  self.assertEqual(len(v.metadata.parameters), 0) # no params were passed when `annotate()` was called
276
276
  out_mmif = self.app.annotate(self.in_mmif, pretty=[str(False)])
277
277
  out_mmif = Mmif(out_mmif)
278
278
  for v in out_mmif.views:
279
- if v.metadata.app == self.app.metadata.identifier:
279
+ if v.metadata.app == str(self.app.metadata.identifier):
280
280
  self.assertEqual(len(v.metadata.parameters), 1) # 'pretty` parameter was passed
281
281
  out_mmif = Mmif(self.app.annotate(out_mmif))
282
282
  self.assertEqual(len(out_mmif.views), 4)
283
283
  views = list(out_mmif.views)
284
284
  # insertion order is kept
285
- self.assertTrue(views[0].metadata.timestamp < views[1].metadata.timestamp)
286
-
285
+ self.assertEqual(views[0].metadata.timestamp, views[1].metadata.timestamp)
286
+ self.assertEqual(views[2].metadata.timestamp, views[3].metadata.timestamp)
287
+ self.assertTrue(views[1].metadata.timestamp < views[2].metadata.timestamp)
288
+
289
+ def test_run_id(self):
290
+ # first run
291
+ out_mmif = Mmif(self.app.annotate(self.in_mmif))
292
+ app_views = [v for v in out_mmif.views if v.metadata.app == str(self.app.metadata.identifier)]
293
+ self.assertTrue(len(app_views) > 0)
294
+ first_timestamp = app_views[0].metadata.timestamp
295
+ for view in app_views[1:]:
296
+ self.assertEqual(first_timestamp, view.metadata.timestamp)
297
+ # second run
298
+ out_mmif2 = Mmif(self.app.annotate(out_mmif))
299
+ app_views2 = [v for v in out_mmif2.views if v.metadata.app == str(self.app.metadata.identifier)]
300
+ self.assertEqual(len(app_views2), len(app_views) * 2)
301
+ second_timestamp = app_views2[-1].metadata.timestamp
302
+ self.assertNotEqual(first_timestamp, second_timestamp)
303
+ for view in app_views2:
304
+ if view.id in [v.id for v in app_views]:
305
+ self.assertEqual(first_timestamp, view.metadata.timestamp)
306
+ else:
307
+ self.assertEqual(second_timestamp, view.metadata.timestamp)
308
+
287
309
  def test_annotate_returns_invalid_mmif(self):
288
310
  m = Mmif(self.in_mmif)
289
311
  v = m.new_view()
@@ -297,13 +319,13 @@ class TestClamsApp(unittest.TestCase):
297
319
 
298
320
  def test_open_document_location(self):
299
321
  mmif = ExampleInputMMIF.get_rawmmif()
300
- with self.app.open_document_location(mmif.documents['t1']) as f:
322
+ with self.app.open_document_location(mmif['t1']) as f:
301
323
  self.assertEqual(f.read(), ExampleInputMMIF.EXAMPLE_TEXT)
302
324
 
303
325
  def test_open_document_location_custom_opener(self):
304
326
  from PIL import Image
305
327
  mmif = ExampleInputMMIF.get_rawmmif()
306
- with self.app.open_document_location(mmif.documents['i1'], Image.open) as f:
328
+ with self.app.open_document_location(mmif['i1'], Image.open) as f:
307
329
  self.assertEqual(f.size, (200, 71))
308
330
 
309
331
  def test_refine_parameters(self):
@@ -355,20 +377,89 @@ class TestClamsApp(unittest.TestCase):
355
377
  def test_error_handling(self):
356
378
  params = {'raise_error': ['true'], 'pretty': ['true']}
357
379
  in_mmif = Mmif(self.in_mmif)
358
- try:
380
+ try:
359
381
  out_mmif = self.app.annotate(in_mmif, **params)
360
382
  except Exception as e:
361
383
  out_mmif_from_str = self.app.set_error_view(self.in_mmif, **params)
362
384
  out_mmif_from_mmif = self.app.set_error_view(in_mmif, **params)
363
385
  self.assertEqual(
364
- out_mmif_from_mmif.views.get_last(),
365
- out_mmif_from_str.views.get_last())
386
+ out_mmif_from_mmif.views.get_last_contentful_view(),
387
+ out_mmif_from_str.views.get_last_contentful_view())
366
388
  out_mmif = out_mmif_from_str
367
389
  self.assertIsNotNone(out_mmif)
368
390
  last_view: View = next(reversed(out_mmif.views))
369
391
  self.assertEqual(len(last_view.metadata.contains), 0)
370
392
  self.assertEqual(len(last_view.metadata.error), 2)
371
393
 
394
+ def test_gpu_mem_fields_default_zero(self):
395
+ """GPU memory fields default to 0."""
396
+ metadata = AppMetadata(
397
+ name="Test App",
398
+ description="Test",
399
+ app_license="MIT",
400
+ identifier="test-app",
401
+ url="https://example.com",
402
+ )
403
+ metadata.add_input(DocumentTypes.TextDocument)
404
+ metadata.add_output(AnnotationTypes.TimeFrame)
405
+
406
+ self.assertEqual(metadata.est_gpu_mem_min, 0)
407
+ self.assertEqual(metadata.est_gpu_mem_typ, 0)
408
+
409
+ def test_est_gpu_mem_typ_validation(self):
410
+ """Warning issued when est_gpu_mem_typ < est_gpu_mem_min, autocorrected."""
411
+ with warnings.catch_warnings(record=True) as w:
412
+ warnings.simplefilter("always")
413
+
414
+ metadata = AppMetadata(
415
+ name="Test App",
416
+ description="Test",
417
+ app_license="MIT",
418
+ identifier="test-app",
419
+ url="https://example.com",
420
+ est_gpu_mem_min=4000, # 4GB min
421
+ est_gpu_mem_typ=2000, # 2GB typical (less than min!)
422
+ )
423
+ metadata.add_input(DocumentTypes.TextDocument)
424
+ metadata.add_output(AnnotationTypes.TimeFrame)
425
+
426
+ # Should have issued a warning
427
+ self.assertEqual(len(w), 1)
428
+ self.assertIn('est_gpu_mem_typ', str(w[0].message))
429
+ self.assertIn('est_gpu_mem_min', str(w[0].message))
430
+
431
+ # Should have auto-corrected
432
+ self.assertEqual(metadata.est_gpu_mem_typ, metadata.est_gpu_mem_min)
433
+
434
+ def test_analyzer_versions_default_none(self):
435
+ """analyzer_versions field defaults to None."""
436
+ metadata = AppMetadata(
437
+ name="Test App",
438
+ description="Test",
439
+ app_license="MIT",
440
+ identifier="test-app",
441
+ url="https://example.com",
442
+ )
443
+ metadata.add_input(DocumentTypes.TextDocument)
444
+ metadata.add_output(AnnotationTypes.TimeFrame)
445
+
446
+ self.assertIsNone(metadata.analyzer_versions)
447
+
448
+ def test_analyzer_versions_with_value(self):
449
+ """analyzer_versions can be set with a dictionary."""
450
+ test_versions = {"model_a": "1.0", "model_b": "2.1"}
451
+ metadata = AppMetadata(
452
+ name="Test App",
453
+ description="Test",
454
+ app_license="MIT",
455
+ identifier="test-app",
456
+ url="https://example.com",
457
+ analyzer_versions=test_versions,
458
+ )
459
+ metadata.add_input(DocumentTypes.TextDocument)
460
+ metadata.add_output(AnnotationTypes.TimeFrame)
461
+
462
+ self.assertEqual(metadata.analyzer_versions, test_versions)
372
463
 
373
464
  class TestRestifier(unittest.TestCase):
374
465
 
@@ -1 +0,0 @@
1
- 1.3.3
@@ -1 +0,0 @@
1
- __version__ = "1.3.3"
File without changes
File without changes
File without changes
File without changes
File without changes