clams-python 1.1.3__tar.gz → 1.1.5__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.3/clams_python.egg-info → clams-python-1.1.5}/PKG-INFO +2 -2
  2. clams-python-1.1.5/VERSION +1 -0
  3. {clams-python-1.1.3 → clams-python-1.1.5}/clams/app/__init__.py +150 -30
  4. {clams-python-1.1.3 → clams-python-1.1.5}/clams/appmetadata/__init__.py +83 -23
  5. {clams-python-1.1.3 → clams-python-1.1.5}/clams/restify/__init__.py +6 -88
  6. clams-python-1.1.5/clams/ver/__init__.py +1 -0
  7. {clams-python-1.1.3 → clams-python-1.1.5/clams_python.egg-info}/PKG-INFO +2 -2
  8. {clams-python-1.1.3 → clams-python-1.1.5}/clams_python.egg-info/requires.txt +1 -1
  9. {clams-python-1.1.3 → clams-python-1.1.5}/requirements.txt +1 -1
  10. {clams-python-1.1.3 → clams-python-1.1.5}/tests/test_clamsapp.py +85 -31
  11. clams-python-1.1.3/VERSION +0 -1
  12. clams-python-1.1.3/clams/ver/__init__.py +0 -1
  13. {clams-python-1.1.3 → clams-python-1.1.5}/LICENSE +0 -0
  14. {clams-python-1.1.3 → clams-python-1.1.5}/MANIFEST.in +0 -0
  15. {clams-python-1.1.3 → clams-python-1.1.5}/README.md +0 -0
  16. {clams-python-1.1.3 → clams-python-1.1.5}/clams/__init__.py +0 -0
  17. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/__init__.py +0 -0
  18. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/app/.dockerignore.template +0 -0
  19. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/app/.gitignore.template +0 -0
  20. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/app/Containerfile.template +0 -0
  21. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/app/LICENSE.template +0 -0
  22. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/app/README.md.template +0 -0
  23. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/app/app.py.template +0 -0
  24. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/app/metadata.py.template +0 -0
  25. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/app/requirements.txt.template +0 -0
  26. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/gha/for-clams-team.md.template +0 -0
  27. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/gha/workflows/issue-apps-project.yml.template +0 -0
  28. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/gha/workflows/issue-assign.yml.template +0 -0
  29. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/gha/workflows/issue-close.yml.template +0 -0
  30. {clams-python-1.1.3 → clams-python-1.1.5}/clams/develop/templates/gha/workflows/publish.yml.template +0 -0
  31. {clams-python-1.1.3 → clams-python-1.1.5}/clams/mmif_utils/__init__.py +0 -0
  32. {clams-python-1.1.3 → clams-python-1.1.5}/clams/mmif_utils/rewind.py +0 -0
  33. {clams-python-1.1.3 → clams-python-1.1.5}/clams/mmif_utils/source.py +0 -0
  34. {clams-python-1.1.3 → clams-python-1.1.5}/clams/serve/__init__.py +0 -0
  35. {clams-python-1.1.3 → clams-python-1.1.5}/clams_python.egg-info/SOURCES.txt +0 -0
  36. {clams-python-1.1.3 → clams-python-1.1.5}/clams_python.egg-info/dependency_links.txt +0 -0
  37. {clams-python-1.1.3 → clams-python-1.1.5}/clams_python.egg-info/entry_points.txt +0 -0
  38. {clams-python-1.1.3 → clams-python-1.1.5}/clams_python.egg-info/top_level.txt +0 -0
  39. {clams-python-1.1.3 → clams-python-1.1.5}/setup.cfg +0 -0
  40. {clams-python-1.1.3 → clams-python-1.1.5}/setup.py +0 -0
  41. {clams-python-1.1.3 → clams-python-1.1.5}/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.3
3
+ Version: 1.1.5
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.10
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.5
@@ -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, Tuple
13
13
 
14
14
  from mmif import Mmif, Document, DocumentTypes, View
15
- from clams.appmetadata import AppMetadata
15
+ from clams.appmetadata import AppMetadata, real_valued_primitives, python_type, map_param_kv_delimiter
16
16
 
