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.
- {clams_python-1.3.3/clams_python.egg-info → clams_python-1.5.0}/PKG-INFO +2 -2
- clams_python-1.5.0/VERSION +1 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/app/__init__.py +291 -14
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/appmetadata/__init__.py +39 -19
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/metadata.py.template +3 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/restify/__init__.py +57 -2
- clams_python-1.5.0/clams/ver/__init__.py +1 -0
- {clams_python-1.3.3 → clams_python-1.5.0/clams_python.egg-info}/PKG-INFO +2 -2
- {clams_python-1.3.3 → clams_python-1.5.0}/clams_python.egg-info/requires.txt +1 -1
- {clams_python-1.3.3 → clams_python-1.5.0}/requirements.txt +1 -1
- {clams_python-1.3.3 → clams_python-1.5.0}/tests/test_clamsapp.py +105 -14
- clams_python-1.3.3/VERSION +0 -1
- clams_python-1.3.3/clams/ver/__init__.py +0 -1
- {clams_python-1.3.3 → clams_python-1.5.0}/LICENSE +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/MANIFEST.in +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/README.md +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/__init__.py +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/__init__.py +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/.dockerignore.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/.gitignore.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/Containerfile.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/LICENSE.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/README.md.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/app.py.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/cli.py.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/requirements.txt.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/gha/for-clams-team.md.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/gha/workflows/issue-apps-project.yml.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/gha/workflows/issue-assign.yml.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/gha/workflows/issue-close.yml.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/gha/workflows/publish.yml.template +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams/serve/__init__.py +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams_python.egg-info/SOURCES.txt +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams_python.egg-info/dependency_links.txt +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams_python.egg-info/entry_points.txt +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/clams_python.egg-info/top_level.txt +0 -0
- {clams_python-1.3.3 → clams_python-1.5.0}/setup.cfg +0 -0
- {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
|
+
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
|
|
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':
|
|
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
|
-
|
|
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['
|
|
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
|
|
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(
|
|
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
|
-
|
|
219
|
+
profiling_data['runningTime'] = str(td)
|
|
176
220
|
if len(runtime_recs) > 0:
|
|
177
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
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
|
|
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
|
|
@@ -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 =
|
|
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),
|
|
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),
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
365
|
-
out_mmif_from_str.views.
|
|
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
|
|
clams_python-1.3.3/VERSION
DELETED
|
@@ -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
|
{clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/.dockerignore.template
RENAMED
|
File without changes
|
|
File without changes
|
{clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/Containerfile.template
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/app/requirements.txt.template
RENAMED
|
File without changes
|
{clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/gha/for-clams-team.md.template
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clams_python-1.3.3 → clams_python-1.5.0}/clams/develop/templates/gha/workflows/publish.yml.template
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|