clams-python 1.3.3__tar.gz → 1.4.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.4.0}/PKG-INFO +2 -2
  2. clams_python-1.4.0/VERSION +1 -0
  3. {clams_python-1.3.3 → clams_python-1.4.0}/clams/app/__init__.py +261 -14
  4. {clams_python-1.3.3 → clams_python-1.4.0}/clams/appmetadata/__init__.py +37 -18
  5. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/app/metadata.py.template +3 -0
  6. {clams_python-1.3.3 → clams_python-1.4.0}/clams/restify/__init__.py +57 -2
  7. clams_python-1.4.0/clams/ver/__init__.py +1 -0
  8. {clams_python-1.3.3 → clams_python-1.4.0/clams_python.egg-info}/PKG-INFO +2 -2
  9. {clams_python-1.3.3 → clams_python-1.4.0}/clams_python.egg-info/requires.txt +1 -1
  10. {clams_python-1.3.3 → clams_python-1.4.0}/requirements.txt +1 -1
  11. {clams_python-1.3.3 → clams_python-1.4.0}/tests/test_clamsapp.py +103 -12
  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.4.0}/LICENSE +0 -0
  15. {clams_python-1.3.3 → clams_python-1.4.0}/MANIFEST.in +0 -0
  16. {clams_python-1.3.3 → clams_python-1.4.0}/README.md +0 -0
  17. {clams_python-1.3.3 → clams_python-1.4.0}/clams/__init__.py +0 -0
  18. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/__init__.py +0 -0
  19. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/app/.dockerignore.template +0 -0
  20. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/app/.gitignore.template +0 -0
  21. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/app/Containerfile.template +0 -0
  22. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/app/LICENSE.template +0 -0
  23. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/app/README.md.template +0 -0
  24. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/app/app.py.template +0 -0
  25. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/app/cli.py.template +0 -0
  26. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/app/requirements.txt.template +0 -0
  27. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/gha/for-clams-team.md.template +0 -0
  28. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/gha/workflows/issue-apps-project.yml.template +0 -0
  29. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/gha/workflows/issue-assign.yml.template +0 -0
  30. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/gha/workflows/issue-close.yml.template +0 -0
  31. {clams_python-1.3.3 → clams_python-1.4.0}/clams/develop/templates/gha/workflows/publish.yml.template +0 -0
  32. {clams_python-1.3.3 → clams_python-1.4.0}/clams/serve/__init__.py +0 -0
  33. {clams_python-1.3.3 → clams_python-1.4.0}/clams_python.egg-info/SOURCES.txt +0 -0
  34. {clams_python-1.3.3 → clams_python-1.4.0}/clams_python.egg-info/dependency_links.txt +0 -0
  35. {clams_python-1.3.3 → clams_python-1.4.0}/clams_python.egg-info/entry_points.txt +0 -0
  36. {clams_python-1.3.3 → clams_python-1.4.0}/clams_python.egg-info/top_level.txt +0 -0
  37. {clams_python-1.3.3 → clams_python-1.4.0}/setup.cfg +0 -0
  38. {clams_python-1.3.3 → clams_python-1.4.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.4.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.2.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.4.0
@@ -13,10 +13,11 @@ __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.cli.describe import generate_param_hash # pytype: disable=import-error
16
17
  from clams.appmetadata import AppMetadata, real_valued_primitives, python_type, map_param_kv_delimiter
17
18
 
18
19
  logging.basicConfig(
19
- level=logging.WARNING,
20
+ level=getattr(logging, os.environ.get('CLAMS_LOGLEVEL', 'WARNING').upper(), logging.WARNING),
20
21
  format="%(asctime)s %(name)s %(levelname)-8s %(thread)d %(message)s",
21
22
  datefmt="%Y-%m-%d %H:%M:%S")
22
23
 
@@ -47,7 +48,7 @@ class ClamsApp(ABC):
47
48
  'description': 'The JSON body of the HTTP response will be re-formatted with 2-space indentation',
48
49
  },
