clams-python 1.1.2__tar.gz → 1.1.4__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 (41) hide show
  1. {clams-python-1.1.2/clams_python.egg-info → clams-python-1.1.4}/PKG-INFO +2 -2
  2. clams-python-1.1.4/VERSION +1 -0
  3. {clams-python-1.1.2 → clams-python-1.1.4}/clams/app/__init__.py +148 -34
  4. {clams-python-1.1.2 → clams-python-1.1.4}/clams/appmetadata/__init__.py +90 -21
  5. {clams-python-1.1.2 → clams-python-1.1.4}/clams/restify/__init__.py +6 -88
  6. clams-python-1.1.4/clams/ver/__init__.py +1 -0
  7. {clams-python-1.1.2 → clams-python-1.1.4/clams_python.egg-info}/PKG-INFO +2 -2
  8. {clams-python-1.1.2 → clams-python-1.1.4}/clams_python.egg-info/requires.txt +1 -1
  9. {clams-python-1.1.2 → clams-python-1.1.4}/requirements.txt +1 -1
  10. {clams-python-1.1.2 → clams-python-1.1.4}/tests/test_clamsapp.py +90 -34
  11. clams-python-1.1.2/VERSION +0 -1
  12. clams-python-1.1.2/clams/ver/__init__.py +0 -1
  13. {clams-python-1.1.2 → clams-python-1.1.4}/LICENSE +0 -0
  14. {clams-python-1.1.2 → clams-python-1.1.4}/MANIFEST.in +0 -0
  15. {clams-python-1.1.2 → clams-python-1.1.4}/README.md +0 -0
  16. {clams-python-1.1.2 → clams-python-1.1.4}/clams/__init__.py +0 -0
  17. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/__init__.py +0 -0
  18. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/app/.dockerignore.template +0 -0
  19. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/app/.gitignore.template +0 -0
  20. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/app/Containerfile.template +0 -0
  21. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/app/LICENSE.template +0 -0
  22. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/app/README.md.template +0 -0
  23. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/app/app.py.template +0 -0
  24. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/app/metadata.py.template +0 -0
  25. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/app/requirements.txt.template +0 -0
  26. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/gha/for-clams-team.md.template +0 -0
  27. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/gha/workflows/issue-apps-project.yml.template +0 -0
  28. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/gha/workflows/issue-assign.yml.template +0 -0
  29. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/gha/workflows/issue-close.yml.template +0 -0
  30. {clams-python-1.1.2 → clams-python-1.1.4}/clams/develop/templates/gha/workflows/publish.yml.template +0 -0
  31. {clams-python-1.1.2 → clams-python-1.1.4}/clams/mmif_utils/__init__.py +0 -0
  32. {clams-python-1.1.2 → clams-python-1.1.4}/clams/mmif_utils/rewind.py +0 -0
  33. {clams-python-1.1.2 → clams-python-1.1.4}/clams/mmif_utils/source.py +0 -0
  34. {clams-python-1.1.2 → clams-python-1.1.4}/clams/serve/__init__.py +0 -0
  35. {clams-python-1.1.2 → clams-python-1.1.4}/clams_python.egg-info/SOURCES.txt +0 -0
  36. {clams-python-1.1.2 → clams-python-1.1.4}/clams_python.egg-info/dependency_links.txt +0 -0
  37. {clams-python-1.1.2 → clams-python-1.1.4}/clams_python.egg-info/entry_points.txt +0 -0
  38. {clams-python-1.1.2 → clams-python-1.1.4}/clams_python.egg-info/top_level.txt +0 -0
  39. {clams-python-1.1.2 → clams-python-1.1.4}/setup.cfg +0 -0
  40. {clams-python-1.1.2 → clams-python-1.1.4}/setup.py +0 -0
  41. {clams-python-1.1.2 → clams-python-1.1.4}/tests/test_clamscli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: clams-python
3
- Version: 1.1.2
3
+ Version: 1.1.4
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.8
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
- Requires-Dist: mmif-python==1.0.9
17
+ Requires-Dist: mmif-python==1.0.11
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.1.4
@@ -9,10 +9,10 @@ from urllib import parse as urlparser
9
9
 
10
10
  __all__ = ['ClamsApp']
11
11
 
12
- from typing import Union, Any, Optional
12
+ from typing import Union, Any, Optional, Dict, List, Iterable
13
13
 
14
14
  from mmif import Mmif, Document, DocumentTypes, View
15
- from clams.appmetadata import AppMetadata
15
+ from clams.appmetadata import AppMetadata, RuntimeParameter, real_valued_primitives
16
16
 
