From e310ba2cfbd1909a378937b6bda9d90dcee02a25 Mon Sep 17 00:00:00 2001 From: Keigh Rim Date: Sun, 1 Nov 2026 09:54:57 -0500 Subject: [PATCH 1/3] added `required_env` to app metadata and documented env var passing --- clams/appmetadata/__init__.py | 48 +++++++++++++++++++++++++++++++++++ documentation/appmetadata.rst | 22 ++++++++++++++++ documentation/clamsapp.rst | 47 ++++++++++++++++++++++++++++++++++ tests/test_clamsapp.py | 11 ++++++++ 4 files changed, 128 insertions(+) diff --git a/clams/appmetadata/__init__.py b/clams/appmetadata/__init__.py index 588a867..3c96e5c 100644 --- a/clams/appmetadata/__init__.py +++ b/clams/appmetadata/__init__.py @@ -268,6 +268,29 @@ def __init__(self, **kwargs): } +class RequiredEnv(_BaseModel): + """ + Defines a data model that describes an environment variable that a CLAMS + app cannot run without, such as an access token for a gated model. Only + the name of the variable is part of the app metadata, never its value. + """ + name: str = pydantic.Field( + ..., + pattern=r'^[A-Za-z_][A-Za-z0-9_]*$', + description="Name of the environment variable." + ) + description: str = pydantic.Field( + ..., + description="Description of what the variable is for and how to " + "obtain its value." + ) + + model_config = { + 'title': 'CLAMS App Required Environment Variable', + 'extra': 'forbid', + } + + class AppMetadata(pydantic.BaseModel): """ Data model that describes a CLAMS app. @@ -379,6 +402,14 @@ class AppMetadata(pydantic.BaseModel): "The values declared here are propagated by the SDK into the ``appTags`` field of every view " "the app signs." ) + required_env: List[RequiredEnv] = pydantic.Field( + [], + description="(optional) List of environment variables that the app " + "cannot run without. Being listed here means the variable " + "is required, so optional settings are left out. Values " + "of these variables are never part of app metadata or " + "output MMIF." + ) est_gpu_mem_min: int = pydantic.Field( 0, description="(optional) Minimum GPU memory required to run the app, in megabytes (MB). " @@ -567,6 +598,23 @@ def add_more(self, key: str, value: str): raise ValueError(f"'{key}' is already being used in the appmetadata!") else: raise ValueError("Key and value should not be empty!") + + def add_required_env(self, name: str, description: str) -> RequiredEnv: + """ + Helper method to declare an environment variable that the app cannot + run without. + + :param name: name of the environment variable + :param description: what the variable is for and how to get its value + :returns: the newly added + :class:`~clams.appmetadata.RequiredEnv` object + :raises ValueError: if the name is invalid or is already declared + """ + if any(env.name == name for env in self.required_env): + raise ValueError(f"'{name}' is already declared as required!") + new_env = RequiredEnv(name=name, description=description) + self.required_env.append(new_env) + return new_env def jsonify(self, pretty=False): """ diff --git a/documentation/appmetadata.rst b/documentation/appmetadata.rst index d0ba20a..1364d9f 100644 --- a/documentation/appmetadata.rst +++ b/documentation/appmetadata.rst @@ -222,6 +222,28 @@ section). Syntax for Parameter Specification in App Metadata -------------------------------------------------- +Required Environment Variables +============================== + +Some apps cannot run without a value that comes from the machine they run on. +Examples are an access token for a gated model on the Hugging Face Hub, which must stay out of the output MMIF, and the address of a model server (such as Ollama or vLLM) that the app sends requests to. +Declare these with :meth:`~clams.appmetadata.AppMetadata.add_required_env`, and read the value from the environment in the app code. + +.. code-block:: python + + metadata.add_required_env( + name='HF_TOKEN', + description='token with access to pyannote/speaker-diarization-3.1') + metadata.add_required_env( + name='OLLAMA_HOST', + description='URL of the Ollama server that runs the model') + +Being listed in ``required_env`` means the variable is required. +Do not list optional settings, variables that have a usable default, or common system variables such as ``PYTHONPATH``. + +Do not take a secret as a runtime parameter: the SDK records runtime parameters in the ``metadata`` of every view the app signs, so the value ends up in the output MMIF. +See :ref:`clamsapp-envvars` for how users pass these variables to an app. + Metadata Schema =============== diff --git a/documentation/clamsapp.rst b/documentation/clamsapp.rst index 76c6c1f..08cf855 100644 --- a/documentation/clamsapp.rst +++ b/documentation/clamsapp.rst @@ -124,6 +124,53 @@ Unlike the data directory, the cache directory is not required to be mounted, bu This can include sensitive information such as browser cache, authentication tokens, etc, hence will pose a great security risk. It is recommended to create a separate directory to use as a cache directory for CLAMS containers. +.. _clamsapp-envvars: + +Passing environment variables +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Some apps need environment variables to run, for example an access token for a gated model, or the address of a model server such as Ollama or vLLM. +These apps list the variables in the ``required_env`` field of their app metadata. +If one of them is unset or empty, the app stops at startup with an error that names the missing variables. +To provide them, write a file with one ``NAME=value`` per line, usually named ``.env``. + +.. code-block:: bash + + HF_TOKEN= + OLLAMA_HOST=http://host.docker.internal:11434 + +Then pass the file with ``--env-file`` when you start the container. + +.. code-block:: bash + + $ docker run --env-file .env -v /path/to/data/directory:/data -p :5000 + +To set a single variable, use ``-e NAME=value`` in place of ``--env-file``. + +.. note:: + + Inside a container, ``localhost`` is the container itself. + To reach a model server that runs on the host machine, use ``host.docker.internal`` as the host name. + On Linux, Docker also needs ``--add-host=host.docker.internal:host-gateway`` in the ``docker run`` command, and Podman uses ``host.containers.internal`` in its place. + +When you run the app without a container, load the file into your shell first. +``set -a`` is needed because ``source`` alone does not export the variables to the app. + +.. code-block:: bash + + $ set -a; source .env; set +a + $ python app.py + +.. note:: + + Docker and bash read this file differently, so use the plain ``NAME=value`` form to make one file work for both. + Do not add an ``export`` prefix, quotes around the value, or a comment after the value. + A comment on its own line is fine. + +.. warning:: + + Keep the ``.env`` file out of version control. + .. _clamsapp-invoking: Invoking the app server diff --git a/tests/test_clamsapp.py b/tests/test_clamsapp.py index 829fe23..c96bfd3 100644 --- a/tests/test_clamsapp.py +++ b/tests/test_clamsapp.py @@ -311,6 +311,17 @@ def test_app_tags_propagated_to_view(self): ['TemporalSegmentation', 'BarsDetection'], ) + def test_add_required_env(self): + env = self.app.metadata.add_required_env('HF_TOKEN', 'hub token') + self.assertEqual('HF_TOKEN', env.name) + dumped = json.loads(self.app.appmetadata()) + self.assertEqual([{'name': 'HF_TOKEN', 'description': 'hub token'}], + dumped['required_env']) + with self.assertRaises(ValueError): + self.app.metadata.add_required_env('HF_TOKEN', 'duplicate') + with self.assertRaises(ValueError): + self.app.metadata.add_required_env('NOT A NAME', 'invalid') + def test_add_app_tag_dedupes_and_validates(self): self.app.metadata.add_app_tag('foo', 'bar', 'foo') self.assertEqual(self.app.metadata.app_tags, ['foo', 'bar']) From 268051b7a011e030204ace31b4c1ae7f3cece1c3 Mon Sep 17 00:00:00 2001 From: Keigh Rim Date: Wed, 4 Nov 2026 20:27:31 -0500 Subject: [PATCH 2/3] added check for missing required envvar --- clams/app/__init__.py | 25 ++++++++++ .../templates/app/.dockerignore.template | 2 +- documentation/appmetadata.rst | 41 ++++++++++++++-- tests/test_clamsapp.py | 48 +++++++++++++++++++ 4 files changed, 111 insertions(+), 5 deletions(-) diff --git a/clams/app/__init__.py b/clams/app/__init__.py index 3c8d2cc..6153df6 100644 --- a/clams/app/__init__.py +++ b/clams/app/__init__.py @@ -141,6 +141,7 @@ class ClamsApp(ABC): def __init__(self): self.metadata: AppMetadata = self._load_appmetadata() + self._check_required_env() super().__init__() # data type spec to be used in type caster @@ -191,6 +192,30 @@ def _load_appmetadata(self) -> AppMetadata: metadata = self._appmetadata() return metadata + def _check_required_env(self) -> None: + """ + A private method to stop the app at startup when an environment + variable declared in ``required_env`` of the app metadata is unset or + empty. Only presence is checked, and the error message names the + missing variables. Values are never stored, logged, or included in the + message. + + :raises RuntimeError: if any required environment variable is unset + or empty + """ + # `os.environ.get` is used only for its truthiness, so no value is + # kept in a local variable (some traceback tools print locals) + missing = [env for env in self.metadata.required_env + if not os.environ.get(env.name)] + if missing: + listing = '\n'.join(f' {env.name}: {env.description}' + for env in missing) + raise RuntimeError( + f'{self.metadata.name} cannot start because these required ' + f'environment variables are unset or empty:\n{listing}\n' + 'See https://clams.ai/clams-python/clamsapp.html' + '#clamsapp-envvars for how to pass them.') + @abstractmethod def _appmetadata(self) -> AppMetadata: """ diff --git a/clams/develop/templates/app/.dockerignore.template b/clams/develop/templates/app/.dockerignore.template index 4bf612e..e883442 100644 --- a/clams/develop/templates/app/.dockerignore.template +++ b/clams/develop/templates/app/.dockerignore.template @@ -8,4 +8,4 @@ bin/ build/ *~ **/*.pyc -**/__pyche__ +**/__pycache__ diff --git a/documentation/appmetadata.rst b/documentation/appmetadata.rst index 1364d9f..1fee9c6 100644 --- a/documentation/appmetadata.rst +++ b/documentation/appmetadata.rst @@ -222,6 +222,41 @@ section). Syntax for Parameter Specification in App Metadata -------------------------------------------------- +The ``parameters`` field is a list of objects. Each object must include +``name``, ``description``, ``type``, and ``multivalued``. The ``type`` must be +one of ``integer``, ``number``, ``string``, ``boolean``, or ``map``. The +optional ``choices`` field lists accepted values, and ``default`` sets a +default value (making the parameter optional). Defaults should be JSON values +of the declared type; use a JSON array for a multivalued parameter. + +.. code-block:: json + + { + "parameters": [ + { + "name": "minFrameCount", + "description": "Minimum number of frames to process.", + "type": "integer", + "multivalued": false, + "default": 5, + "choices": [1, 5, 10] + }, + { + "name": "labels", + "description": "Labels to include in the output.", + "type": "string", + "multivalued": true, + "default": ["PERSON", "ORG"] + } + ] + } + +``boolean`` parameters always use ``"multivalued": false``; ``map`` parameters +always use ``"multivalued": true`` and represent each key-value pair as a +colon-separated string (for example, ``"default": ["key1:value1"]``). See +:ref:`runtime-params-detailed` for how parameter values are parsed and passed +to the app. + Required Environment Variables ============================== @@ -244,8 +279,8 @@ Do not list optional settings, variables that have a usable default, or common s Do not take a secret as a runtime parameter: the SDK records runtime parameters in the ``metadata`` of every view the app signs, so the value ends up in the output MMIF. See :ref:`clamsapp-envvars` for how users pass these variables to an app. -Metadata Schema -=============== +Full Metadata JSON Schema +========================= The schema for app metadata is as follows. (You can also download the schema in `JSON Schema `_ format from `here `_.) @@ -256,5 +291,3 @@ The schema for app metadata is as follows. :lift_definitions: True :auto_target: True :auto_reference: True - - diff --git a/tests/test_clamsapp.py b/tests/test_clamsapp.py index c96bfd3..184cada 100644 --- a/tests/test_clamsapp.py +++ b/tests/test_clamsapp.py @@ -83,6 +83,54 @@ def _annotate(self, mmif, **kwargs): return mmif +def requiring_env(*names): + """ + Patch :class:`ExampleClamsApp` so that its metadata declares ``names`` + as required environment variables. + """ + original = ExampleClamsApp._load_appmetadata + + def load(self): + metadata = original(self) + for name in names: + metadata.add_required_env(name, f'{name} for testing') + return metadata + return patch.object(ExampleClamsApp, '_load_appmetadata', load) + + +class TestRequiredEnv(unittest.TestCase): + + names = ('CLAMS_TEST_TOKEN_A', 'CLAMS_TEST_TOKEN_B') + secret = 'do-not-leak-1234' + + def setUp(self): + patcher = patch.dict(os.environ) + patcher.start() + self.addCleanup(patcher.stop) + for name in self.names: + os.environ.pop(name, None) + + def test_starts_when_all_set(self): + for name in self.names: + os.environ[name] = self.secret + with requiring_env(*self.names): + app = ExampleClamsApp() + self.assertNotIn(self.secret, app.appmetadata()) + + def test_fails_when_unset_or_empty(self): + os.environ['CLAMS_TEST_TOKEN_A'] = self.secret + os.environ['CLAMS_TEST_TOKEN_B'] = '' + with requiring_env(*self.names), self.assertRaises(RuntimeError) as e: + ExampleClamsApp() + message = str(e.exception) + self.assertIn('CLAMS_TEST_TOKEN_B', message) + self.assertNotIn('CLAMS_TEST_TOKEN_A', message) + self.assertNotIn(self.secret, message) + + def test_no_declaration_needs_no_env(self): + ExampleClamsApp() + + class TestClamsApp(unittest.TestCase): def setUp(self): From 262f123ac4da23358e5dc2abde65f2491be102f9 Mon Sep 17 00:00:00 2001 From: Keigh Rim Date: Wed, 4 Nov 2026 20:38:14 -0500 Subject: [PATCH 3/3] renamed env var requirements to `env_requirements` --- clams/app/__init__.py | 10 +++++----- clams/appmetadata/__init__.py | 17 +++++++++-------- documentation/appmetadata.rst | 8 ++++---- documentation/clamsapp.rst | 2 +- tests/test_clamsapp.py | 14 +++++++------- 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/clams/app/__init__.py b/clams/app/__init__.py index 6153df6..aaae181 100644 --- a/clams/app/__init__.py +++ b/clams/app/__init__.py @@ -141,7 +141,7 @@ class ClamsApp(ABC): def __init__(self): self.metadata: AppMetadata = self._load_appmetadata() - self._check_required_env() + self._check_env_requirements() super().__init__() # data type spec to be used in type caster @@ -192,11 +192,11 @@ def _load_appmetadata(self) -> AppMetadata: metadata = self._appmetadata() return metadata - def _check_required_env(self) -> None: + def _check_env_requirements(self) -> None: """ A private method to stop the app at startup when an environment - variable declared in ``required_env`` of the app metadata is unset or - empty. Only presence is checked, and the error message names the + variable declared in ``env_requirements`` of the app metadata is unset + or empty. Only presence is checked, and the error message names the missing variables. Values are never stored, logged, or included in the message. @@ -205,7 +205,7 @@ def _check_required_env(self) -> None: """ # `os.environ.get` is used only for its truthiness, so no value is # kept in a local variable (some traceback tools print locals) - missing = [env for env in self.metadata.required_env + missing = [env for env in self.metadata.env_requirements if not os.environ.get(env.name)] if missing: listing = '\n'.join(f' {env.name}: {env.description}' diff --git a/clams/appmetadata/__init__.py b/clams/appmetadata/__init__.py index 3c96e5c..3bce45f 100644 --- a/clams/appmetadata/__init__.py +++ b/clams/appmetadata/__init__.py @@ -268,7 +268,7 @@ def __init__(self, **kwargs): } -class RequiredEnv(_BaseModel): +class EnvRequirement(_BaseModel): """ Defines a data model that describes an environment variable that a CLAMS app cannot run without, such as an access token for a gated model. Only @@ -286,7 +286,7 @@ class RequiredEnv(_BaseModel): ) model_config = { - 'title': 'CLAMS App Required Environment Variable', + 'title': 'CLAMS App Environment Requirement', 'extra': 'forbid', } @@ -402,7 +402,7 @@ class AppMetadata(pydantic.BaseModel): "The values declared here are propagated by the SDK into the ``appTags`` field of every view " "the app signs." ) - required_env: List[RequiredEnv] = pydantic.Field( + env_requirements: List[EnvRequirement] = pydantic.Field( [], description="(optional) List of environment variables that the app " "cannot run without. Being listed here means the variable " @@ -599,7 +599,8 @@ def add_more(self, key: str, value: str): else: raise ValueError("Key and value should not be empty!") - def add_required_env(self, name: str, description: str) -> RequiredEnv: + def add_env_requirement(self, name: str, + description: str) -> EnvRequirement: """ Helper method to declare an environment variable that the app cannot run without. @@ -607,13 +608,13 @@ def add_required_env(self, name: str, description: str) -> RequiredEnv: :param name: name of the environment variable :param description: what the variable is for and how to get its value :returns: the newly added - :class:`~clams.appmetadata.RequiredEnv` object + :class:`~clams.appmetadata.EnvRequirement` object :raises ValueError: if the name is invalid or is already declared """ - if any(env.name == name for env in self.required_env): + if any(env.name == name for env in self.env_requirements): raise ValueError(f"'{name}' is already declared as required!") - new_env = RequiredEnv(name=name, description=description) - self.required_env.append(new_env) + new_env = EnvRequirement(name=name, description=description) + self.env_requirements.append(new_env) return new_env def jsonify(self, pretty=False): diff --git a/documentation/appmetadata.rst b/documentation/appmetadata.rst index 1fee9c6..d94afb3 100644 --- a/documentation/appmetadata.rst +++ b/documentation/appmetadata.rst @@ -262,18 +262,18 @@ Required Environment Variables Some apps cannot run without a value that comes from the machine they run on. Examples are an access token for a gated model on the Hugging Face Hub, which must stay out of the output MMIF, and the address of a model server (such as Ollama or vLLM) that the app sends requests to. -Declare these with :meth:`~clams.appmetadata.AppMetadata.add_required_env`, and read the value from the environment in the app code. +Declare these with :meth:`~clams.appmetadata.AppMetadata.add_env_requirement`, and read the value from the environment in the app code. .. code-block:: python - metadata.add_required_env( + metadata.add_env_requirement( name='HF_TOKEN', description='token with access to pyannote/speaker-diarization-3.1') - metadata.add_required_env( + metadata.add_env_requirement( name='OLLAMA_HOST', description='URL of the Ollama server that runs the model') -Being listed in ``required_env`` means the variable is required. +Being listed in ``env_requirements`` means the variable is required. Do not list optional settings, variables that have a usable default, or common system variables such as ``PYTHONPATH``. Do not take a secret as a runtime parameter: the SDK records runtime parameters in the ``metadata`` of every view the app signs, so the value ends up in the output MMIF. diff --git a/documentation/clamsapp.rst b/documentation/clamsapp.rst index 08cf855..ac4d946 100644 --- a/documentation/clamsapp.rst +++ b/documentation/clamsapp.rst @@ -130,7 +130,7 @@ Passing environment variables ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Some apps need environment variables to run, for example an access token for a gated model, or the address of a model server such as Ollama or vLLM. -These apps list the variables in the ``required_env`` field of their app metadata. +These apps list the variables in the ``env_requirements`` field of their app metadata. If one of them is unset or empty, the app stops at startup with an error that names the missing variables. To provide them, write a file with one ``NAME=value`` per line, usually named ``.env``. diff --git a/tests/test_clamsapp.py b/tests/test_clamsapp.py index 184cada..85cb750 100644 --- a/tests/test_clamsapp.py +++ b/tests/test_clamsapp.py @@ -93,12 +93,12 @@ def requiring_env(*names): def load(self): metadata = original(self) for name in names: - metadata.add_required_env(name, f'{name} for testing') + metadata.add_env_requirement(name, f'{name} for testing') return metadata return patch.object(ExampleClamsApp, '_load_appmetadata', load) -class TestRequiredEnv(unittest.TestCase): +class TestEnvRequirements(unittest.TestCase): names = ('CLAMS_TEST_TOKEN_A', 'CLAMS_TEST_TOKEN_B') secret = 'do-not-leak-1234' @@ -359,16 +359,16 @@ def test_app_tags_propagated_to_view(self): ['TemporalSegmentation', 'BarsDetection'], ) - def test_add_required_env(self): - env = self.app.metadata.add_required_env('HF_TOKEN', 'hub token') + def test_add_env_requirement(self): + env = self.app.metadata.add_env_requirement('HF_TOKEN', 'hub token') self.assertEqual('HF_TOKEN', env.name) dumped = json.loads(self.app.appmetadata()) self.assertEqual([{'name': 'HF_TOKEN', 'description': 'hub token'}], - dumped['required_env']) + dumped['env_requirements']) with self.assertRaises(ValueError): - self.app.metadata.add_required_env('HF_TOKEN', 'duplicate') + self.app.metadata.add_env_requirement('HF_TOKEN', 'duplicate') with self.assertRaises(ValueError): - self.app.metadata.add_required_env('NOT A NAME', 'invalid') + self.app.metadata.add_env_requirement('NOT A NAME', 'invalid') def test_add_app_tag_dedupes_and_validates(self): self.app.metadata.add_app_tag('foo', 'bar', 'foo')