49
50
  {
50
- 'name': 'runningTime', 'type': 'boolean', 'choices': None, 'default': False, 'multivalued': False,
51
+ 'name': 'runningTime', 'type': 'boolean', 'choices': None, 'default': True, 'multivalued': False,
51
52
  'description': 'The running time of the app will be recorded in the view metadata',
52
53
  },
53
54
  {
@@ -76,7 +77,7 @@ class ClamsApp(ABC):
76
77
 
77
78
  self.metadata_param_caster = ParameterCaster(self.metadata_param_spec)
78
79
  self.annotate_param_caster = ParameterCaster(self.annotate_param_spec)
79
- self.logger = logging.getLogger(self.metadata.identifier)
80
+ self.logger = logging.getLogger(str(self.metadata.identifier))
80
81
 
81
82
  def appmetadata(self, **kwargs: List[str]) -> str:
82
83
  """
@@ -137,6 +138,7 @@ class ClamsApp(ABC):
137
138
  """
138
139
  if not isinstance(mmif, Mmif):
139
140
  mmif = Mmif(mmif)
141
+ existing_view_ids = {view.id for view in mmif.views}
140
142
  issued_warnings = []
141
143
  for key in runtime_params:
142
144
  if key not in self.annotate_param_spec:
@@ -148,33 +150,47 @@ class ClamsApp(ABC):
148
150
  pretty = refined.get('pretty', False)
149
151
  t = datetime.now()
150
152
  with warnings.catch_warnings(record=True) as ws:
151
- annotated = self._annotate(mmif, **refined)
153
+ annotated, cuda_profiler = self._profile_cuda_memory(self._annotate)(mmif, **refined)
152
154
  if ws:
153
155
  issued_warnings.extend(ws)
154
156
  if issued_warnings:
155
157
  warnings_view = annotated.new_view()
156
158
  self.sign_view(warnings_view, refined)
157
159
  warnings_view.metadata.warnings = issued_warnings
158
- td = datetime.now() - t
160
+ run_id = datetime.now()
161
+ td = run_id - t
159
162
  runningTime = refined.get('runningTime', False)
160
163
  hwFetch = refined.get('hwFetch', False)
161
164
  runtime_recs = {}
162
165
  if hwFetch:
166
+ import multiprocessing
163
167
  import platform, shutil, subprocess
164
- runtime_recs['architecture'] = platform.machine()
165
- # runtime_recs['processor'] = platform.processor() # this only works on Windows
168
+ runtime_recs['cpu'] = f"{platform.machine()}, {multiprocessing.cpu_count()} cores"
166
169
  runtime_recs['cuda'] = []
167
- if shutil.which('nvidia-smi'):
170
+ # Use cuda_profiler data if available, otherwise fallback to nvidia-smi
171
+ if cuda_profiler:
172
+ for gpu_name, mem_info in cuda_profiler.items():
173
+ total_str = self._cuda_memory_to_str(mem_info['total'])
174
+ available_str = self._cuda_memory_to_str(mem_info['available_before'])
175
+ peak_str = self._cuda_memory_to_str(mem_info['peak'])
176
+ runtime_recs['cuda'].append(
177
+ f"{gpu_name}, {total_str} total, {available_str} available, {peak_str} peak used"
178
+ )
179
+ elif shutil.which('nvidia-smi'):
168
180
  for gpu in subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader'],
169
181
  stdout=subprocess.PIPE).stdout.decode('utf-8').strip().split('\n'):
170
182
  name, mem = gpu.split(', ')
171
- runtime_recs['cuda'].append(f'{name} ({mem})')
183
+ runtime_recs['cuda'].append(self._cuda_device_name_concat(name, mem))
172
184
  for annotated_view in annotated.views:
173
- if annotated_view.metadata.app == self.metadata.identifier:
185
+ if annotated_view.id not in existing_view_ids and annotated_view.metadata.app == str(self.metadata.identifier):
186
+ annotated_view.metadata.timestamp = run_id
187
+ profiling_data = {}
174
188
  if runningTime:
