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