17
17
  logging.basicConfig(
18
18
  level=logging.WARNING,
@@ -29,10 +29,10 @@ class ClamsApp(ABC):
29
29
  # A set of "universal runtime parameters that can be used for both GET and POST anytime".
30
30
  # The behavioral changes based on these parameters must be implemented on the SDK level.
31
31
  universal_parameters = [
32
- {
32
+ RuntimeParameter(**{
33
33
  'name': 'pretty', 'type': 'boolean', 'choices': None, 'default': False, 'multivalued': False,
34
34
  'description': 'The JSON body of the HTTP response will be re-formatted with 2-space indentation',
35
- },
35
+ }),
36
36
  ]
37
37
  # this key is used to store users' raw input params in the parameter dict
38
38
  # even after "refinement" (i.e., casting to proper data types)
@@ -42,24 +42,22 @@ class ClamsApp(ABC):
42
42
  self.metadata: AppMetadata = self._load_appmetadata()
43
43
  super().__init__()
44
44
  # data type specification for common parameters
45
- python_type = {"boolean": bool, "number": float, "integer": int, "string": str}
46
45
 
47
- self.metadata_param_spec = {}
48
- self.annotate_param_spec = {}
49
46
  for param in ClamsApp.universal_parameters:
50
- self.metadata.add_parameter(**param)
51
- self.metadata_param_spec[param['name']] = (python_type[param['type']], param.get('multivalued', False))
52
- for param_spec in self.metadata.parameters:
53
- self.annotate_param_spec[param_spec.name] = (python_type[param_spec.type], param_spec.multivalued)
47
+ self.metadata.parameters.append(param)
48
+ self.metadata_param_caster = ParameterCaster(ClamsApp.universal_parameters) # pytype: disable=wrong-arg-types
49
+ self.annotate_param_caster = ParameterCaster(self.metadata.parameters) # pytype: disable=wrong-arg-types
54
50
  self.logger = logging.getLogger(self.metadata.identifier)
55
51
 
56
- def appmetadata(self, **kwargs) -> str:
52
+ def appmetadata(self, **kwargs: List[str]) -> str:
57
53
  """
58
54
  A public method to get metadata for this app as a string.
59
55
 
60
56
  :return: Serialized JSON string of the metadata
61
57
  """
62
- pretty = kwargs.pop('pretty') if 'pretty' in kwargs else False
58
+ # cast only, no refinement
59
+ casted = self.metadata_param_caster.cast(kwargs)
60
+ pretty = casted.pop('pretty') if 'pretty' in casted else False
63
61
  return self.metadata.jsonify(pretty)
64
62
 
65
63
  def _load_appmetadata(self) -> AppMetadata:
@@ -98,7 +96,7 @@ class ClamsApp(ABC):
98
96
  def _check_mmif_compatibility(target_specver, input_specver):
99
97
  return target_specver.split('.')[:2] == input_specver.split('.')[:2]
100
98
 
101
- def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params) -> str:
99
+ def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params: List[str]) -> str:
102
100
  """
103
101
  A public method to invoke the primary app function. It's essentially a
104
102
  wrapper around :meth:`~clams.app.ClamsApp._annotate` method where some common operations
@@ -108,21 +106,22 @@ class ClamsApp(ABC):
108
106
  :param runtime_params: An arbitrary set of k-v pairs to configure the app at runtime
109
107
  :return: Serialized JSON string of the output of the app
110
108
  """
111
- pretty = runtime_params.get('pretty', False)
112
109
  if not isinstance(mmif, Mmif):
113
110
  mmif = Mmif(mmif)
114
111
  issued_warnings = []
115
112
  for key in runtime_params:
116
- if key not in self.annotate_param_spec:
113
+ if key not in self.annotate_param_caster.param_spec:
117
114
  issued_warnings.append(UserWarning(f'An undefined parameter "{key}" (value: "{runtime_params[key]}") is passed'))
118
- refined_params = self._refine_params(**runtime_params)
115
+ # this will do casting + refinement altogether
116
+ refined = self._refine_params(**runtime_params)
117
+ pretty = refined.get('pretty', False)
119
118
  with warnings.catch_warnings(record=True) as ws:
120
- annotated = self._annotate(mmif, **refined_params)
119
+ annotated = self._annotate(mmif, **refined)
121
120
  if ws:
122
121
  issued_warnings.extend(ws)
123
122
  if issued_warnings:
124
123
  warnings_view = annotated.new_view()
125
- self.sign_view(warnings_view, refined_params)
124
+ self.sign_view(warnings_view, runtime_params)
126
125
  warnings_view.metadata.warnings = issued_warnings
127
126
  return annotated.serialize(pretty=pretty, sanitize=True)
128
127
 
@@ -148,7 +147,7 @@ class ClamsApp(ABC):
148
147
  """
149
148
  raise NotImplementedError()
150
149
 
151
- def _refine_params(self, **runtime_params):
150
+ def _refine_params(self, **runtime_params: List[str]):
152
151
  """
153
152
  Method to "fill" the parameter dictionary with default values, when a key-value is not specified in the input.
154
153
  The input map is not really "filled" as a copy of it is returned with addition of default values.
@@ -159,19 +158,21 @@ class ClamsApp(ABC):
159
158
  if self._RAW_PARAMS_KEY in runtime_params:
160
159
  # meaning the dict is already refined, just return it
161
160
  return runtime_params
162
- conf = {}
161
+ refined = {}
162
+
163
+ casted = self.annotate_param_caster.cast(runtime_params)
163
164
  for parameter in self.metadata.parameters:
164
- if parameter.name in runtime_params:
165
- if parameter.choices and runtime_params[parameter.name] not in parameter.choices:
165
+ if parameter.name in casted:
166
+ if parameter.choices and casted[parameter.name] not in parameter.choices:
166
167
  raise ValueError(f"Value for parameter \"{parameter.name}\" must be one of {parameter.choices}.")
167
- conf[parameter.name] = runtime_params[parameter.name]
168
+ refined[parameter.name] = casted[parameter.name]
168
169
  elif parameter.default is not None:
169
- conf[parameter.name] = parameter.default
170
+ refined[parameter.name] = parameter.default
170
171
  else:
171
172
  raise ValueError(f"Cannot find configuration for a required parameter \"{parameter.name}\".")
172
173
  # raw input params are hidden under a special key
173
- conf[self._RAW_PARAMS_KEY] = runtime_params
174
- return conf
174
+ refined[self._RAW_PARAMS_KEY] = runtime_params
175
+ return refined
175
176
 
176
177
  def get_configuration(self, **runtime_params):
177
178
  warnings.warn("ClamsApp.get_configuration() is deprecated. "
@@ -197,15 +198,26 @@ class ClamsApp(ABC):
197
198
  "no longer be optional in the future. Please pass `runtime_params` "
198
199
  "from _annotate() method.",
199
200
  FutureWarning, stacklevel=2)
201
+ return
200
202
  view.metadata.app = self.metadata.identifier
201
- if runtime_conf is not None:
202
- if self._RAW_PARAMS_KEY in runtime_conf:
203
- conf = runtime_conf[self._RAW_PARAMS_KEY]
204
- else:
205
- conf = runtime_conf
206
- view.metadata.add_parameters(**{k: str(v) for k, v in conf.items()})
203
+ if self._RAW_PARAMS_KEY in runtime_conf:
204
+ for k, v in runtime_conf.items():
205
+ if k == self._RAW_PARAMS_KEY:
206
+ for orik, oriv in v.items():
207
+ if orik in self.metadata.parameters and self.metadata.parameters[orik].multivalued:
208
+ view.metadata.add_parameter(orik, str(oriv))
209
+ else:
210
+ view.metadata.add_parameter(orik, oriv[0])
211
+ view.metadata.add_app_configuration(k, v)
212
+ else:
213
+ # meaning the parameters directly from flask or argparser and values are in lists
214
+ for k, v in runtime_conf.items():
215
+ if k in self.metadata.parameters and self.metadata.parameters[k].multivalued:
216
+ view.metadata.add_parameter(k, str(v))
217
+ else:
218
+ view.metadata.add_parameter(k, v[0])
207
219
 
