diff --git a/clams/__init__.py b/clams/__init__.py index bd1753f..5ed8716 100644 --- a/clams/__init__.py +++ b/clams/__init__.py @@ -3,9 +3,11 @@ import mmif from clams import develop +from clams import envelop from clams.app import * from clams.app import __all__ as app_all from clams.appmetadata import AppMetadata +from clams.envelop import create_envelope from clams.restify import Restifier from clams.ver import __version__ @@ -32,6 +34,7 @@ def cli(): to_register = list(mmif.find_all_modules('mmif.utils.cli')) # then add my own subcommands to_register.append(develop) + to_register.append(envelop) for cli_module in to_register: cli_module_name = cli_module.__name__.rsplit('.')[-1] cli_modules[cli_module_name] = cli_module diff --git a/clams/envelop/__init__.py b/clams/envelop/__init__.py new file mode 100644 index 0000000..6be34e1 --- /dev/null +++ b/clams/envelop/__init__.py @@ -0,0 +1,161 @@ +import argparse +import json +import sys +from typing import Dict, List, Optional, Tuple, Union + +from mmif import Mmif + +from clams.appmetadata import map_param_kv_delimiter + +ENVELOPE_KEY = 'parameters' +MMIF_KEY = 'mmif' + + +def normalize_params(params: dict) -> Dict[str, List[str]]: + """ + Normalize JSON-native parameter values to the + ``Dict[str, List[str]]`` format expected by + :class:`~clams.app.ParameterCaster`. + + :param params: parameter dict with JSON-native values + :returns: normalized dict where every value is a list of strings + :rtype: Dict[str, List[str]] + """ + normalized = {} + for k, v in params.items(): + if isinstance(v, list): + normalized[k] = [str(elem) for elem in v] + elif isinstance(v, dict): + normalized[k] = [ + f"{dk}{map_param_kv_delimiter}{dv}" + for dk, dv in v.items() + ] + else: + normalized[k] = [str(v)] + return normalized + + +def is_envelope(body: dict) -> bool: + """ + Check whether a parsed JSON body is an envelope. + + Detection relies on the presence of a top-level ``"parameters"`` + key, which is never part of the MMIF schema. + + :param body: parsed JSON dict + :returns: True if the body appears to be an envelope + :rtype: bool + """ + return isinstance(body, dict) and ENVELOPE_KEY in body + + +def unwrap_envelope(body: dict) -> Tuple[str, Dict[str, List[str]]]: + """ + Extract MMIF and normalized parameters from an envelope. + + :param body: parsed JSON dict with ``"parameters"`` and ``"mmif"`` + :returns: tuple of (mmif_json_string, normalized_params) + :rtype: Tuple[str, Dict[str, List[str]]] + :raises ValueError: if ``"mmif"`` key is missing or + ``"parameters"`` is not a dict + """ + params = body.get(ENVELOPE_KEY) + if not isinstance(params, dict): + raise ValueError( + f'"{ENVELOPE_KEY}" must be a JSON object, ' + f'got {type(params).__name__}' + ) + if MMIF_KEY not in body: + raise ValueError( + f'Envelope is missing required "{MMIF_KEY}" key' + ) + mmif_str = json.dumps(body[MMIF_KEY]) + return mmif_str, normalize_params(params) + + +def create_envelope( + mmif: Union[str, dict, Mmif], + parameters: Optional[dict] = None, +) -> str: + """ + Create a JSON envelope string wrapping MMIF and parameters. + + :param mmif: MMIF as a string, dict, or + :class:`~mmif.serialize.mmif.Mmif` object + :param parameters: parameter dict with JSON-native values + :returns: JSON string of the envelope + :rtype: str + """ + if isinstance(mmif, Mmif): + mmif_obj = json.loads(mmif.serialize()) + elif isinstance(mmif, str): + mmif_obj = json.loads(mmif) + else: + mmif_obj = mmif + envelope = { + ENVELOPE_KEY: parameters if parameters is not None else {}, + MMIF_KEY: mmif_obj, + } + return json.dumps(envelope) + + +# -- CLI interface --------------------------------------------------- + +def describe_argparser(): + """ + :returns: tuple of (one-line help, detailed description) + """ + oneliner = ( + 'create a JSON envelope wrapping MMIF and runtime parameters' + ) + detailed = ( + 'Reads a JSON parameter file and an MMIF file (or stdin), ' + 'combines them into a JSON envelope, and writes the result ' + 'to stdout. The envelope can be POSTed directly to a CLAMS ' + 'app HTTP endpoint.' + ) + return oneliner, detailed + + +def prep_argparser(**kwargs): + """ + :returns: argparse.ArgumentParser for the envelop subcommand + """ + parser = argparse.ArgumentParser( + description=describe_argparser()[1], + formatter_class=argparse.RawDescriptionHelpFormatter, + **kwargs, + ) + parser.add_argument( + 'PARAMS_FILE', + type=argparse.FileType('r'), + help='Path to a JSON file containing runtime parameters.', + ) + parser.add_argument( + 'MMIF_FILE', + nargs='?', + type=argparse.FileType('r'), + default=None if sys.stdin.isatty() else sys.stdin, + help=( + 'Path to the input MMIF file, or stdin if omitted. ' + 'Use "-" to explicitly read from stdin.' + ), + ) + return parser + + +def main(args): + """ + CLI entry point. Reads params JSON and MMIF, writes envelope + JSON to stdout. + """ + if args.MMIF_FILE is None: + print( + 'Error: no MMIF input provided ' + '(pass a file path or pipe to stdin)', + file=sys.stderr, + ) + sys.exit(1) + params = json.load(args.PARAMS_FILE) + mmif_str = args.MMIF_FILE.read() + print(create_envelope(mmif_str, parameters=params)) diff --git a/clams/restify/__init__.py b/clams/restify/__init__.py index 811ee4a..a059312 100644 --- a/clams/restify/__init__.py +++ b/clams/restify/__init__.py @@ -1,9 +1,12 @@ +import json + import jsonschema from flask import Flask, request, Response from flask_restful import Resource, Api from mmif import Mmif from clams.app import ClamsApp +from clams.envelop import is_envelope, unwrap_envelope class Restifier(object): @@ -188,19 +191,51 @@ def post(self) -> Response: Maps HTTP POST verb to :meth:`~clams.app.ClamsApp.annotate`. Note that for now HTTP PUT verbs is also mapped to :meth:`~clams.app.ClamsApp.annotate`. + The request body can be either raw MMIF JSON or a JSON envelope + containing both ``"parameters"`` and ``"mmif"`` keys. When an + envelope is detected, parameters are normalized and merged with + any query-string parameters (query string takes priority). + :return: Returns MMIF output from a ClamsApp in a HTTP response. """ raw_data = request.get_data().decode('utf-8') # this will catch duplicate arguments with different values into a list under the key raw_params = request.args.to_dict(flat=False) try: - _ = Mmif(raw_data) + body = json.loads(raw_data) + except (json.JSONDecodeError, ValueError): + return Response( + response="Invalid JSON in request body.", + status=500, mimetype='text/plain') + if is_envelope(body): + try: + mmif_data, envelope_params = unwrap_envelope(body) + except ValueError as e: + return Response( + response=f"Invalid envelope format: {e}", + status=500, mimetype='text/plain') + # query string overrides envelope parameters + params = {**envelope_params, **raw_params} + else: + mmif_data = raw_data + params = raw_params + try: + _ = Mmif(mmif_data) except jsonschema.exceptions.ValidationError as e: - return Response(response="Invalid input data. See below for validation error.\n\n" + str(e), status=500, mimetype='text/plain') + return Response( + response="Invalid input data. " + "See below for validation error.\n\n" + + str(e), + status=500, mimetype='text/plain') try: - return self.json_to_response(self.cla.annotate(raw_data, **raw_params)) + return self.json_to_response( + self.cla.annotate(mmif_data, **params)) except Exception: self.cla.logger.exception("Error in annotation") - return self.json_to_response(self.cla.record_error(raw_data, **raw_params).serialize(pretty=True), status=500) + return self.json_to_response( + self.cla.record_error( + mmif_data, **params + ).serialize(pretty=True), + status=500) put = post diff --git a/documentation/clamsapp.rst b/documentation/clamsapp.rst index b294db2..b1af2db 100644 --- a/documentation/clamsapp.rst +++ b/documentation/clamsapp.rst @@ -206,18 +206,140 @@ This can be resolved for the duration of the current session by using the comman Configuring the app ^^^^^^^^^^^^^^^^^^^ -Running as an HTTP server, CLAMS Apps are stateless, but can be configured for each HTTP request by providing configuration parameters as `query string `_. +CLAMS Apps are stateless, but can be configured per-request via runtime parameters. +Different apps have different configurability. +For configuration parameters of an app, please refer to the ``parameter`` section of the app metadata. +In addition to app-specific parameters, all apps support universal parameters (e.g., ``pretty`` for formatted output). +Check the app metadata for the complete and up-to-date list. + +For detailed documentation of parameter types including map-type and multivalued +parameters, see :ref:`runtime-params-detailed`. + +There are three ways to pass runtime parameters, depending on your execution mode. + +Via query string (HTTP) +""""""""""""""""""""""" + +When running an app as an HTTP server, you can pass simple parameters as +`query strings `_ appended to the request URL. For example, appending ``?pretty=True`` to the URL will result in a JSON output with indentation for better readability. +.. code-block:: bash + + $ curl -X POST -d@input.mmif "http://app-server:5000\?pretty=True" + +For multivalued parameters, repeat the parameter name: + +.. code-block:: bash + + $ curl -X POST -d@input.mmif "http://app-server:5000\?labels=PERSON\&labels=ORG" + .. note:: When you're using ``curl`` from a shell session, you need to escape the ``?`` or ``&`` characters with ``\`` to prevent the shell from interpreting it as a special character. -Different apps have different configurability. For configuration parameters of an app, please refer to ``parameter`` section of the app metadata. In addition to app-specific parameters, all apps support universal parameters (e.g., ``pretty`` for formatted output). Check the app metadata for the complete and up-to-date list. +Via CLI flags +""""""""""""" -For detailed documentation of parameter types including map-type and multivalued -parameters, see :ref:`runtime-params-detailed`. +When running an app via ``cli.py`` (see :ref:`clamsapp-cli` below), parameters +are passed as ``--``-prefixed flags. + +.. code-block:: bash + + $ python cli.py --pretty True input.mmif output.mmif + +For multivalued parameters, list the values after the flag, then use a +``--`` separator before the positional arguments: + +.. code-block:: bash + + $ python cli.py --labels PERSON ORG -- input.mmif output.mmif + +.. warning:: + + A multivalued flag greedily consumes every following token, so without + the ``--`` separator the positional ``INPUT_MMIF`` (and ``OUTPUT_MMIF``) + would be swallowed as additional ``--labels`` values, and the command + would fail with a missing-argument error. Always place ``--`` between the + last value of a multivalued flag and the positional file arguments. + +.. _clamsapp-envelope: + +Via JSON envelope +""""""""""""""""" + +For complex or lengthy parameter values (e.g., long prompts for LLM-based apps, +large map parameters), query strings and CLI flags can be impractical. +The JSON envelope format wraps both the MMIF input and the parameters in a +single JSON object. +It works the same way in both execution modes: the input (the POST body in +HTTP mode, or the ``INPUT_MMIF`` argument read from stdin or a positional +file path in CLI mode) can be either pure MMIF or a MMIF wrapped in a +parameter envelope, and the app detects and unwraps the envelope +automatically (see :ref:`clamsapp-cli`). +The envelope looks like this: + +.. code-block:: json + + { + "parameters": { + "prompt": "A very long prompt that would not fit in a query string...", + "labelMap": {"B": "bars", "S": "slate"}, + "temperature": 0.7, + "pretty": true + }, + "mmif": { + "metadata": { "mmif": "..." }, + "documents": [ "..." ], + "views": [] + } + } + +The output is always raw MMIF, regardless of input format, so downstream +pipeline steps are unaffected. + +You can still combine the envelope with query string parameters. +When the same parameter appears in both, the **query string takes priority**, +which allows quick overrides without editing the parameter file: + +.. code-block:: bash + + $ curl -X POST -d@envelope.json "http://app-server:5000\?temperature=0.3" + +The ``clams envelop`` CLI tool helps construct envelope JSON from a parameter +file and an MMIF file: + +.. note:: + + The subcommand is spelled ``envelop`` (no trailing ``e``) -- it is the + *verb* "to envelop" (to wrap/enclose), consistent with the other + action-named subcommands (``describe``, ``rewind``, ``summarize``). The + *noun* "envelope" still refers to the JSON object it produces. The + missing ``e`` is intentional, not a typo. + +.. code-block:: bash + + # from files + $ clams envelop params.json input.mmif > envelope.json + + # pipe-friendly: read MMIF from stdin + $ cat input.mmif | clams envelop params.json | curl -d@- http://app-server:5000 + + # chain apps: first app uses query string, second uses envelope + $ cat input.mmif \ + | curl -d@- -s "http://app1:5000\?simple_param=value" \ + | clams envelop params.json \ + | curl -d@- -s http://app2:5000 > output.mmif + +For programmatic use in Python: + +.. code-block:: python + + from clams import create_envelope + + body = create_envelope(mmif_obj, parameters={"prompt": "...", "labelMap": {"B": "bars"}}) + requests.post("http://app-server:5000", data=body) .. _clamsapp-cli: @@ -311,6 +433,16 @@ All parameter names are the same as the HTTP query parameters, but you need to u $ python cli.py --pretty True input.mmif output.mmif +.. note:: + + For complex parameters that are difficult to express as CLI flags (e.g., long + prompts, large map parameters), consider using the JSON envelope approach + instead. You can pipe ``clams envelop`` output into ``cli.py``'s stdin:: + + cat input.mmif | clams envelop params.json | python cli.py + + See :ref:`clamsapp-envelope` for details. + Finally, when running the app as a container, you can override the default ``CMD`` (``app.py``) by passing a ``cli.py`` command to the ``docker run`` command. .. code-block:: bash diff --git a/documentation/runtime-params.rst b/documentation/runtime-params.rst index c6f45fa..4d3bf93 100644 --- a/documentation/runtime-params.rst +++ b/documentation/runtime-params.rst @@ -41,10 +41,12 @@ As HTTP Server When running as a HTTP server, a CLAMS app should be stateless (or always set to default states), and all the state should be "configured" by the client for each request, via the runtime configuration parameters we described above if necessary. -For HTTP interface, users can enter configuration values via -`query strings `_ as part of the -request URL. For example, if the user wants to use the above app as a server -with the `labels` parameter only set to ``PERSON`` and ``ORG``, then the user +For HTTP interface, users can enter configuration values via +`query strings `_ as part of the +request URL, or via a JSON envelope in the POST body (see :ref:`clamsapp-configuring` +for user-facing details on all three parameter-passing methods). +For example, if the user wants to use the above app as a server +with the `labels` parameter only set to ``PERSON`` and ``ORG``, then the user can send a ``POST`` request to the server with the following URL: .. code-block:: bash @@ -187,3 +189,16 @@ Default values must be a list of colon-separated strings:: For more complex value structures (e.g., comma-separated lists within values), the app developer is responsible for further parsing and should document the expected format in the parameter's ``description`` field. + +.. _runtime-params-envelope-note: + +Note on JSON envelope input +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Users may also pass parameters via a JSON envelope in the POST body +(see :ref:`clamsapp-configuring` for user-facing documentation). +App developers do **not** need to handle this case specially. +The SDK normalizes envelope parameters to the same ``Dict[str, List[str]]`` +format as query strings before they reach ``_annotate()``, so all type casting, +default filling, and view signing work identically regardless of how parameters +were provided. diff --git a/pyproject.toml b/pyproject.toml index 1f97d59..1b3fc4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only", ] dependencies = [ - "mmif-python==1.3.1", + "mmif-python==1.4.0", "Flask>=2", "Flask-RESTful>=0.3.9", "gunicorn>=20", diff --git a/tests/test_clamsapp.py b/tests/test_clamsapp.py index dc9a5b7..1abcbac 100644 --- a/tests/test_clamsapp.py +++ b/tests/test_clamsapp.py @@ -73,9 +73,8 @@ def _annotate(self, mmif, **kwargs): new_view.new_contain(AnnotationTypes.TimeFrame, **{"producer": "dummy-producer"}) ann = new_view.new_annotation(AnnotationTypes.TimeFrame, 'a1', start=10, end=99) ann.add_property("f1", "hello_world") - d1 = DocumentTypes.VideoDocument - d2 = DocumentTypes.from_str(f'{str(d1)[:-1]}99') - if mmif.get_documents_by_type(d2): + # forcing a version mismatch warning for testing "warning view" generation in Restifier + if mmif.get_documents_by_type(DocumentTypes.VideoDocument_v1): new_view.new_annotation(AnnotationTypes.TimePoint, 'tp1') if 'raise_error' in kwargs and kwargs['raise_error']: raise ValueError diff --git a/tests/test_envelope.py b/tests/test_envelope.py new file mode 100644 index 0000000..833042f --- /dev/null +++ b/tests/test_envelope.py @@ -0,0 +1,338 @@ +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from typing import Union + +from mmif import AnnotationTypes, Mmif + +import clams +import clams.app +from clams.appmetadata import AppMetadata +from clams.envelop import ( + create_envelope, is_envelope, main as envelope_cli_main, + normalize_params, prep_argparser, unwrap_envelope, +) +from tests.test_clamsapp import ExampleInputMMIF + + +class EnvelopeTestApp(clams.app.ClamsApp): + """ + Minimal test app that exercises the parameter pipeline without + relying on mmif APIs that have shifted between versions. + """ + app_version = 'envelope-test' + + def _appmetadata(self) -> Union[dict, AppMetadata]: + # the metadata.py fallback in tests/ is used by other tests; + # we don't need extra params here, sign_view records anything + # in _RAW_PARAMS_KEY anyway. + pass + + def _annotate(self, mmif, **kwargs): + if type(mmif) is not Mmif: + mmif = Mmif(mmif, validate=False) + new_view = mmif.new_view() + self.sign_view(new_view, kwargs) + new_view.new_contain(AnnotationTypes.TimeFrame, + producer='envelope-test') + return mmif + + +class TestNormalizeParams(unittest.TestCase): + + def test_string_scalar(self): + result = normalize_params({'prompt': 'hello'}) + self.assertEqual(result, {'prompt': ['hello']}) + + def test_int_scalar(self): + result = normalize_params({'count': 5}) + self.assertEqual(result, {'count': ['5']}) + + def test_float_scalar(self): + result = normalize_params({'temperature': 0.7}) + self.assertEqual(result, {'temperature': ['0.7']}) + + def test_bool_true(self): + result = normalize_params({'pretty': True}) + self.assertEqual(result, {'pretty': ['True']}) + + def test_bool_false(self): + result = normalize_params({'pretty': False}) + self.assertEqual(result, {'pretty': ['False']}) + + def test_array_of_strings(self): + result = normalize_params({'labels': ['slate', 'chyron']}) + self.assertEqual(result, {'labels': ['slate', 'chyron']}) + + def test_array_of_numbers(self): + result = normalize_params({'ids': [1, 2, 3]}) + self.assertEqual(result, {'ids': ['1', '2', '3']}) + + def test_object(self): + result = normalize_params( + {'labelMap': {'B': 'bars', 'S': 'slate'}} + ) + self.assertEqual( + result, + {'labelMap': ['B:bars', 'S:slate']}, + ) + + def test_mixed(self): + result = normalize_params({ + 'prompt': 'describe this', + 'temperature': 0.7, + 'pretty': True, + 'labels': ['a', 'b'], + 'labelMap': {'X': 'y'}, + }) + self.assertEqual(result['prompt'], ['describe this']) + self.assertEqual(result['temperature'], ['0.7']) + self.assertEqual(result['pretty'], ['True']) + self.assertEqual(result['labels'], ['a', 'b']) + self.assertEqual(result['labelMap'], ['X:y']) + + def test_empty(self): + self.assertEqual(normalize_params({}), {}) + + +class TestEnvelopeDetection(unittest.TestCase): + + def test_is_envelope_true(self): + self.assertTrue(is_envelope({'parameters': {}, 'mmif': {}})) + + def test_is_envelope_false(self): + self.assertFalse(is_envelope({'metadata': {}, 'views': []})) + + def test_is_envelope_non_dict(self): + self.assertFalse(is_envelope('not a dict')) + + def test_unwrap_missing_mmif(self): + with self.assertRaises(ValueError) as ctx: + unwrap_envelope({'parameters': {}}) + self.assertIn('mmif', str(ctx.exception).lower()) + + def test_unwrap_non_dict_parameters(self): + with self.assertRaises(ValueError) as ctx: + unwrap_envelope({'parameters': 'bad', 'mmif': {}}) + self.assertIn('object', str(ctx.exception).lower()) + + +class TestEnvelopeCreation(unittest.TestCase): + + def setUp(self): + self.mmif_str = ExampleInputMMIF.get_mmif() + self.mmif_obj = Mmif(self.mmif_str) + + def test_from_string(self): + result = json.loads( + create_envelope(self.mmif_str, {'pretty': True}) + ) + self.assertIn('parameters', result) + self.assertIn('mmif', result) + self.assertEqual(result['parameters']['pretty'], True) + + def test_from_mmif_object(self): + result = json.loads( + create_envelope(self.mmif_obj, {'pretty': True}) + ) + self.assertIn('parameters', result) + self.assertIn('mmif', result) + + def test_no_params(self): + result = json.loads(create_envelope(self.mmif_str)) + self.assertEqual(result['parameters'], {}) + self.assertIn('mmif', result) + + def test_roundtrip(self): + params = {'prompt': 'describe', 'labels': ['a', 'b']} + envelope_str = create_envelope(self.mmif_str, params) + body = json.loads(envelope_str) + self.assertTrue(is_envelope(body)) + mmif_str, normalized = unwrap_envelope(body) + # MMIF should be valid + Mmif(mmif_str) + self.assertEqual(normalized['prompt'], ['describe']) + self.assertEqual(normalized['labels'], ['a', 'b']) + + +class TestRestifierEnvelope(unittest.TestCase): + + def setUp(self): + self.client = clams.Restifier(EnvelopeTestApp()).test_client() + self.mmif_str = ExampleInputMMIF.get_mmif() + + def test_post_envelope(self): + envelope_str = create_envelope( + self.mmif_str, {'pretty': True} + ) + res = self.client.post('/', data=envelope_str) + self.assertEqual(res.status_code, 200) + Mmif(res.get_data(as_text=True)) + + def test_put_envelope(self): + envelope_str = create_envelope( + self.mmif_str, {'pretty': True} + ) + res = self.client.put('/', data=envelope_str) + self.assertEqual(res.status_code, 200) + Mmif(res.get_data(as_text=True)) + + def test_query_string_overrides_envelope(self): + envelope_str = create_envelope( + self.mmif_str, {'pretty': False} + ) + # query string says pretty=true, should override envelope + res = self.client.post( + '/', data=envelope_str, + query_string={'pretty': 'true'}, + ) + self.assertEqual(res.status_code, 200) + # indented JSON indicates pretty=true was honored + output = res.get_data(as_text=True) + self.assertIn('\n', output) + + def test_envelope_missing_mmif(self): + bad = json.dumps({'parameters': {'pretty': True}}) + res = self.client.post('/', data=bad) + self.assertEqual(res.status_code, 500) + self.assertEqual(res.mimetype, 'text/plain') + + def test_envelope_invalid_mmif(self): + bad = json.dumps({ + 'parameters': {}, + 'mmif': {'not': 'valid mmif'}, + }) + res = self.client.post('/', data=bad) + self.assertEqual(res.status_code, 500) + self.assertEqual(res.mimetype, 'text/plain') + + def test_raw_mmif_still_works(self): + res = self.client.post('/', data=self.mmif_str) + self.assertEqual(res.status_code, 200) + Mmif(res.get_data(as_text=True)) + + def test_invalid_json(self): + res = self.client.post('/', data='this is not json') + self.assertEqual(res.status_code, 500) + self.assertEqual(res.mimetype, 'text/plain') + + +class TestEnvelopeReproducibility(unittest.TestCase): + """ + End-to-end tests verifying the issue's key claim: envelope parameters + are recorded in view metadata via ``sign_view``, providing + transparent and reproducible app configuration. + """ + + def setUp(self): + self.client = clams.Restifier(EnvelopeTestApp()).test_client() + self.mmif_str = ExampleInputMMIF.get_mmif() + + def _get_view_params(self, response_text): + out = Mmif(response_text) + # signing view is the last view added by EnvelopeTestApp + signed = list(out.views)[-1] + return json.loads(signed.metadata.serialize()).get( + 'parameters', {}) + + def test_envelope_param_recorded_in_view_metadata(self): + envelope_str = create_envelope( + self.mmif_str, + {'prompt': 'describe this scene'}, + ) + res = self.client.post('/', data=envelope_str) + self.assertEqual(res.status_code, 200) + params = self._get_view_params(res.get_data(as_text=True)) + self.assertEqual(params.get('prompt'), 'describe this scene') + + def test_long_prompt_roundtrip(self): + # The original motivation: prompts of any length should pass + # through transparently. URL length limits don't apply since + # the envelope rides in the POST body. + long_prompt = 'word ' * 500 + envelope_str = create_envelope( + self.mmif_str, {'prompt': long_prompt}, + ) + res = self.client.post('/', data=envelope_str) + self.assertEqual(res.status_code, 200) + params = self._get_view_params(res.get_data(as_text=True)) + self.assertEqual(params.get('prompt'), long_prompt) + + def test_query_string_overrides_in_view_metadata(self): + envelope_str = create_envelope( + self.mmif_str, {'prompt': 'envelope value'}, + ) + res = self.client.post( + '/', data=envelope_str, + query_string={'prompt': 'query value'}, + ) + self.assertEqual(res.status_code, 200) + params = self._get_view_params(res.get_data(as_text=True)) + self.assertEqual(params.get('prompt'), 'query value') + + +class TestEnvelopeCLI(unittest.TestCase): + + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + tmp = Path(self.tmpdir.name) + self.params_path = tmp / 'params.json' + self.mmif_path = tmp / 'input.mmif' + self.params_path.write_text(json.dumps( + {'prompt': 'hello', 'temperature': 0.5} + )) + self.mmif_path.write_text(ExampleInputMMIF.get_mmif()) + + def tearDown(self): + self.tmpdir.cleanup() + + def _run_cli(self, argv): + parser = prep_argparser() + args = parser.parse_args(argv) + buf = io.StringIO() + with redirect_stdout(buf): + envelope_cli_main(args) + return buf.getvalue() + + def test_cli_with_files(self): + output = self._run_cli([ + str(self.params_path), str(self.mmif_path) + ]) + body = json.loads(output) + self.assertTrue(is_envelope(body)) + self.assertEqual(body['parameters']['prompt'], 'hello') + self.assertEqual(body['parameters']['temperature'], 0.5) + # extracted MMIF should still be valid + mmif_str, _ = unwrap_envelope(body) + Mmif(mmif_str) + + def test_cli_envelope_can_be_consumed_by_restifier(self): + # The envelope produced by the CLI should be directly POSTable. + output = self._run_cli([ + str(self.params_path), str(self.mmif_path) + ]) + client = clams.Restifier(EnvelopeTestApp()).test_client() + res = client.post('/', data=output) + self.assertEqual(res.status_code, 200) + Mmif(res.get_data(as_text=True)) + + +class TestEnvelopePythonAPI(unittest.TestCase): + + def test_create_envelope_at_package_root(self): + # `from clams import create_envelope` exposes the fn at root. + self.assertTrue(callable(clams.create_envelope)) + result = json.loads( + clams.create_envelope( + ExampleInputMMIF.get_mmif(), {'pretty': True} + ) + ) + self.assertTrue(is_envelope(result)) + self.assertEqual(result['parameters']['pretty'], True) + + +if __name__ == '__main__': + unittest.main()