175
- annotated_view.metadata.set_additional_property('appRunningTime', str(td))
189
+ profiling_data['runningTime'] = str(td)
176
190
  if len(runtime_recs) > 0:
177
- annotated_view.metadata.set_additional_property('appRunningHardware', runtime_recs)
191
+ profiling_data['hardware'] = runtime_recs
192
+ if profiling_data:
193
+ annotated_view.metadata.set_additional_property('appProfiling', profiling_data)
178
194
 
179
195
  return annotated.serialize(pretty=pretty, sanitize=True)
180
196
 
@@ -252,7 +268,7 @@ class ClamsApp(ABC):
252
268
  :param view: a view to sign
253
269
  :param runtime_conf: runtime configuration of the app as k-v pairs
254
270
  """
255
- view.metadata.app = self.metadata.identifier
271
+ view.metadata.app = str(self.metadata.identifier)
256
272
  params_map = {p.name: p for p in self.metadata.parameters}
257
273
  if self._RAW_PARAMS_KEY in runtime_conf:
258
274
  for k, v in runtime_conf.items():
@@ -287,7 +303,7 @@ class ClamsApp(ABC):
287
303
  mmif = Mmif(mmif)
288
304
  error_view: Optional[View] = None
289
305
  for view in reversed(mmif.views):
290
- if view.metadata.app == self.metadata.identifier:
306
+ if view.metadata.app == str(self.metadata.identifier):
291
307
  error_view = view
292
308
  break
293
309
  if error_view is None:
@@ -321,6 +337,237 @@ class ClamsApp(ABC):
321
337
  # (https://github.com/clamsproject/mmif/issues/150) , here is a good place for additional check for
322
338
  # file integrity
323
339
 
340
+ @staticmethod
341
+ def _cuda_memory_to_str(mem) -> str:
342
+ mib = mem / (1024 * 1024)
343
+ return f"{mib:.0f} MiB" # No decimal places
344
+
345
+ @staticmethod
346
+ def _cuda_device_name_concat(name, mem):
347
+ if type(mem) in (bytes, int):
348
+ mem = ClamsApp._cuda_memory_to_str(mem)
349
+ return f"{name}, With {mem}"
350
+
351
+ def _get_profile_path(self, param_hash: str) -> pathlib.Path:
352
+ """
353
+ Get filesystem path for memory profile file.
354
+
355
+ Profile files are stored in a per-app directory under user's cache.
356
+
357
+ :param param_hash: Hash of parameters from :func:`mmif.utils.cli.describe.generate_param_hash`
358
+ :return: Path to the profile file
359
+ """
360
+ # Sanitize app identifier for filesystem use
361
+ app_id = str(self.metadata.identifier).replace('/', '-').replace(':', '-')
362
+ cache_base = pathlib.Path(os.environ.get('XDG_CACHE_HOME', pathlib.Path.home() / '.cache'))
363
+ cache_dir = cache_base / 'clams' / 'memory_profiles' / app_id
364
+ return cache_dir / f"memory_{param_hash}.json"
365
+
366
+ @staticmethod
367
+ def _get_available_vram() -> int:
368
+ """
369
+ Get currently available VRAM in bytes (GPU-wide, across all processes).
370
+
371
+ Uses nvidia-smi to get actual available memory, not just current process.
372
+
373
+ :return: Available VRAM in bytes, or 0 if unavailable
374
+ """
375
+ try:
376
+ import subprocess
377
+ import shutil
378
+ if shutil.which('nvidia-smi'):
379
+ # Get free memory from nvidia-smi (reports GPU-wide, not per-process)
380
+ result = subprocess.run(
381
+ ['nvidia-smi', '--query-gpu=memory.free', '--format=csv,noheader,nounits', '-i', '0'],
382
+ capture_output=True, text=True, timeout=5
383
+ )
384
+ if result.returncode == 0 and result.stdout.strip():
385
+ free_mb = float(result.stdout.strip())
386
+ return int(free_mb * 1024 * 1024) # Convert MB to bytes
387
+ except Exception:
388
+ pass
389
+
390
+ # Fallback to torch (only sees current process memory)
391
+ try:
392
+ import torch # pytype: disable=import-error
393
+ if not torch.cuda.is_available():
394
+ return 0
395
+
396
+ device = torch.cuda.current_device()
397
+ total = torch.cuda.get_device_properties(device).total_memory
398
+ used = max(torch.cuda.memory_allocated(device),
399
+ torch.cuda.memory_reserved(device))
400
+ return total - used
401
+ except Exception:
402
+ return 0
403
+
404
+ def _record_vram_usage(self, parameters: dict, peak_bytes: int) -> None:
405
+ """
406
+ Record peak memory usage to profile file.
407
+
408
+ Uses atomic write (temp + rename) to avoid corruption from
409
+ concurrent writes. Only updates if new value is higher.
410
+
411
+ Profile files are JSON containing:
412
+ - peak_bytes: Peak VRAM usage by the torch process
413
+ - parameters: Original parameters for human readability
414
+
415
+ :param parameters: Request parameters (for hash and recording)
416
+ :param peak_bytes: Measured peak VRAM usage
417
+ """
418
+ import json
419
+
420
+ if peak_bytes <= 0:
421
+ return
422
+
423
+ param_hash = generate_param_hash(parameters)
424
+ profile_path = self._get_profile_path(param_hash)
425
+
426
+ try:
427
+ profile_path.parent.mkdir(parents=True, exist_ok=True)
428
+
429
+ # Check if we should update
430
+ should_write = True
431
+ if profile_path.exists():
432
+ try:
433
+ existing_data = json.loads(profile_path.read_text())
434
+ existing = existing_data.get('peak_bytes', 0)
435
+ if peak_bytes <= existing:
436
+ should_write = False # Existing value is sufficient
437
+ else:
438
+ self.logger.debug(
439
+ f"Updating peak memory for {param_hash}: "
440
+ f"{self._cuda_memory_to_str(existing)} -> {self._cuda_memory_to_str(peak_bytes)}"
441
+ )
442
+ except (ValueError, IOError, json.JSONDecodeError):
443
+ pass # Corrupted file, overwrite
444
+
445
+ if should_write:
446
+ # Prepare profile data with original parameters for readability
447
+ # Filter out internal keys and non-serializable values
448
+ clean_params = {
449
+ k: v for k, v in parameters.items()
450
+ if k != self._RAW_PARAMS_KEY and not k.startswith('#')
451
+ }
452
+ profile_data = {
453
+ 'peak_bytes': peak_bytes,
454
+ 'parameters': clean_params
455
+ }
456
+
457
+ # Atomic write: write to temp, then rename
458
+ temp_path = profile_path.with_suffix('.tmp')
459
+ temp_path.write_text(json.dumps(profile_data, indent=2))
460
+ temp_path.rename(profile_path) # Atomic on POSIX
461
+
462
+ self.logger.info(
463
+ f"Recorded peak memory for {param_hash}: "
464
+ f"{self._cuda_memory_to_str(peak_bytes)}"
465
+ )
466
+ except Exception as e:
467
+ self.logger.warning(f"Failed to record memory profile: {e}")
468
+
469
+ @staticmethod
470
+ def _profile_cuda_memory(func):
471
+ """
472
+ Decorator for profiling CUDA memory usage and managing VRAM availability.
473
+
474
+ This decorator:
475
+ 1. Checks VRAM requirements before execution (if conditions met)
476
+ 2. Rejects requests if insufficient VRAM
477
+ 3. Records peak memory usage after execution
478
+ 4. Calls empty_cache() for cleanup
479
+
480
+ :param func: The function to wrap (typically _annotate)
481
+ :return: Decorated function that returns (result, cuda_profiler)
482
+ where cuda_profiler is dict with "<GPU_NAME>, <GPU_TOTAL_MEMORY>" keys
483
+ and dict values containing 'available_before' and 'peak' memory in bytes
484
+ """
485
+ def wrapper(*args, **kwargs):
486
+ # Get the ClamsApp instance from the bound method
487
+ app_instance = getattr(func, '__self__', None)
488
+
489
+ cuda_profiler = {}
490
+ torch_available = False
491
+ cuda_available = False
492
+ device_count = 0
493
+ available_before = {}
494
+
495
+ try:
496
+ import torch # pytype: disable=import-error
497
+ torch_available = True
498
+ cuda_available = torch.cuda.is_available()
499
+ device_count = torch.cuda.device_count()
500
+ except ImportError:
501
+ pass
502
+
503
+ # Capture available VRAM before execution and reset stats
504
+ if torch_available and cuda_available:
505
+ for device_id in range(device_count):
506
+ device_id_str = f'cuda:{device_id}'
507
+ # Get GPU-wide available memory via nvidia-smi
508
+ try:
509
+ import subprocess
510
+ import shutil
511
+ if shutil.which('nvidia-smi'):
512
+ result = subprocess.run(
513
+ ['nvidia-smi', '--query-gpu=memory.free',
514
+ '--format=csv,noheader,nounits', '-i', str(device_id)],
515
+ capture_output=True, text=True, timeout=5
516
+ )
517
+ if result.returncode == 0 and result.stdout.strip():
518
+ free_mb = float(result.stdout.strip())
519
+ available_before[device_id] = int(free_mb * 1024 * 1024)
520
+ else:
521
+ # Fallback to torch (process-specific)
522
+ total = torch.cuda.get_device_properties(device_id_str).total_memory
523
+ allocated = torch.cuda.memory_allocated(device_id_str)
524
+ available_before[device_id] = total - allocated
525
+ else:
526
+ # Fallback to torch (process-specific)
527
+ total = torch.cuda.get_device_properties(device_id_str).total_memory
528
+ allocated = torch.cuda.memory_allocated(device_id_str)
529
+ available_before[device_id] = total - allocated
530
+ except Exception:
531
+ # Fallback to torch (process-specific)
532
+ total = torch.cuda.get_device_properties(device_id_str).total_memory
533
+ allocated = torch.cuda.memory_allocated(device_id_str)
534
+ available_before[device_id] = total - allocated
535
+ # Reset peak memory stats for all devices
536
+ torch.cuda.reset_peak_memory_stats('cuda')
537
+
538
+ try:
539
+ result = func(*args, **kwargs)
540
+
541
+ # Record peak memory usage
542
+ total_peak = 0
543
+ if torch_available and cuda_available and device_count > 0:
544
+ for device_id in range(device_count):
545
+ device_id_str = f'cuda:{device_id}'
546
+ peak_memory = torch.cuda.max_memory_allocated(device_id_str)
547
+ total_peak = max(total_peak, peak_memory)
548
+ gpu_name = torch.cuda.get_device_name(device_id_str)
549
+ gpu_total_memory = torch.cuda.get_device_properties(device_id_str).total_memory
550
+ cuda_profiler[gpu_name] = {
551
+ 'total': gpu_total_memory,
552
+ 'available_before': available_before.get(device_id, 0),
553
+ 'peak': peak_memory
554
+ }
555
+
556
+ # Record peak memory for future requests (if GPU app)
557
+ gpu_app = (
558
+ hasattr(app_instance, 'metadata') and
559
+ getattr(app_instance.metadata, 'est_gpu_mem_min', 0) > 0
560
+ )
561
+ if gpu_app and total_peak > 0:
562
+ app_instance._record_vram_usage(kwargs, total_peak)
563
+
564
+ return result, cuda_profiler
565
+ finally:
566
+ if torch_available and cuda_available:
567
+ torch.cuda.empty_cache()
568
+
569
+ return wrapper
570
+
324
571
  @staticmethod
325
572
  @contextmanager
326
573
  def open_document_location(document: Union[str, Document], opener: Any = open, **openerargs):
@@ -131,11 +131,6 @@ class Output(_BaseModel):
131
131
  def __init__(self, **kwargs):
132
132
  super().__init__(**kwargs)
133
133
 
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
134
  @pydantic.field_validator('at_type', mode='before')
140
135
  @classmethod
141
136
  def at_type_must_be_str(cls, v):
@@ -295,6 +290,10 @@ class AppMetadata(pydantic.BaseModel):
295
290
  None,
296
291
  description="(optional) Version of an analyzer software, if the app is working as a wrapper for one. "
297
292
  )
293
+ analyzer_versions: Optional[Dict[str, str]] = pydantic.Field(
294
+ None,
295
+ description="(optional) Map of analyzer IDs to their versions, for apps that wrap multiple models or families."
296
+ )
298
297
  app_license: str = pydantic.Field(
299
298
  ...,
300
299
  description="License information of the app."
@@ -352,9 +351,20 @@ class AppMetadata(pydantic.BaseModel):
352
351
  "a package name and its version in the string value at the minimum (e.g., ``clams-python==1.2.3``)."
353
352
  )
354
353
  more: Optional[Dict[str, str]] = pydantic.Field(
355
- None,
354
+ None,
356
355
  description="(optional) A string-to-string map that can be used to store any additional metadata of the app."
357
356
  )
357
+ est_gpu_mem_min: int = pydantic.Field(
358
+ 0,
359
+ description="(optional) Minimum GPU memory required to run the app, in megabytes (MB). "
360
+ "Set to 0 (default) if the app does not use GPU."
361
+ )
362
+ est_gpu_mem_typ: int = pydantic.Field(
363
+ 0,
364
+ description="(optional) Typical GPU memory usage for default parameters, in megabytes (MB). "
365
+ "Must be equal or larger than est_gpu_mem_min. "
366
+ "Set to 0 (default) if the app does not use GPU."
367
+ )
358
368
 
359
369
  model_config = {
360
370
  'title': 'CLAMS AppMetadata',
@@ -364,13 +374,26 @@ class AppMetadata(pydantic.BaseModel):
364
374
  }
365
375
 
366
376
  @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
377
+ def assign_versions(self):
378
+ if self.app_version == '':
379
+ self.app_version = generate_app_version()
380
+ if self.mmif_version == '':
381
+ self.mmif_version = get_mmif_specver()
382
+ return self
383
+
384
+ @pydantic.model_validator(mode='after')
385
+ def validate_gpu_memory(self):
386
+ import warnings
387
+ if self.est_gpu_mem_typ > 0 and self.est_gpu_mem_min > 0:
388
+ if self.est_gpu_mem_typ < self.est_gpu_mem_min:
389
+ warnings.warn(
390
+ f"est_gpu_mem_typ ({self.est_gpu_mem_typ} MB) is less than "
391
+ f"est_gpu_mem_min ({self.est_gpu_mem_min} MB). "
392
+ f"Setting est_gpu_mem_typ to {self.est_gpu_mem_min} MB.",
393
+ UserWarning
394
+ )
395
+ self.est_gpu_mem_typ = self.est_gpu_mem_min
396
+ return self
374
397
 
375
398
  @pydantic.field_validator('identifier', mode='before')
376
399
  @classmethod
@@ -379,11 +402,7 @@ class AppMetadata(pydantic.BaseModel):
379
402
  suffix = generate_app_version()
380
403
  return '/'.join(map(lambda x: x.strip('/'), filter(None, (prefix, val, suffix))))
381
404
 
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
-
405
+
387
406
  def _check_input_duplicate(self, a_input):
388
407
  for elem in self.input:
389
408
  if isinstance(elem, list):
@@ -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.4.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clams-python
3
- Version: 1.3.3
3
+ Version: 1.4.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.2.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.2.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.2.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:
@@ -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