208
- def set_error_view(self, mmif: Union[str, dict, Mmif], runtime_conf: Optional[dict] = None) -> Mmif:
220
+ def set_error_view(self, mmif: Union[str, dict, Mmif], **runtime_conf: List[str]) -> Mmif:
209
221
  """
210
222
  A method to record an error instead of annotation results in the view
211
223
  this app generated. For logging purpose, the runtime parameters used
@@ -282,3 +294,105 @@ class ClamsApp(ABC):
282
294
  document_file.close()
283
295
  else:
284
296
  raise FileNotFoundError(p.path)
297
+
298
+
299
+ class ParameterCaster(object):
300
+ KV_DELIMITER = ':'
301
+ python_type = {"boolean": bool, "number": float, "integer": int, "string": str, "map": dict}
302
+
303
+ """
304
+ A helper class to convert parameters passed by HTTP query strings to
305
+ proper python data types.
306
+
307
+ :param param_spec: A specification of a data types of parameters
308
+ """
309
+ def __init__(self, params: Iterable[RuntimeParameter]):
310
+ self.param_spec = {}
311
+ for param in params:
312
+ self.param_spec[param.name] = (self.python_type[param.type], param.multivalued)
313
+
314
+ def cast(self, args: Dict[str, List[str]]) \
315
+ -> Dict[str, Union[real_valued_primitives, List[real_valued_primitives], Dict[str, str]]]:
316
+ """
317
+ Given parameter specification, tries to cast values of args to specified Python data types.
318
+ Note that this caster deals with query strings, thus all keys and values in the input args are plain strings.
319
+ Also note that the caster does not handle "unexpected" parameters came as an input.
320
+ Handling (raising an exception or issuing a warning upon receiving) an unexpected runtime parameter
321
+ must be done within the app itself.
322
+ Thus, when a key is not found in the parameter specifications, it should just pass it as a vanilla string.
323
+
324
+ :param args: k-v pairs
325
+ :return: A new dictionary of type-casted args, of which keys are always strings (parameter name),
326
+ and values are either
327
+ 1) a single value of a specified type (multivalued=False)
328
+ 2) a list of values of a specified type (multivalued=True) (all duplicates in the input are kept)
329
+ 3) a nested string-string dictionary (type=map ⊨ multivalued=True)
330
+ With the third case, developers can further process the nested values into a more complex data types or
331
+ structures, but that is not in the scope of this Caster class.
332
+ """
333
+ casted = {}
334
+ for k, vs in args.items():
335
+ assert isinstance(vs, list), f"Expected a list of values for key {k}, but got {vs} of type {type(vs)}"
336
+ assert all(isinstance(v, str) for v in vs), f"Expected a list of strings for key {k}, but got {vs} of types {[type(v) for v in vs]}"
337
+ if k in self.param_spec:
338
+ for v in vs:
339
+ valuetype, multivalued = self.param_spec[k]
340
+ if multivalued or k not in casted: # effectively only keeps the first value for non-multi params
341
+ if valuetype == bool:
342
+ v = self.bool_param(v)
343
+ elif valuetype == float:
344
+ v = self.float_param(v)
345
+ elif valuetype == int:
346
+ v = self.int_param(v)
347
+ elif valuetype == str:
348
+ v = self.str_param(v)
349
+ elif valuetype == dict:
350
+ v = self.kv_param(v)
351
+ if multivalued:
352
+ if valuetype == dict:
353
+ casted.setdefault(k, {}).update(v)
354
+ else:
355
+ casted.setdefault(k, []).append(v)
356
+ else:
357
+ casted[k] = v
358
+ else:
359
+ if len(vs) > 1:
360
+ casted[k] = vs
361
+ else:
362
+ casted[k] = vs[0]
363
+ return casted # pytype: disable=bad-return-type
364
+
365
+ @staticmethod
366
+ def bool_param(value) -> bool:
367
+ """
368
+ Helper function to convert string values to bool type.
369
+ """
370
+ return False if value in (False, 0, 'False', 'false', '0') else True
371
+
372
+ @staticmethod
373
+ def float_param(value) -> float:
374
+ """
375
+ Helper function to convert string values to float type.
376
+ """
377
+ return float(value)
378
+
379
+ @staticmethod
380
+ def int_param(value) -> int:
381
+ """
382
+ Helper function to convert string values to int type.
383
+ """
384
+ return int(value)
385
+
386
+ @staticmethod
387
+ def str_param(value) -> str:
388
+ """
389
+ Helper function to convert string values to string type.
390
+ """
391
+ return value
392
+
393
+ @staticmethod
394
+ def kv_param(value) -> Dict[str, str]:
395
+ """
396
+ Helper function to convert string values to key-value pair type.
397
+ """
398
+ return dict([value.split(ParameterCaster.KV_DELIMITER, 1)])
@@ -1,7 +1,7 @@
1
1
  import os
2
+ import shutil
2
3
  import subprocess
3
4
  import sys
4
- import shutil
5
5
  from pathlib import Path
6
6
  from typing import Union, Dict, List, Optional, Literal
7
7
 
@@ -14,7 +14,7 @@ app_version_envvar_key = 'CLAMS_APP_VERSION'
14
14
  # type aliases to use in app metadata and runtime parameter processing
15
15
  real_valued_primitives = Union[int, float, bool, str]
16
16
  # these names are taken from the JSON schema data types
17
- param_value_types = Literal['integer', 'number', 'string', 'boolean']
17
+ param_value_types = Literal['integer', 'number', 'string', 'boolean', 'map']
18
18
 
19
19
  param_value_types_values = param_value_types.__args__ # pytype: disable=attribute-error
20
20
  app_directory_baseurl = "http://apps.clams.ai"
@@ -38,9 +38,12 @@ def generate_app_version(cwd=None):
38
38
  gitcmd = shutil.which('git')
39
39
  gitdir = (Path(sys.modules["__main__"].__file__).parent.resolve() if cwd is None else Path(cwd)) / '.git'
40
40
  if gitcmd is not None and gitdir.exists():
41
- proc = subprocess.run([gitcmd, '--git-dir', str(gitdir), 'describe', '--tags', '--always'],
42
- capture_output=True, check=True)
43
- return proc.stdout.decode('utf8').strip()
41
+ try:
42
+ proc = subprocess.run([gitcmd, '--git-dir', str(gitdir), 'describe', '--tags', '--always'],
43
+ capture_output=True, check=True)
44
+ return proc.stdout.decode('utf8').strip()
45
+ except subprocess.CalledProcessError:
46
+ return unresolved_app_version_num
44
47
  elif app_version_envvar_key in os.environ:
45
48
  return os.environ[app_version_envvar_key]
46
49
  else:
@@ -62,16 +65,32 @@ class _BaseModel(pydantic.BaseModel):
62
65
 
63
66
  class Output(_BaseModel):
64
67
  """