17
17
  logging.basicConfig(
18
18
  level=logging.WARNING,
@@ -28,12 +28,14 @@ class ClamsApp(ABC):
28
28
  """
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
+ # This needs to stay as a list of dicts for compatibility with the metadata.py file (templated).
31
32
  universal_parameters = [
32
33
  {
33
34
  'name': 'pretty', 'type': 'boolean', 'choices': None, 'default': False, 'multivalued': False,
34
35
  'description': 'The JSON body of the HTTP response will be re-formatted with 2-space indentation',
35
36
  },
36
37
  ]
38
+
37
39
  # this key is used to store users' raw input params in the parameter dict
38
40
  # even after "refinement" (i.e., casting to proper data types)
39
41
  _RAW_PARAMS_KEY = "#RAW#"
@@ -41,25 +43,30 @@ class ClamsApp(ABC):
41
43
  def __init__(self):
42
44
  self.metadata: AppMetadata = self._load_appmetadata()
43
45
  super().__init__()
44
- # data type specification for common parameters
45
- python_type = {"boolean": bool, "number": float, "integer": int, "string": str}
46
-
46
+
47
+ # data type spec to be used in type caster
47
48
  self.metadata_param_spec = {}
48
49
  self.annotate_param_spec = {}
49
50
  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))
51
+ # in addition to building the param spec, add them as regular params, so they are serialized in metadata
52
+ self.metadata.add_parameter(**param)
53
+ self.metadata_param_spec[param['name']] = (param['type'], param.get('multivalued', False))
52
54
  for param_spec in self.metadata.parameters:
53
- self.annotate_param_spec[param_spec.name] = (python_type[param_spec.type], param_spec.multivalued)
55
+ self.annotate_param_spec[param_spec.name] = (param_spec.type, param_spec.multivalued)
56
+
57
+ self.metadata_param_caster = ParameterCaster(self.metadata_param_spec)
58
+ self.annotate_param_caster = ParameterCaster(self.annotate_param_spec)
54
59
  self.logger = logging.getLogger(self.metadata.identifier)
55
60
 
56
- def appmetadata(self, **kwargs) -> str:
61
+ def appmetadata(self, **kwargs: List[str]) -> str:
57
62
  """
58
63
  A public method to get metadata for this app as a string.
59
64
 
60
65
  :return: Serialized JSON string of the metadata
61
66
  """
62
- pretty = kwargs.pop('pretty') if 'pretty' in kwargs else False
67
+ # cast only, no refinement
68
+ casted = self.metadata_param_caster.cast(kwargs)
69
+ pretty = casted.pop('pretty') if 'pretty' in casted else False
63
70
  return self.metadata.jsonify(pretty)
64
71
 
65
72
  def _load_appmetadata(self) -> AppMetadata:
@@ -98,7 +105,7 @@ class ClamsApp(ABC):
98
105
  def _check_mmif_compatibility(target_specver, input_specver):
99
106
  return target_specver.split('.')[:2] == input_specver.split('.')[:2]
100
107
 
101
- def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params) -> str:
108
+ def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params: List[str]) -> str:
102
109
  """
103
110
  A public method to invoke the primary app function. It's essentially a
104
111
  wrapper around :meth:`~clams.app.ClamsApp._annotate` method where some common operations
@@ -108,21 +115,22 @@ class ClamsApp(ABC):
108
115
  :param runtime_params: An arbitrary set of k-v pairs to configure the app at runtime
109
116
  :return: Serialized JSON string of the output of the app
110
117
  """
111
- pretty = runtime_params.get('pretty', False)
112
118
  if not isinstance(mmif, Mmif):
113
119
  mmif = Mmif(mmif)
114
120
  issued_warnings = []
115
121
  for key in runtime_params:
116
122
  if key not in self.annotate_param_spec:
117
123
  issued_warnings.append(UserWarning(f'An undefined parameter "{key}" (value: "{runtime_params[key]}") is passed'))
118
- refined_params = self._refine_params(**runtime_params)
124
+ # this will do casting + refinement altogether
125
+ refined = self._refine_params(**runtime_params)
126
+ pretty = refined.get('pretty', False)
119
127
  with warnings.catch_warnings(record=True) as ws:
120
- annotated = self._annotate(mmif, **refined_params)
128
+ annotated = self._annotate(mmif, **refined)
121
129
  if ws:
122
130
  issued_warnings.extend(ws)
123
131
  if issued_warnings:
124
132
  warnings_view = annotated.new_view()
125
- self.sign_view(warnings_view, refined_params)
133
+ self.sign_view(warnings_view, runtime_params)
126
134
  warnings_view.metadata.warnings = issued_warnings
127
135
  return annotated.serialize(pretty=pretty, sanitize=True)
128
136
 
@@ -148,7 +156,7 @@ class ClamsApp(ABC):
148
156
  """
149
157
  raise NotImplementedError()
150
158
 
151
- def _refine_params(self, **runtime_params):
159
+ def _refine_params(self, **runtime_params: List[str]):
152
160
  """
153
161
  Method to "fill" the parameter dictionary with default values, when a key-value is not specified in the input.
154
162
  The input map is not really "filled" as a copy of it is returned with addition of default values.
@@ -159,19 +167,21 @@ class ClamsApp(ABC):
159
167
  if self._RAW_PARAMS_KEY in runtime_params:
160
168
  # meaning the dict is already refined, just return it
161
169
  return runtime_params
162
- conf = {}
170
+ refined = {}
171
+
172
+ casted = self.annotate_param_caster.cast(runtime_params)
163
173
  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:
174
+ if parameter.name in casted:
175
+ if parameter.choices and casted[parameter.name] not in parameter.choices:
166
176
  raise ValueError(f"Value for parameter \"{parameter.name}\" must be one of {parameter.choices}.")
167
- conf[parameter.name] = runtime_params[parameter.name]
177
+ refined[parameter.name] = casted[parameter.name]
168
178
  elif parameter.default is not None:
169
- conf[parameter.name] = parameter.default
179
+ refined[parameter.name] = parameter.default
170
180
  else:
171
181
  raise ValueError(f"Cannot find configuration for a required parameter \"{parameter.name}\".")
172
182
  # raw input params are hidden under a special key
173
- conf[self._RAW_PARAMS_KEY] = runtime_params
174
- return conf
183
+ refined[self._RAW_PARAMS_KEY] = runtime_params
184
+ return refined
175
185
 
176
186
  def get_configuration(self, **runtime_params):
177
187
  warnings.warn("ClamsApp.get_configuration() is deprecated. "
@@ -197,15 +207,26 @@ class ClamsApp(ABC):
197
207
  "no longer be optional in the future. Please pass `runtime_params` "
198
208
  "from _annotate() method.",
199
209
  FutureWarning, stacklevel=2)
210
+ return
200
211
  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()})
212
+ if self._RAW_PARAMS_KEY in runtime_conf:
213
+ for k, v in runtime_conf.items():
214
+ if k == self._RAW_PARAMS_KEY:
215
+ for orik, oriv in v.items():
216
+ if orik in self.metadata.parameters and self.metadata.parameters[orik].multivalued:
217
+ view.metadata.add_parameter(orik, str(oriv))
218
+ else:
219
+ view.metadata.add_parameter(orik, oriv[0])
220
+ view.metadata.add_app_configuration(k, v)
221
+ else:
222
+ # meaning the parameters directly from flask or argparser and values are in lists
223
+ for k, v in runtime_conf.items():
224
+ if k in self.metadata.parameters and self.metadata.parameters[k].multivalued:
225
+ view.metadata.add_parameter(k, str(v))
226
+ else:
227
+ view.metadata.add_parameter(k, v[0])
207
228
 
208
- def set_error_view(self, mmif: Union[str, dict, Mmif], runtime_conf: Optional[dict] = None) -> Mmif:
229
+ def set_error_view(self, mmif: Union[str, dict, Mmif], **runtime_conf: List[str]) -> Mmif:
209
230
  """
210
231
  A method to record an error instead of annotation results in the view
211
232
  this app generated. For logging purpose, the runtime parameters used
@@ -282,3 +303,102 @@ class ClamsApp(ABC):
282
303
  document_file.close()
283
304
  else:
284
305
  raise FileNotFoundError(p.path)
306
+
307
+
308
+ class ParameterCaster(object):
309
+
310
+ def __init__(self, param_spec: Dict[str, Tuple[str, bool]]):
311
+ """
312
+ A helper class to convert parameters passed by HTTP query strings to
313
+ proper python data types.
314
+
315
+ :param param_spec: A specification of a data types of parameters
316
+ """
317
+ self.param_spec = {pname: (python_type[ptype_str], pmultivalued)
318
+ for pname, (ptype_str, pmultivalued) in param_spec.items()}
319
+
320
+ def cast(self, args: Dict[str, List[str]]) \
321
+ -> Dict[str, Union[real_valued_primitives, List[real_valued_primitives], Dict[str, str]]]:
322
+ """
323
+ Given parameter specification, tries to cast values of args to specified Python data types.
324
+ Note that this caster deals with query strings, thus all keys and values in the input args are plain strings.
325
+ Also note that the caster does not handle "unexpected" parameters came as an input.
326
+ Handling (raising an exception or issuing a warning upon receiving) an unexpected runtime parameter
327
+ must be done within the app itself.
328
+ Thus, when a key is not found in the parameter specifications, it should just pass it as a vanilla string.
329
+
330
+ :param args: k-v pairs
331
+ :return: A new dictionary of type-casted args, of which keys are always strings (parameter name),
332
+ and values are either
333
+ 1) a single value of a specified type (multivalued=False)
334
+ 2) a list of values of a specified type (multivalued=True) (all duplicates in the input are kept)
335
+ 3) a nested string-string dictionary (type=map ⊨ multivalued=True)
336
+ With the third case, developers can further process the nested values into a more complex data types or
337
+ structures, but that is not in the scope of this Caster class.
338
+ """
339
+ casted = {}
340
+ for k, vs in args.items():
341
+ assert isinstance(vs, list), f"Expected a list of values for key {k}, but got {vs} of type {type(vs)}"
342
+ 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]}"
343
+ if k in self.param_spec:
344
+ for v in vs:
345
+ valuetype, multivalued = self.param_spec[k]
346
+ if multivalued or k not in casted: # effectively only keeps the first value for non-multi params
347
+ if valuetype == bool:
348
+ v = self.bool_param(v)
349
+ elif valuetype == float:
350
+ v = self.float_param(v)
351
+ elif valuetype == int:
352
+ v = self.int_param(v)
353
+ elif valuetype == str:
354
+ v = self.str_param(v)
355
+ elif valuetype == dict:
356
+ v = self.kv_param(v)
357
+ if multivalued:
358
+ if valuetype == dict:
359
+ casted.setdefault(k, {}).update(v)
360
+ else:
361
+ casted.setdefault(k, []).append(v)
362
+ else:
363
+ casted[k] = v
364
+ else:
365
+ if len(vs) > 1:
366
+ casted[k] = vs
367
+ else:
368
+ casted[k] = vs[0]
369
+ return casted # pytype: disable=bad-return-type
370
+
371
+ @staticmethod
372
+ def bool_param(value) -> bool:
373
+ """
374
+ Helper function to convert string values to bool type.
375
+ """
376
+ return False if value in (False, 0, 'False', 'false', '0') else True
377
+
378
+ @staticmethod
379
+ def float_param(value) -> float:
380
+ """
381
+ Helper function to convert string values to float type.
382
+ """
383
+ return float(value)
384
+
385
+ @staticmethod
386
+ def int_param(value) -> int:
387
+ """
388
+ Helper function to convert string values to int type.
389
+ """
390
+ return int(value)
391
+
392
+ @staticmethod
393
+ def str_param(value) -> str:
394
+ """
395
+ Helper function to convert string values to string type.
396
+ """
397
+ return value
398
+
399
+ @staticmethod
400
+ def kv_param(value) -> Dict[str, str]:
401
+ """
402
+ Helper function to convert string values to key-value pair type.
403
+ """
404
+ return dict([value.split(map_param_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,12 @@ 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
+ # and the specific delimiter for the map type
19
+ map_param_kv_delimiter = ':'
20
+
21
+ # dict to map app parameter data types to Python data types
22
+ python_type = {"boolean": bool, "number": float, "integer": int, "string": str, "map": dict}
18
23
 
19
24
  param_value_types_values = param_value_types.__args__ # pytype: disable=attribute-error
20
25
  app_directory_baseurl = "http://apps.clams.ai"
@@ -38,9 +43,12 @@ def generate_app_version(cwd=None):
38
43
  gitcmd = shutil.which('git')
39
44
  gitdir = (Path(sys.modules["__main__"].__file__).parent.resolve() if cwd is None else Path(cwd)) / '.git'
40
45
  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()
46
+ try:
47
+ proc = subprocess.run([gitcmd, '--git-dir', str(gitdir), 'describe', '--tags', '--always'],
48
+ capture_output=True, check=True)
49
+ return proc.stdout.decode('utf8').strip()
50
+ except subprocess.CalledProcessError:
51
+ return unresolved_app_version_num
44
52
  elif app_version_envvar_key in os.environ:
45
53
  return os.environ[app_version_envvar_key]
46
54
  else:
@@ -65,20 +73,29 @@ class Output(_BaseModel):
65
73
  Data model that describes output specification of a CLAMS app.
66
74
 
67
75
  CLAMS apps are expected to have at least one output type, and each type must
68
- be defined by a ``@type`` URI string. If the type has specific properties and values,
76
+ be defined by a ``@type`` URI string. If the type has common properties and values generated by the app,
69
77
  they can be described in the (optional) ``properties`` field. Finally, a human-readable
70
78
  verbose description can be provided in the (optional) ``description`` field for users.
71
79
 
72
- Developers should take diligent care to include all output types and their properties in the app metadata.
80
+ Developers should take diligent care to include all output types and their properties in the app metadata. To
81
+ specify the property values, developers can use an actual value (for full match) or ``'*'`` (for any value).
73
82
  """
74
83
  at_type: pydantic.AnyHttpUrl = pydantic.Field(
75
84
  ...,
76
85
  alias="@type",
77
86
  description="The type of the object. Must be a IRI string."
78
87
  )
88
+ description: Optional[str] = pydantic.Field(
89
+ None,
90
+ description="A verbose, human-readable description of the type. This is intended to be used for documentation "
91
+ "purpose for a particular use case of this annotation type and is not expected to be consumed by "
92
+ "software. This description should work as a guideline for users to understand the output type, "
93
+ "and also can be used as a expansion specification for the type definition beyond the base "
94
+ "vocabulary."
95
+ )
79
96
  properties: Dict[str, real_valued_primitives] = pydantic.Field(
80
97
  {},
81
- description="(optional) Specification for type properties, if any."
98
+ description="(optional) Specification for type properties, if any. ``\"*\"`` indicates any value."
82
99
  )
83
100
 
84
101
  @pydantic.validator('at_type', pre=True)
@@ -91,18 +108,24 @@ class Output(_BaseModel):
91
108
  title = 'CLAMS Output Specification'
92
109
  extra = 'forbid'
93
110
  allow_population_by_field_name = True
111
+
112
+ def add_description(self, description: str):
113
+ """
114
+ Add description string to the type specification.
115
+ """
116
+ self.description = description
94
117
 
95
118
 
96
119
  class Input(Output):
97
120
  """
98
121
  Data model that describes input specification of a CLAMS app.
99
122
 
100
- CLAMS apps are expected to have at least one output type, and each type must
101
- be defined by a ``@type`` URI string. If the type has specific properties and values,
123
+ CLAMS apps are expected to have at least one input type, and each type must
124
+ be defined by a ``@type`` URI string. If the type has specific properties and values required by the app,
102
125
  they can be described in the (optional) ``properties`` field. Finally, a human-readable
103
126
  verbose description can be provided in the (optional) ``description`` field for users.
104
127
 
105
- Developers should take diligent care to include all output types and their properties in the app metadata.
128
+ Developers should take diligent care to include all input types and their properties in the app metadata.
106
129
  """
107
130
  required: bool = pydantic.Field(
108
131
  None,
@@ -138,16 +161,33 @@ class RuntimeParameter(_BaseModel):
138
161
  )
139
162
  type: param_value_types = pydantic.Field(
140
163
  ...,
141
- description=f"Type of the parameter value the app expects. Must be one of {param_value_types_values}."
164
+ description=f"Type of the parameter value the app expects. Must be one of {param_value_types_values}. When "
165
+ "type is ``map``, ``multivalued=true`` is automatically forced. \n\n"
166
+ "Notes for developers: \n\n"
167
+ "When the type is ``map``, the parameter value (still a single string from the users' perspective) "
168
+ "must be formatted as a ``KEY:VALUE`` pair, namely a colon-separated string. To pass multiple "
169
+ "key-value pairs, users need to pass the parameter multiple times (remember ``type=map`` implies "
170
+ "``multivalued=true``) with pairs in the colon-separated format. \n\n"
171
+ "Also, the `VALUE` part of the user input is always expected and handled as a string. If a "
172
+ "developers wants to do more text processing on the passed value to accept more complex data types "
173
+ "or structures (e.g., map from a string to a list of strings), it is up to the developer. However, "
174
+ "any additional form requirements should be precisely described in the ``description`` field for "
175
+ "users. \n\n"
176
+ "Finally, the same format is expected for the default value, if any. For example, if the default "
177
+ "desired dictionary is ``{'key1': 'value1', 'key2': 'value2'}``, the default value (used when "
178
+ "initializing a parameter) should be ``['key1:value1','key2:value2']``\n."
142
179
  )
143
180
  choices: List[real_valued_primitives] = pydantic.Field(
144
181
  None,
145
182
  description="(optional) List of string values that can be accepted."
146
183
  )
147
- default: real_valued_primitives = pydantic.Field(
184
+ default: Union[real_valued_primitives, List[real_valued_primitives]] = pydantic.Field(
148
185
  None,
149
- description="(optional) Default value for the parameter. Only valid for optional parameters. Namely, setting "
150
- "a default value makes a parameter 'optional'."
186
+ description="(optional) Default value for the parameter.\n\n"
187
+ "Notes for developers: \n\n"
188
+ "Setting a default value makes a parameter `optional`. \n\n"
189
+ "When ``multivalued=True``, the default value should be a list of values. \n\n"
190
+ "When ``type=map``, the default value should be a list of colon-separated strings. \n\n"
151
191
  )
152
192
  multivalued: bool = pydantic.Field(
153
193
  ...,
@@ -157,8 +197,15 @@ class RuntimeParameter(_BaseModel):
157
197
 
158
198
  def __init__(self, **kwargs):
159
199
  super().__init__(**kwargs)
200
+ if self.type == 'map':
201
+ if self.multivalued is False:
202
+ raise ValueError("When the type is `map`, multivalued must not be False.")
203
+ else:
204
+ self.multivalued = True
160
205
  if self.multivalued is None:
161
206
  self.multivalued = False
207
+ if self.multivalued and self.default is not None and not isinstance(self.default, list):
208
+ self.default = [self.default]
162
209
 
163
210
  class Config:
164
211
  title = 'CLAMS App Runtime Parameter'
@@ -301,13 +348,14 @@ class AppMetadata(pydantic.BaseModel):
301
348
  return True
302
349
  return False
303
350
 
304
- def add_input(self, at_type: Union[str, vocabulary.ThingTypesBase], required: bool = True, **properties):
351
+ def add_input(self, at_type: Union[str, vocabulary.ThingTypesBase], required: bool = True, **properties) -> Input:
305
352
  """
306
353
  Helper method to add an element to the ``input`` list.
307
354
 
308
355
  :param at_type: ``@type`` of the input object
309
356
  :param required: whether this type is mandatory or optional
310
357
  :param properties: additional property specifications
358
+ :return: the newly added Input object
311
359
  """
312
360
  new = Input(at_type=at_type, required=required)
313
361
  if len(properties) > 0:
@@ -320,8 +368,9 @@ class AppMetadata(pydantic.BaseModel):
320
368
  # TODO (krim @ 5/12/21): automatically add *optional* input types to parameter
321
369
  # see https://github.com/clamsproject/clams-python/issues/29 for discussion
322
370
  pass
371
+ return new
323
372
 
324
- def add_input_oneof(self, *inputs: Union[str, Input, vocabulary.ThingTypesBase] ):
373
+ def add_input_oneof(self, *inputs: Union[str, Input, vocabulary.ThingTypesBase]):
325
374
  newinputs = []
326
375
  if len(inputs) == 1:
327
376
  if isinstance(inputs[0], Input):
@@ -342,12 +391,13 @@ class AppMetadata(pydantic.BaseModel):
342
391
  newinputs.append(i)
343
392
  self.input.append(newinputs)
344
393
 
345
- def add_output(self, at_type: Union[str, vocabulary.ThingTypesBase], **properties):
394
+ def add_output(self, at_type: Union[str, vocabulary.ThingTypesBase], **properties) -> Output:
346
395
  """
347
396
  Helper method to add an element to the ``output`` list.
348
397
 
349
398
  :param at_type: ``@type`` of the input object
350
399
  :param properties: additional property specifications
400
+ :return: the newly added Output object
351
401
  """
352
402
  new = Output(at_type=at_type)
353
403
  if len(properties) > 0:
@@ -356,21 +406,31 @@ class AppMetadata(pydantic.BaseModel):
356
406
  self.output.append(new)
357
407
  else:
358
408
  raise ValueError(f"Cannot add a duplicate output '{new}'.")
409
+ return new
359
410
 
360
411
  def add_parameter(self, name: str, description: str, type: param_value_types,
361
412
  choices: Optional[List[real_valued_primitives]] = None,
362
413
  multivalued: bool = False,
363
- default: real_valued_primitives = None):
414
+ default: Union[real_valued_primitives, List[real_valued_primitives]] = None):
364
415
  """
365
416
  Helper method to add an element to the ``parameters`` list.
366
417
  """
367
- # casting default values (when the value is not nothing) makes sure
368
- # the values are correctly casted by the pydantic
418
+ # casting default values (when the value is not nothing) to str
419
+ # will make sure the values are correctly handled by the pydantic
369
420
  # see https://docs.pydantic.dev/1.10/usage/types/#unions
370
421
  # e.g. casting 0.1 using the `primitives` dict will result in 0 (int)
371
422
  # while casting "0.1" using the `primitives` dict will result in 0.1 (float)
372
- new_param = RuntimeParameter(name=name, description=description, type=type,
373
- choices=choices, default=str(default) if default else default, multivalued=multivalued)
423
+ if type == 'map' and multivalued is False:
424
+ multivalued = True
425
+ if default is not None:
426
+ if isinstance(default, list):
427
+ default = [str(d) for d in default]
428
+ else:
429
+ default = str(default)
430
+
431
+ new_param = RuntimeParameter(
432
+ name=name, description=description, type=type, choices=choices, multivalued=multivalued,
433
+ default=default)
374
434
  if new_param.name not in [param.name for param in self.parameters]:
375
435
  self.parameters.append(new_param)
376
436
  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.5"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: clams-python
3
- Version: 1.1.3
3
+ Version: 1.1.5
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.10
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.10
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.10
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.app import ParameterCaster
16
17
  from clams.appmetadata import AppMetadata, Input
17
- from clams.restify import ParameterCaster
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,13 +420,14 @@ 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)
423
+ self.param_spec = {'str_param': ('string', False),
424
+ 'number_param': ('number', False),
425
+ 'int_param': ('integer', False),
426
+ 'bool_param': ('boolean', False),
427
+ 'str_multi_param': ('string', True),
428
+ 'map_param': ('map', True),
382
429
  }
383
-
430
+
384
431
  def test_cast(self):
385
432
  caster = ParameterCaster(self.param_spec)
386
433
  params = {
@@ -391,7 +438,9 @@ class TestParameterCaster(unittest.TestCase):
391
438
  'str_multi_param': ['value1', 'value2'],
392
439
  'undefined_param_single': ['undefined_value1'],
393
440
  'undefined_param_multi': ['undefined_value1', 'undefined_value2'],
441
+ 'map_param': ['key1:val1', 'key2:val2', 'key3:val3', 'key1:val4']
394
442
  }
443
+ # all "RAW" parameter values are assumed to be strings
395
444
  self.assertTrue(all(map(lambda x: isinstance(x, str), itertools.chain.from_iterable(params.values()))))
396
445
  casted = caster.cast(params)
397
446
  # must push out to the list
@@ -404,6 +453,11 @@ class TestParameterCaster(unittest.TestCase):
404
453
  self.assertTrue('undefined_param_multi' in casted)
405
454
  self.assertFalse(isinstance(casted['undefined_param_single'], list))
406
455
  self.assertTrue(isinstance(casted['undefined_param_multi'], list))
456
+ self.assertEqual(3, len(casted['map_param']))
457
+ self.assertTrue(all(map(lambda x: isinstance(x, str), casted['map_param'].values())))
458
+ self.assertTrue('val4', len(casted['map_param']['key1']))
459
+ self.assertTrue('val2', len(casted['map_param']['key2']))
460
+ self.assertTrue('val3', len(casted['map_param']['key3']))
407
461
  unknown_param_key = 'unknown'
408
462
  unknown_param_val = 'dunno'
409
463
  params[unknown_param_key] = [unknown_param_val]
@@ -1 +0,0 @@
1
- 1.1.3
@@ -1 +0,0 @@
1
- __version__ = "1.1.3"
File without changes
File without changes
File without changes
File without changes
File without changes