65
- Defines a data model that describes output specification of a CLAMS app
68
+ Data model that describes output specification of a CLAMS app.
69
+
70
+ CLAMS apps are expected to have at least one output type, and each type must
71
+ be defined by a ``@type`` URI string. If the type has common properties and values generated by the app,
72
+ they can be described in the (optional) ``properties`` field. Finally, a human-readable
73
+ verbose description can be provided in the (optional) ``description`` field for users.
74
+
75
+ Developers should take diligent care to include all output types and their properties in the app metadata. To
76
+ specify the property values, developers can use an actual value (for full match) or ``'*'`` (for any value).
66
77
  """
67
78
  at_type: pydantic.AnyHttpUrl = pydantic.Field(
68
79
  ...,
69
80
  alias="@type",
70
81
  description="The type of the object. Must be a IRI string."
71
82
  )
83
+ description: Optional[str] = pydantic.Field(
84
+ None,
85
+ description="A verbose, human-readable description of the type. This is intended to be used for documentation "
86
+ "purpose for a particular use case of this annotation type and is not expected to be consumed by "
87
+ "software. This description should work as a guideline for users to understand the output type, "
88
+ "and also can be used as a expansion specification for the type definition beyond the base "
89
+ "vocabulary."
90
+ )
72
91
  properties: Dict[str, real_valued_primitives] = pydantic.Field(
73
92
  {},
74
- description="(optional) Specification for type properties, if any."
93
+ description="(optional) Specification for type properties, if any. ``\"*\"`` indicates any value."
75
94
  )
76
95
 
77
96
  @pydantic.validator('at_type', pre=True)
@@ -84,11 +103,24 @@ class Output(_BaseModel):
84
103
  title = 'CLAMS Output Specification'
85
104
  extra = 'forbid'
86
105
  allow_population_by_field_name = True
106
+
107
+ def add_description(self, description: str):
108
+ """
109
+ Add description string to the type specification.
110
+ """
111
+ self.description = description
87
112
 
88
113
 
89
114
  class Input(Output):
90
115
  """
91
- Defines a data model that describes input specification of a CLAMS app
116
+ Data model that describes input specification of a CLAMS app.
117
+
118
+ CLAMS apps are expected to have at least one input type, and each type must
119
+ be defined by a ``@type`` URI string. If the type has specific properties and values required by the app,
120
+ they can be described in the (optional) ``properties`` field. Finally, a human-readable
121
+ verbose description can be provided in the (optional) ``description`` field for users.
122
+
123
+ Developers should take diligent care to include all input types and their properties in the app metadata.
92
124
  """
93
125
  required: bool = pydantic.Field(
94
126
  None,
@@ -124,16 +156,33 @@ class RuntimeParameter(_BaseModel):
124
156
  )
125
157
  type: param_value_types = pydantic.Field(
126
158
  ...,
127
- description=f"Type of the parameter value the app expects. Must be one of {param_value_types_values}."
159
+ description=f"Type of the parameter value the app expects. Must be one of {param_value_types_values}. When "
160
+ "type is ``map``, ``multivalued=true`` is automatically forced. \n\n"
161
+ "Notes for developers: \n\n"
162
+ "When the type is ``map``, the parameter value (still a single string from the users' perspective) "
163
+ "must be formatted as a ``KEY:VALUE`` pair, namely a colon-separated string. To pass multiple "
164
+ "key-value pairs, users need to pass the parameter multiple times (remember ``type=map`` implies "
165
+ "``multivalued=true``) with pairs in the colon-separated format. \n\n"
166
+ "Also, the `VALUE` part of the user input is always expected and handled as a string. If a "
167
+ "developers wants to do more text processing on the passed value to accept more complex data types "
168
+ "or structures (e.g., map from a string to a list of strings), it is up to the developer. However, "
169
+ "any additional form requirements should be precisely described in the ``description`` field for "
170
+ "users. \n\n"
171
+ "Finally, the same format is expected for the default value, if any. For example, if the default "
172
+ "desired dictionary is ``{'key1': 'value1', 'key2': 'value2'}``, the default value (used when "
173
+ "initializing a parameter) should be ``['key1:value1','key2:value2']``\n."
128
174
  )
129
175
  choices: List[real_valued_primitives] = pydantic.Field(
130
176
  None,
131
177
  description="(optional) List of string values that can be accepted."
132
178
  )
133
- default: real_valued_primitives = pydantic.Field(
179
+ default: Union[real_valued_primitives, List[real_valued_primitives]] = pydantic.Field(
134
180
  None,
135
- description="(optional) Default value for the parameter. Only valid for optional parameters. Namely, setting "
136
- "a default value makes a parameter 'optional'."
181
+ description="(optional) Default value for the parameter.\n\n"
182
+ "Notes for developers: \n\n"
183
+ "Setting a default value makes a parameter `optional`. \n\n"
184
+ "When ``multivalued=True``, the default value should be a list of values. \n\n"
185
+ "When ``type=map``, the default value should be a list of colon-separated strings. \n\n"
137
186
  )
138
187
  multivalued: bool = pydantic.Field(
139
188
  ...,
@@ -143,8 +192,15 @@ class RuntimeParameter(_BaseModel):
143
192
 
144
193
  def __init__(self, **kwargs):
145
194
  super().__init__(**kwargs)
195
+ if self.type == 'map':
196
+ if self.multivalued is False:
197
+ raise ValueError("When the type is `map`, multivalued must not be False.")
198
+ else:
199
+ self.multivalued = True
146
200
  if self.multivalued is None:
147
201
  self.multivalued = False
202
+ if self.multivalued and self.default is not None and not isinstance(self.default, list):
203
+ self.default = [self.default]
148
204
 
149
205
  class Config:
150
206
  title = 'CLAMS App Runtime Parameter'
@@ -153,7 +209,7 @@ class RuntimeParameter(_BaseModel):
153
209
 
154
210
  class AppMetadata(pydantic.BaseModel):
155
211
  """
156
- Defines a data model that describes a CLAMS app.
212
+ Data model that describes a CLAMS app.
157
213
 
158
214
  Can be initialized by simply passing all required key-value pairs.
159
215
 
@@ -287,13 +343,14 @@ class AppMetadata(pydantic.BaseModel):
287
343
  return True
288
344
  return False
289
345
 
290
- def add_input(self, at_type: Union[str, vocabulary.ThingTypesBase], required: bool = True, **properties):
346
+ def add_input(self, at_type: Union[str, vocabulary.ThingTypesBase], required: bool = True, **properties) -> Input:
291
347
  """
292
348
  Helper method to add an element to the ``input`` list.
293
349
 
294
350
  :param at_type: ``@type`` of the input object
295
351
  :param required: whether this type is mandatory or optional
296
352
  :param properties: additional property specifications
353
+ :return: the newly added Input object
297
354
  """
298
355
  new = Input(at_type=at_type, required=required)
299
356
  if len(properties) > 0:
@@ -306,8 +363,9 @@ class AppMetadata(pydantic.BaseModel):
306
363
  # TODO (krim @ 5/12/21): automatically add *optional* input types to parameter
307
364
  # see https://github.com/clamsproject/clams-python/issues/29 for discussion
308
365
  pass
366
+ return new
309
367
 
310
- def add_input_oneof(self, *inputs: Union[str, Input, vocabulary.ThingTypesBase] ):
368
+ def add_input_oneof(self, *inputs: Union[str, Input, vocabulary.ThingTypesBase]):
311
369
  newinputs = []
312
370
  if len(inputs) == 1:
313
371
  if isinstance(inputs[0], Input):
@@ -328,12 +386,13 @@ class AppMetadata(pydantic.BaseModel):
328
386
  newinputs.append(i)
329
387
  self.input.append(newinputs)
330
388
 
331
- def add_output(self, at_type: Union[str, vocabulary.ThingTypesBase], **properties):
389
+ def add_output(self, at_type: Union[str, vocabulary.ThingTypesBase], **properties) -> Output:
332
390
  """
333
391
  Helper method to add an element to the ``output`` list.
334
392
 
335
393
  :param at_type: ``@type`` of the input object
336
394
  :param properties: additional property specifications
395
+ :return: the newly added Output object
337
396
  """
338
397
  new = Output(at_type=at_type)
339
398
  if len(properties) > 0:
@@ -342,21 +401,31 @@ class AppMetadata(pydantic.BaseModel):
342
401
  self.output.append(new)
343
402
  else:
344
403
  raise ValueError(f"Cannot add a duplicate output '{new}'.")
404
+ return new
345
405
 
346
406
  def add_parameter(self, name: str, description: str, type: param_value_types,
347
407
  choices: Optional[List[real_valued_primitives]] = None,
348
408
  multivalued: bool = False,
349
- default: real_valued_primitives = None):
409
+ default: Union[real_valued_primitives, List[real_valued_primitives]] = None):
350
410
  """
351
411
  Helper method to add an element to the ``parameters`` list.
352
412
  """
353
- # casting default values (when the value is not nothing) makes sure
354
- # the values are correctly casted by the pydantic
413
+ # casting default values (when the value is not nothing) to str
414
+ # will make sure the values are correctly handled by the pydantic
355
415
  # see https://docs.pydantic.dev/1.10/usage/types/#unions
356
416
  # e.g. casting 0.1 using the `primitives` dict will result in 0 (int)
357
417
  # while casting "0.1" using the `primitives` dict will result in 0.1 (float)
358
- new_param = RuntimeParameter(name=name, description=description, type=type,
359
- choices=choices, default=str(default) if default else default, multivalued=multivalued)
418
+ if type == 'map' and multivalued is False:
419
+ multivalued = True
420
+ if default is not None:
421
+ if isinstance(default, list):
422
+ default = [str(d) for d in default]
423
+ else:
424
+ default = str(default)
425
+
426
+ new_param = RuntimeParameter(
427
+ name=name, description=description, type=type, choices=choices, multivalued=multivalued,
428
+ default=default)
360
429
  if new_param.name not in [param.name for param in self.parameters]:
361
430
  self.parameters.append(new_param)
362
431
  else:
@@ -1,12 +1,9 @@
1
- from typing import Dict, Union, Any, Tuple, List
2
-
3
1
  import jsonschema
4
2
  from flask import Flask, request, Response
5
3
  from flask_restful import Resource, Api
6
4
  from mmif import Mmif
7
5
 
8
6
  from clams.app import ClamsApp
9
- from clams.appmetadata import real_valued_primitives
10
7
 
11
8
 
12
9
  class Restifier(object):
@@ -107,8 +104,6 @@ class ClamsHTTPApi(Resource):
107
104
  def __init__(self, cla_instance: ClamsApp):
108
105
  super().__init__()
109
106
  self.cla = cla_instance
110
- self.metadata_param_caster = ParameterCaster(self.cla.metadata_param_spec) # pytype: disable=wrong-arg-types
111
- self.annotate_param_caster = ParameterCaster(self.cla.annotate_param_spec) # pytype: disable=wrong-arg-types
112
107
 
113
108
  @staticmethod
114
109
  def json_to_response(json_str: str, status=200) -> Response:
@@ -129,7 +124,7 @@ class ClamsHTTPApi(Resource):
129
124
 
130
125
  :return: Returns app metadata in a HTTP response.
131
126
  """
132
- return self.json_to_response(self.cla.appmetadata(**self.metadata_param_caster.cast(request.args)))
127
+ return self.json_to_response(self.cla.appmetadata(**request.args))
133
128
 
134
129
  def post(self) -> Response:
135
130
  """
@@ -138,93 +133,16 @@ class ClamsHTTPApi(Resource):
138
133
 
139
134
  :return: Returns MMIF output from a ClamsApp in a HTTP response.
140
135
  """
141
- raw_data = request.get_data()
136
+ raw_data = request.get_data().decode('utf-8')
142
137
  # this will catch duplicate arguments with different values into a list under the key
143
- raw_params = request.args.to_dict(False)
138
+ raw_params = request.args.to_dict(flat=False)
144
139
  try:
145
140
  _ = Mmif(raw_data)
146
141
  except jsonschema.exceptions.ValidationError as e:
147
142
  return Response(response="Invalid input data. See below for validation error.\n\n" + str(e), status=500, mimetype='text/plain')
148
143
  try:
149
- return self.json_to_response(self.cla.annotate(raw_data, **self.annotate_param_caster.cast(raw_params)))
150
- except Exception as e:
151
- return self.json_to_response(self.cla.record_error(raw_data, self.annotate_param_caster.cast(raw_params)).serialize(pretty=True), status=500)
144
+ return self.json_to_response(self.cla.annotate(raw_data, **raw_params))
145
+ except Exception:
146
+ return self.json_to_response(self.cla.record_error(raw_data, **raw_params).serialize(pretty=True), status=500)
152
147
 
153
148
  put = post
154
-
155
-
156
- class ParameterCaster(object):
157
- """
158
- A helper class to convert parameters passed by HTTP query strings to
159
- proper python data types.
160
-
161
- :param param_spec: A specification of a data types of parameters
162
- """
163
- def __init__(self, param_spec: Dict[str, Tuple[real_valued_primitives, bool]]):
164
- self.param_spec = param_spec
165
-
166
- def cast(self, args: Dict[str, List[str]]) -> Dict[str, Union[real_valued_primitives, List[real_valued_primitives]]]:
167
- """
168
- Given parameter specification, tries to cast values of args to specified Python data types.
169
- Note that this caster deals with query strings, thus all keys and values in the input args are plain strings.
170
- Also note that the caster does not handle "unexpected" parameters came as an input.
171
- Handling (raising an exception or issuing a warning upon receiving) an unexpected runtime parameter
172
- must be done within the app itself.
173
- Thus, when a key is not found in the parameter specifications, it should just pass it as a vanilla string.
174
-
175
- :param args: k-v pairs
176
- :return: A new dictionary of type-casted args
177
- """
178
- casted = {}
179
- for k, vs in args.items():
180
- if k in self.param_spec:
181
- for v in vs:
182
- type_, multivalued = self.param_spec[k]
183
- if multivalued or k not in casted: # effectively only keeps the first value for non-multi params
184
- if type_ == bool:
185
- v = self.bool_param(v)
186
- elif type_ == float:
187
- v = self.float_param(v)
188
- elif type_ == int:
189
- v = self.int_param(v)
190
- elif type_ == str:
191
- v = self.str_param(v)
192
- if multivalued:
193
- casted.setdefault(k, []).append(v)
194
- else:
195
- casted[k] = v
196
- else:
197
- if len(vs) > 1:
198
- casted[k] = vs
199
- else:
200
- casted[k] = vs[0]
201
-
202
- return casted
203
-
204
- @staticmethod
205
- def bool_param(value):
206
- """
207
- Helper function to convert string values to bool type.
208
- """
209
- return False if value in (False, 0, 'False', 'false', '0') else True
210
-
211
- @staticmethod
212
- def float_param(value):
213
- """
214
- Helper function to convert string values to float type.
215
- """
216
- return float(value)
217
-
218
- @staticmethod
219
- def int_param(value):
220
- """
221
- Helper function to convert string values to int type.
222
- """
223
- return int(value)
224
-
225
- @staticmethod
226
- def str_param(value):
227
- """
228
- Helper function to convert string values to string type.
229
- """
230
- return value
@@ -0,0 +1 @@
1
+ __version__ = "1.1.4"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: clams-python
3
- Version: 1.1.2
3
+ Version: 1.1.4
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.8
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
- Requires-Dist: mmif-python==1.0.9
17
+ Requires-Dist: mmif-python==1.0.11
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.0.9
1
+ mmif-python==1.0.11
2
2
  Flask>=2
3
3
  Flask-RESTful>=0.3.9
4
4
  gunicorn>=20
@@ -1,4 +1,4 @@
1
- mmif-python==1.0.9
1
+ mmif-python==1.0.11
2
2
 
3
3
  Flask>=2
4
4
  Flask-RESTful>=0.3.9
@@ -13,8 +13,8 @@ from mmif import Mmif, Document, DocumentTypes, AnnotationTypes, View, __specver
13
13
 
14
14
  import clams.app
15
15
  import clams.restify
16
- from clams.appmetadata import AppMetadata, Input
17
- from clams.restify import ParameterCaster
16
+ from clams.app import ParameterCaster
17
+ from clams.appmetadata import AppMetadata, Input, RuntimeParameter
18
18
 
19
19
 
20
20
  class ExampleInputMMIF(object):
@@ -123,7 +123,7 @@ class TestClamsApp(unittest.TestCase):
123
123
  self.assertTrue('properties' not in elem)
124
124
  self.assertTrue(elem['required'])
125
125
 
126
- # test add_X methods
126
+ def test_metadata_inputoutput(self):
127
127
  self.app.metadata.add_output(AnnotationTypes.BoundingBox, boxType='text')
128
128
  metadata = json.loads(self.app.appmetadata())
129
129
  self.assertEqual(len(metadata['output']), 2)
@@ -132,11 +132,22 @@ class TestClamsApp(unittest.TestCase):
132
132
  self.app.metadata.add_input(at_type=DocumentTypes.TextDocument)
133
133
  with self.assertRaises(ValueError):
134
134
  self.app.metadata.add_output(AnnotationTypes.BoundingBox, boxType='text')
135
- # but this should
136
- self.app.metadata.add_output(AnnotationTypes.BoundingBox, boxType='face')
135
+ # but this should, even when there's `BoundingBox` already because the boxType prop value is different
136
+ face_bb_out = self.app.metadata.add_output(AnnotationTypes.BoundingBox, boxType='face')
137
+ # with an optional "comment"
138
+ face_bb_out.description = 'face bounding box'
139
+ # then should be able to set the description of the existing output
140
+ text_bb_out = next(o for o in self.app.metadata.output if o.properties.get('boxType') == 'text')
141
+ text_bb_out.description = 'text bounding box'
137
142
  metadata = json.loads(self.app.appmetadata())
138
143
  self.assertEqual(len(metadata['input']), 2)
139
- self.assertTrue('properties' not in metadata['input'][0])
144
+ for i in metadata['input']:
145
+ self.assertTrue('properties' not in i)
146
+ self.assertTrue('description' not in i)
147
+ for o in metadata['output']:
148
+ if o['@type'] == AnnotationTypes.BoundingBox:
149
+ self.assertTrue('properties' in o)
150
+ self.assertTrue('description' in o)
140
151
  # adding input with properties
141
152
  self.app.metadata.add_input(at_type=AnnotationTypes.TimeFrame, frameType='speech')
142
153
  metadata = json.loads(self.app.appmetadata())
@@ -151,19 +162,42 @@ class TestClamsApp(unittest.TestCase):
151
162
  j = Input(at_type=AnnotationTypes.VideoObject)
152
163
  with self.assertRaises(ValueError):
153
164
  self.app.metadata.add_input_oneof(i, j)
165
+
166
+ def test_metadata_runtimeparams(self):
154
167
  # now parameters
168
+ num_params = 1 # starts with `raise_error` in metadata.py
155
169
  # using a custom class
156
170
  # this should conflict with existing parameter
157
171
  with self.assertRaises(ValueError):
158
172
  self.app.metadata.add_parameter(
159
173
  name='raise_error', description='force raise a ValueError',
160
174
  type='boolean', default='false')
161
- # using python dict
175
+ num_params += 0
176
+
177
+ # some random multiple choice parameters
162
178
  self.app.metadata.add_parameter(
163
- name='multiple_choice', description='meaningless multiple choice option',
179
+ name='single_choice', description='meaningless choice for a single value',
180
+ type='integer', choices=[1, 2, 3, 4, 5], default='3', multivalued=False)
181
+ num_params += 1
182
+
183
+ self.app.metadata.add_parameter(
184
+ name='multiple_choice', description='meaningless choice for any number of values',
164
185
  type='integer', choices=[1, 2, 3, 4, 5], default=3, multivalued=True)
186
+ num_params += 1
187
+
165
188
  metadata = json.loads(self.app.appmetadata())
166
- self.assertEqual(len(metadata['parameters']), 2 + len(self.app.universal_parameters))
189
+ self.assertEqual(len(metadata['parameters']), num_params + len(self.app.universal_parameters))
190
+
191
+ for p in metadata['parameters']:
192
+ # default value must match the data type
193
+ if p['name'] == 'single_choice':
194
+ self.assertFalse(p['multivalued'])
195
+ self.assertTrue(isinstance(p['default'], int))
196
+ # default for multivalued para must be a list
197
+ if p['name'] == 'multiple_choice':
198
+ self.assertTrue(p['multivalued'])
199
+ self.assertTrue(isinstance(p['default'], list))
200
+ self.assertEqual(len(p['default']), 1)
167
201
  # now more additional metadata
168
202
  self.app.metadata.add_more('one', 'more')
169
203
  self.assertEqual(self.app.metadata.more['one'], 'more')
@@ -173,7 +207,7 @@ class TestClamsApp(unittest.TestCase):
173
207
  self.app.metadata.add_more('one', '')
174
208
 
175
209
  # finally for an eye exam
176
- print(self.app.appmetadata(pretty=True))
210
+ print(self.app.appmetadata(pretty=['true']))
177
211
 
178
212
  @pytest.mark.skip('legacy type version check')
179
213
  def test__check_mmif_compatibility(self):
@@ -205,7 +239,7 @@ class TestClamsApp(unittest.TestCase):
205
239
  for v in out_mmif.views:
206
240
  if v.metadata.app == self.app.metadata.identifier:
207
241
  self.assertEqual(len(v.metadata.parameters), 0) # no params were passed when `annotate()` was called
208
- out_mmif = self.app.annotate(self.in_mmif, pretty=False)
242
+ out_mmif = self.app.annotate(self.in_mmif, pretty=[str(False)])
209
243
  out_mmif = Mmif(out_mmif)
210
244
  for v in out_mmif.views:
211
245
  if v.metadata.app == self.app.metadata.identifier:
@@ -226,8 +260,6 @@ class TestClamsApp(unittest.TestCase):
226
260
  self.app._annotate = MagicMock(return_value=m)
227
261
  with self.assertRaises(jsonschema.ValidationError):
228
262
  self.app.annotate(self.in_mmif)
229
-
230
-
231
263
 
232
264
  def test_open_document_location(self):
233
265
  mmif = ExampleInputMMIF.get_rawmmif()
@@ -242,40 +274,54 @@ class TestClamsApp(unittest.TestCase):
242
274
 
243
275
  def test_refine_parameters(self):
244
276
  self.app.metadata.parameters = []
245
- self.app.metadata.add_parameter('param1', 'first_param', 'string')
246
- self.app.metadata.add_parameter('param2', 'second_param', 'string', default='second_default')
247
- self.app.metadata.add_parameter('param3', 'third_param', 'boolean', default='f')
248
- self.app.metadata.add_parameter('param4', 'fourth_param', 'integer', default='1', choices="1 2 3".split())
249
- self.app.metadata.add_parameter('param5', 'fifth_param', 'number', default='0.5')
250
- dummy_params = {'param1': 'okay', 'non_parameter': 'should be ignored'}
277
+ self.app.metadata.add_parameter('param1', type='string',
278
+ description='string -def +req')
279
+ self.app.metadata.add_parameter('param2', type='string', default='second_default',
280
+ description='stirng +def')
281
+ self.app.metadata.add_parameter('param3', type='boolean', default='f',
282
+ description='bool +def')
283
+ self.app.metadata.add_parameter('param4', type='integer', default=1, choices="1 2 3".split(),
284
+ description='int +def +choices')
285
+ self.app.metadata.add_parameter('param5', type='number', default=0.5,
286
+ description='float +def')
287
+ self.app.metadata.add_parameter('param6', type='number', multivalued=True, default=[0.5],
288
+ description='float +def +mv')
289
+ self.app.metadata.add_parameter('param7', type='number', default=[], multivalued=True,
290
+ description='float +def +empty +mv')
291
+ with self.assertRaises(ValueError):
292
+ self.app._refine_params(**{}) # param1 is required, but not passed
293
+ dummy_params = {'param1': ['okay'], 'non_parameter': ['should be ignored']}
251
294
  conf = self.app._refine_params(**dummy_params)
252
295
  # should not refine what's already refined
253
296
  double_refine = self.app._refine_params(**conf)
254
297
  self.assertTrue(clams.ClamsApp._RAW_PARAMS_KEY in double_refine)
255
298
  self.assertEqual(double_refine, conf)
256
299
  conf.pop(clams.ClamsApp._RAW_PARAMS_KEY, None)
257
- self.assertEqual(len(conf), 5) # 1 from `param1`, 4 from default value
300
+ self.assertEqual(len(conf), 7) # 1 from `param1`, 6 from default value
258
301
  self.assertFalse('non_parameter' in conf)
259
302
  self.assertEqual(type(conf['param1']), str)
260
303
  self.assertEqual(type(conf['param2']), str)
261
304
  self.assertEqual(type(conf['param3']), bool)
262
305
  self.assertEqual(type(conf['param4']), int)
263
306
  self.assertEqual(type(conf['param5']), float)
307
+ self.assertEqual(type(conf['param6']), list)
308
+ self.assertEqual(type(conf['param7']), list)
309
+ self.assertEqual(len(conf['param7']), 0)
264
310
  with self.assertRaises(ValueError):
265
311
  # because param1 doesn't have a default value and thus a required param
266
- self.app._refine_params(param2='okay')
267
- with self.assertRaisesRegexp(ValueError, r'.+must be one of.+'):
312
+ self.app._refine_params(param2=['okay'])
313
+ with self.assertRaisesRegex(ValueError, r'.+must be one of.+'):
268
314
  # because param4 can't be 4, note that param1 is "required"
269
- self.app._refine_params(param1='p1', param4=4)
315
+ self.app._refine_params(param1=['p1'], param4=['4'])
270
316
 
271
317
  def test_error_handling(self):
272
- params = {'raise_error': True, 'pretty': True}
318
+ params = {'raise_error': 'true', 'pretty': 'true'}
273
319
  in_mmif = Mmif(self.in_mmif)
274
320
  try:
275
321
  out_mmif = self.app.annotate(in_mmif, **params)
276
322
  except Exception as e:
277
- out_mmif_from_str = self.app.set_error_view(self.in_mmif, params)
278
- out_mmif_from_mmif = self.app.set_error_view(in_mmif, params)
323
+ out_mmif_from_str = self.app.set_error_view(self.in_mmif, **params)
324
+ out_mmif_from_mmif = self.app.set_error_view(in_mmif, **params)
279
325
  self.assertEqual(out_mmif_from_mmif.views, out_mmif_from_str.views)
280
326
  out_mmif = out_mmif_from_str
281
327
  self.assertIsNotNone(out_mmif)
@@ -374,15 +420,19 @@ class TestRestifier(unittest.TestCase):
374
420
  class TestParameterCaster(unittest.TestCase):
375
421
 
376
422
  def setUp(self) -> None:
377
- self.param_spec = {'str_param': (str, False),
378
- 'number_param': (float, False),
379
- 'int_param': (int, False),
380
- 'bool_param': (bool, False),
381
- 'str_multi_param': (str, True)
382
- }
383
-
423
+ self.params = []
424
+ specs = {'str_param': ('string', False),
425
+ 'number_param': ('number', False),
426
+ 'int_param': ('integer', False),
427
+ 'bool_param': ('boolean', False),
428
+ 'str_multi_param': ('string', True),
429
+ 'map_param': ('map', True),
430
+ }
431
+ for name, (t, mv) in specs.items():
432
+ self.params.append(RuntimeParameter(**{'name': name, 'type': t, 'multivalued': mv, 'description': ""}))
433
+
384
434
  def test_cast(self):
385
- caster = ParameterCaster(self.param_spec)
435
+ caster = ParameterCaster(self.params)
386
436
  params = {
387
437
  'str_param': ["a_string"],
388
438
  'number_param': ["1.11"],
@@ -391,6 +441,7 @@ class TestParameterCaster(unittest.TestCase):
391
441
  'str_multi_param': ['value1', 'value2'],
392
442
  'undefined_param_single': ['undefined_value1'],
393
443
  'undefined_param_multi': ['undefined_value1', 'undefined_value2'],
444
+ 'map_param': ['key1:val1', 'key2:val2', 'key3:val3', 'key1:val4']
394
445
  }
395
446
  self.assertTrue(all(map(lambda x: isinstance(x, str), itertools.chain.from_iterable(params.values()))))
396
447
  casted = caster.cast(params)
@@ -404,6 +455,11 @@ class TestParameterCaster(unittest.TestCase):
404
455
  self.assertTrue('undefined_param_multi' in casted)
405
456
  self.assertFalse(isinstance(casted['undefined_param_single'], list))
406
457
  self.assertTrue(isinstance(casted['undefined_param_multi'], list))
458
+ self.assertEqual(3, len(casted['map_param']))
459
+ self.assertTrue(all(map(lambda x: isinstance(x, str), casted['map_param'].values())))
460
+ self.assertTrue('val4', len(casted['map_param']['key1']))
461
+ self.assertTrue('val2', len(casted['map_param']['key2']))
462
+ self.assertTrue('val3', len(casted['map_param']['key3']))
407
463
  unknown_param_key = 'unknown'
408
464
  unknown_param_val = 'dunno'
409
465
  params[unknown_param_key] = [unknown_param_val]
@@ -1 +0,0 @@
1
- 1.1.2
@@ -1 +0,0 @@
1
- __version__ = "1.1.2"
File without changes
File without changes
File without changes
File without changes
File without changes