Repository: valohai/valohai-cli

Issue:
Add deployment-version creation command

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/valohai/valohai-cli/pull/206?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai) Report
> Merging [#206](https://codecov.io/gh/valohai/valohai-cli/pull/206?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai) (8b7c052) into [master](https://codecov.io/gh/valohai/valohai-cli/commit/242273d91de0cc33395e5675318831f18469be45?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai) (242273d) will **decrease** coverage by `1.21%`.
> The diff coverage is `44.56%`.

```diff
@@            Coverage Diff             @@
##           master     #206      +/-   ##
==========================================
- Coverage   85.84%   84.63%   -1.22%     
==========================================
  Files         117      122       +5     
  Lines        3822     3904      +82     
  Branches      642      557      -85     
==========================================
+ Hits         3281     3304      +23     
- Misses        374      428      +54     
- Partials      167      172       +5     
```


| [Impacted Files](https://codecov.io/gh/valohai/valohai-cli/pull/206?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai) | Coverage Δ | |
|---|---|---|
| [...loyment/create\_version/dynamic\_creation\_command.py](https://codecov.io/gh/valohai/valohai-cli/pull/206/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai#diff-dmFsb2hhaV9jbGkvY29tbWFuZHMvZGVwbG95bWVudC9jcmVhdGVfdmVyc2lvbi9keW5hbWljX2NyZWF0aW9uX2NvbW1hbmQucHk=) | `31.11% <31.11%> (ø)` | |
| [...hai\_cli/commands/deployment/create\_version/excs.py](https://codecov.io/gh/valohai/valohai-cli/pull/206/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai#diff-dmFsb2hhaV9jbGkvY29tbWFuZHMvZGVwbG95bWVudC9jcmVhdGVfdmVyc2lvbi9leGNzLnB5) | `36.36% <36.36%> (ø)` | |
| [...mmands/deployment/create\_version/create\_version.py](https://codecov.io/gh/valohai/valohai-cli/pull/206/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai#diff-dmFsb2hhaV9jbGkvY29tbWFuZHMvZGVwbG95bWVudC9jcmVhdGVfdmVyc2lvbi9jcmVhdGVfdmVyc2lvbi5weQ==) | `53.57% <53.57%> (ø)` | |
| [valohai\_cli/commands/deployment/\_\_init\_\_.py](https://codecov.io/gh/valohai/valohai-cli/pull/206/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai#diff-dmFsb2hhaV9jbGkvY29tbWFuZHMvZGVwbG95bWVudC9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [...cli/commands/deployment/create\_version/\_\_init\_\_.py](https://codecov.io/gh/valohai/valohai-cli/pull/206/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai#diff-dmFsb2hhaV9jbGkvY29tbWFuZHMvZGVwbG95bWVudC9jcmVhdGVfdmVyc2lvbi9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [valohai\_cli/exceptions.py](https://codecov.io/gh/valohai/valohai-cli/pull/206/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai#diff-dmFsb2hhaV9jbGkvZXhjZXB0aW9ucy5weQ==) | `72.94% <100.00%> (ø)` | |
| [valohai\_cli/utils/file\_input.py](https://codecov.io/gh/valohai/valohai-cli/pull/206/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai#diff-dmFsb2hhaV9jbGkvdXRpbHMvZmlsZV9pbnB1dC5weQ==) | `69.23% <0.00%> (-7.70%)` | :arrow_down: |
| [valohai\_cli/adhoc.py](https://codecov.io/gh/valohai/valohai-cli/pull/206/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai#diff-dmFsb2hhaV9jbGkvYWRob2MucHk=) | `79.68% <0.00%> (-3.13%)` | :arrow_down: |
| [valohai\_cli/commands/execution/logs.py](https://codecov.io/gh/valohai/valohai-cli/pull/206/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai#diff-dmFsb2hhaV9jbGkvY29tbWFuZHMvZXhlY3V0aW9uL2xvZ3MucHk=) | `75.00% <0.00%> (-3.05%)` | :arrow_down: |
| [valohai\_cli/yaml\_wizard.py](https://codecov.io/gh/valohai/valohai-cli/pull/206/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai#diff-dmFsb2hhaV9jbGkveWFtbF93aXphcmQucHk=) | `84.90% <0.00%> (-2.60%)` | :arrow_down: |
| ... and [10 more](https://codecov.io/gh/valohai/valohai-cli/pull/206/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/valohai/valohai-cli/pull/206?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/valohai/valohai-cli/pull/206?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai). Last update [242273d...8b7c052](https://codecov.io/gh/valohai/valohai-cli/pull/206?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=valohai).


Patch:
diff --git a/valohai_cli/commands/deployment/__init__.py b/valohai_cli/commands/deployment/__init__.py
new file mode 100644
index 0000000..81ff41f
--- /dev/null
+++ b/valohai_cli/commands/deployment/__init__.py
@@ -0,0 +1,10 @@
+import click
+
+from valohai_cli.plugin_cli import PluginCLI
+
+
+@click.command(cls=PluginCLI, commands_module='valohai_cli.commands.deployment')
+def deployment() -> None:
+    """
+    Deployment-related commands.
+    """
diff --git a/valohai_cli/commands/deployment/create_version/__init__.py b/valohai_cli/commands/deployment/create_version/__init__.py
new file mode 100644
index 0000000..533fdd8
--- /dev/null
+++ b/valohai_cli/commands/deployment/create_version/__init__.py
@@ -0,0 +1,3 @@
+from .create_version import create_version
+
+__all__ = ['create_version']
diff --git a/valohai_cli/commands/deployment/create_version/create_version.py b/valohai_cli/commands/deployment/create_version/create_version.py
new file mode 100644
index 0000000..4681e8d
--- /dev/null
+++ b/valohai_cli/commands/deployment/create_version/create_version.py
@@ -0,0 +1,68 @@
+from typing import Any, List, Optional
+
+import click
+
+from valohai_cli.api import request
+from valohai_cli.commands.deployment.create_version.dynamic_creation_command import CreationCommand
+from valohai_cli.ctx import get_project
+from valohai_cli.messages import info
+from valohai_cli.utils import parse_environment_variable_strings
+from valohai_cli.utils.commits import create_or_resolve_commit
+from valohai_cli.utils.matching import match_from_list_with_error
+
+
+@click.command(
+    context_settings=dict(ignore_unknown_options=True),
+    add_help_option=False,
+)
+@click.option('--commit', '-c', default=None, metavar='SHA', help='The commit to use. Defaults to the current HEAD.')
+@click.option('--deployment', '-d', help='The name of the deployment to use.')
+@click.option('--var', '-v', 'environment_variables', multiple=True, help='Add environment variable (NAME=VALUE). May be repeated.')
+@click.option('--endpoints', '-e', multiple=True, help='Names of endpoints enabled in deployment version.')
+@click.option('--name', '-n', 'version_name', default=None, help='Name of the created version (defaults to a name based on the creation date).')
+@click.option('--inherit-env-vars/--no-inherit-env-vars', default=True, help='Use project environment variables in deployment.')
+@click.argument('args', nargs=-1, type=click.UNPROCESSED, metavar='ENDPOINT-OPTIONS...')
+@click.pass_context
+def create_version(
+    ctx: click.Context,
+    *,
+    args: List[str],
+    commit: Optional[str],
+    deployment: str,
+    environment_variables: List[str],
+    endpoints: List[str],
+    version_name: Optional[str],
+    inherit_env_vars: bool,
+) -> Any:
+    """
+    Create a new deployment version.
+    """
+    project = get_project(require=True)
+    project.refresh_details()
+    commit = create_or_resolve_commit(
+        project,
+        commit=commit,
+        adhoc=False,
+        yaml_path=None,
+    )
+    deployments = request('get', '/api/v0/deployments/', params={'project': project.id}).json()['results']
+    deployment_names = [d['name'] for d in deployments]
+    matched_deployment_name = match_from_list_with_error(deployment_names, deployment, "deployment")
+    matched_deployment = [d for d in deployments if d['name'] == matched_deployment_name][0]
+
+    if not version_name:
+        suggested_version_name: str = request('get', f'/api/v0/deployments/{matched_deployment["id"]}/suggest_version_name/').json()['name']
+        info(f"Using automatically generated name {suggested_version_name}...")
+
+    cc = CreationCommand(
+        project=project,
+        commit=commit,
+        environment_variables=parse_environment_variable_strings(environment_variables),
+        deployment=matched_deployment,
+        endpoint_names=endpoints,
+        version_name=version_name or suggested_version_name,
+        inherit_env_vars=inherit_env_vars,
+    )
+
+    with cc.make_context(cc.name, list(args), parent=ctx) as child_ctx:
+        return cc.invoke(child_ctx)
diff --git a/valohai_cli/commands/deployment/create_version/dynamic_creation_command.py b/valohai_cli/commands/deployment/create_version/dynamic_creation_command.py
new file mode 100644
index 0000000..d14d94d
--- /dev/null
+++ b/valohai_cli/commands/deployment/create_version/dynamic_creation_command.py
@@ -0,0 +1,120 @@
+from typing import Any, Dict, List, Optional
+from uuid import UUID
+
+import click
+import valohai_yaml
+from click.core import Option
+from valohai_yaml.objs import File
+
+from valohai_cli.api import request
+from valohai_cli.commands.execution.run.dynamic_run_command import generate_sanitized_options
+from valohai_cli.messages import error, success
+from valohai_cli.models.project import Project
+from valohai_cli.utils import humanize_identifier, parse_environment_variable_strings, sanitize_option_name
+
+DATUM_URI_PREFIX = 'datum://'
+
+
+class CreationCommand(click.Command):
+    """
+    A dynamically-generated subcommand that has Click options for files for deployments.
+    """
+    def __init__(
+        self,
+        project: Project,
+        deployment: Dict[str, Any],
+        endpoint_names: List[str],
+        commit: str,
+        version_name: str,
+        inherit_env_vars: bool = True,
+        environment_variables: Optional[Dict[str, str]] = None,
+    ):
+        self.project = project
+        self.deployment = deployment
+        self.endpoint_names = endpoint_names
+        self.commit = commit
+        self.version_name = version_name
+        self.environment_variables = dict(environment_variables or {})
+        self.inherit_env_vars = inherit_env_vars
+        super().__init__(
+            name=sanitize_option_name(deployment['name'].lower()),
+            callback=self.execute,
+            add_help_option=True,
+        )
+
+        commit_config = request(
+            method='get',
+            url=f'/api/v0/commits/{commit}/',
+            params={'include': 'config'},
+        ).json()
+        self.config = valohai_yaml.parse(commit_config['config'])
+
+        for endpoint in self.config.endpoints.values():
+            if endpoint.name in self.endpoint_names:
+                for file in endpoint.files:
+                    self.params.append(self.convert_file_to_option(endpoint_name=endpoint.name, file=file))
+
+    def convert_file_to_option(self, endpoint_name: str, file: File) -> Option:
+        """
+        Convert an endpoint file into a click Option.
+        """
+        name = f"{endpoint_name}.{file.name}"
+        option = click.Option(
+            param_decls=list(generate_sanitized_options(name)),
+            required=True,
+            metavar='URL',
+            help=f'File "{humanize_identifier(name)}"',
+        )
+        option.name = f"^{name}"
+        option.help_group = 'File Options'  # type: ignore[attr-defined]
+        return option
+
+    def execute(self, **kwargs: Any) -> None:
+        """
+        Execute the creation of the deployment version.
+
+        This is the Click callback for this command.
+
+        :param kwargs: Assorted kwargs (as passed in by Click).
+        :return: Naught
+        """
+        payload = {
+            'commit': self.commit,
+            'deployment': self.deployment['id'],
+            'environment_variables': parse_environment_variable_strings(self.environment_variables),
+            'inherit_environment_variables': self.inherit_env_vars,
+            'name': self.version_name,
+            'enabled': True,
+        }
+
+        endpoint_configurations: Dict[str, Any] = {}
+        endpoint_names_from_config = {e.name for e in self.config.endpoints.values()}
+        for name in self.endpoint_names:
+            if name not in endpoint_names_from_config:
+                raise ValueError(f"No endpoint named {name} present in commit configuration.")
+            endpoint_configurations[name] = {
+                'enabled': True,
+                'files': {},  # These will be filled in during the following loop
+                'replicas': 1,
+            }
+
+        for key, value in kwargs.items():
+            if key.startswith('^'):
+                designated_endpoint, filename = key[1:].split('.')
+                if designated_endpoint in endpoint_configurations:
+                    datum_id = value[len(DATUM_URI_PREFIX):] if DATUM_URI_PREFIX in value else value
+                    try:
+                        UUID(str(datum_id))
+                    except ValueError:
+                        error(f"Not valid datum id: {value}")
+                        return
+                    endpoint_configurations[designated_endpoint]['files'][filename] = datum_id
+
+        payload['endpoint_configurations'] = endpoint_configurations
+        resp = request(
+            method='post',
+            url='/api/v0/deployment-versions/',
+            json=payload,
+        ).json()
+
+        success(f"Deployment version {resp['name']} created. See {resp['urls']['display']}")


Test patch:
diff --git a/tests/commands/deployment/__init__.py b/tests/commands/deployment/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/commands/deployment/test_create_version.py b/tests/commands/deployment/test_create_version.py
new file mode 100644
index 0000000..a90c7a1
--- /dev/null
+++ b/tests/commands/deployment/test_create_version.py
@@ -0,0 +1,51 @@
+import uuid
+
+import pytest
+
+from tests.commands.run_test_utils import RunAPIMock
+from valohai_cli.commands.deployment.create_version import create_version
+from valohai_cli.ctx import get_project
+from valohai_cli.models.project import Project
+
+
+@pytest.mark.parametrize('name', (None, '666'))
+def test_create_version(runner, logged_in_and_linked, monkeypatch, name):
+    commit_identifier = 'f' * 16
+
+    def mock_resolve_commit(mock_self, *, commit_identifier):
+        return {'identifier': commit_identifier}
+
+    monkeypatch.setattr(Project, 'resolve_commit', mock_resolve_commit)
+
+    project = get_project()
+    apimock_kwargs = {'deployment_version_name': name} if name else {}
+    apimock = RunAPIMock(project.id, commit_identifier, **apimock_kwargs)
+    args = ['-d', 'main-deployment', '-e', 'greet', '-c', commit_identifier]
+    if name:
+        args.extend(['-n', name])
+
+    with apimock:
+        # No matching deployment?
+        output = runner.invoke(create_version, ['-d', 'not-found-deployment', '-e', 'greet', '-c', commit_identifier]).output
+        assert '"not-found-deployment" is not a known deployment (try one of main-deployment)' in output
+
+        output = runner.invoke(create_version, args).output
+        assert 'Success!' in output
+
+    # Endpoint with required files
+    endpoint = 'predict-digit'
+    args = ['-d', 'main-deployment', '-e', endpoint, '-c', commit_identifier]
+    if name:
+        args.extend(['-n', name])
+    with apimock:
+        output = runner.invoke(create_version, args).output
+        assert f'--{endpoint}-model' in output
+
+        args.extend([f'--{endpoint}-model', 'potat.h5'])
+        output = runner.invoke(create_version, args).output
+        assert "Not valid datum id: potat.h5" in output
+
+        datum_id = str(uuid.uuid4())
+        args.extend([f'--{endpoint}-model', datum_id])
+        output = runner.invoke(create_version, args).output
+        assert 'Success!' in output
diff --git a/tests/commands/run_test_utils.py b/tests/commands/run_test_utils.py
index 64a9252..ed8659c 100644
--- a/tests/commands/run_test_utils.py
+++ b/tests/commands/run_test_utils.py
@@ -8,6 +8,7 @@ from click.testing import CliRunner
 from tests.fixture_data import (
     CONFIG_DATA,
     CONFIG_YAML,
+    DEPLOYMENT_VERSION_DATA,
     EXECUTION_DATA,
     PIPELINE_DATA,
     PROJECT_DATA,
@@ -23,11 +24,20 @@ ALTERNATIVE_YAML = 'batch.yaml'
 
 class RunAPIMock(requests_mock.Mocker):
 
-    def __init__(self, project_id, commit_id='f' * 16, additional_payload_values=None):
+    def __init__(
+        self,
+        project_id,
+        commit_id='f' * 16,
+        deployment_id=666,
+        additional_payload_values=None,
+        deployment_version_name='220801.0',
+    ):
         super().__init__()
         self.last_create_execution_payload = None
         self.project_id = project_id
         self.commit_id = commit_id
+        self.deployment_id = deployment_id
+        self.deployment_version_name = deployment_version_name
         self.additional_payload_values = (additional_payload_values or {})
         self.get(
             f'https://app.valohai.com/api/v0/projects/{project_id}/',
@@ -41,14 +51,30 @@ class RunAPIMock(requests_mock.Mocker):
             re.compile(r'^https://app.valohai.com/api/v0/commits/(?P<id>.+)/(?:\?.*)?$'),
             json=self.handle_commit_detail,
         )
+        self.get(
+            f'https://app.valohai.com/api/v0/commits/{commit_id}?include=config',
+            json=self.handle_commit_detail,
+        )
         self.get(
             re.compile(r'^https://app.valohai.com/api/v0/commits/(?:\?.*)$'),
             json=self.handle_commits_list,
         )
+        self.get(
+            re.compile(r'^https://app.valohai.com/api/v0/deployments/(?:\?.*)$'),
+            json=self.handle_deployments_list,
+        )
+        self.get(
+            f'https://app.valohai.com/api/v0/deployments/{self.deployment_id}/suggest_version_name/',
+            json=self.handle_deployment_version_name_suggestion,
+        )
         self.post(
             'https://app.valohai.com/api/v0/executions/',
             json=self.handle_create_execution,
         )
+        self.post(
+            'https://app.valohai.com/api/v0/deployment-versions/',
+            json=self.handle_create_deployment_version,
+        )
         self.post(
             'https://app.valohai.com/api/v0/pipelines/',
             json=self.handle_create_pipeline,
@@ -65,6 +91,21 @@ class RunAPIMock(requests_mock.Mocker):
             'yaml_path': 'valohai.yaml',
         }
 
+    def handle_deployments_list(self, request, context):
+        return {
+            'results': [
+                {
+                    'name': 'main-deployment',
+                    'id': self.deployment_id,
+                }
+            ],
+        }
+
+    def handle_deployment_version_name_suggestion(self, request, context):
+        return {
+            'name': self.deployment_version_name,
+        }
+
     def handle_commits_list(self, request, context):
         return {
             'results': self.handle_commits(request, context),
@@ -95,6 +136,17 @@ class RunAPIMock(requests_mock.Mocker):
         self.last_create_execution_payload = body_json
         return EXECUTION_DATA.copy()
 
+    def handle_create_deployment_version(self, request, context):
+        body_json = json.loads(request.body.decode('utf-8'))
+        assert body_json['name'] == self.deployment_version_name
+        if body_json['endpoint_configurations'].get('greet'):
+            assert body_json['endpoint_configurations']['greet']['enabled']
+        if body_json['endpoint_configurations'].get('predict-digit'):
+            files = body_json['endpoint_configurations']['predict-digit']['files']
+            assert files.get('model')
+        context.status_code = 201
+        return DEPLOYMENT_VERSION_DATA.copy()
+
     def handle_create_pipeline(self, request, context):
         body_json = json.loads(request.body.decode('utf-8'))
         assert body_json['project'] == self.project_id
diff --git a/tests/fixture_data.py b/tests/fixture_data.py
index 4d912f2..8c79224 100644
--- a/tests/fixture_data.py
+++ b/tests/fixture_data.py
@@ -24,6 +24,10 @@ PROJECT_DATA = {
 execution_id = str(uuid.uuid4())
 datum_id = str(uuid.uuid4())
 alias_id = str(uuid.uuid4())
+commit_id = str(uuid.uuid4())
+project_id = str(uuid.uuid4())
+deployment_id = str(uuid.uuid4())
+deployment_version_id = str(uuid.uuid4())
 
 DATUM_DATA = {
     "id": datum_id,
@@ -59,6 +63,43 @@ OUTPUT_DATUM_DATA = {
     },
 }
 
+DEPLOYMENT_VERSION_DATA = {
+    "commit": {
+        "adhoc": False,
+        "commit_time": "2022-05-20T10:28:12Z",
+        "description": "",
+        "identifier": commit_id,
+        "project_id": project_id,
+        "ref": "master",
+        "ref_names": [
+            "master"
+        ],
+        "yaml_path": "valohai.yaml",
+        "urls": {
+            "display": f"https://app.valohai.com/p/magda/tensorflow-example/commits/{commit_id}/"
+        },
+        "has_config": True
+    },
+    "ctime": "2022-07-15T11:50:43.645158Z",
+    "mtime": "2022-07-15T11:50:43.645178Z",
+    "deployment": deployment_id,
+    "effective_enabled": True,
+    "enabled": True,
+    "id": deployment_version_id,
+    "name": "20220715.0",
+    "url": f"https://app.valohai.com/api/v0/deployment-versions/{deployment_version_id}/",
+    "endpoints": [],
+    "endpoint_urls": {},
+    "environment_variables": {},
+    "creator": {
+        "id": 1,
+        "username": "magda"
+    },
+    "urls": {
+        "display": f"https://app.valohai.com/p/magda/tensorflow-example/deployment/{deployment_id}/#/version/{deployment_version_id}"
+    }
+}
+
 EXECUTION_DATA = {
     'counter': random.randint(1, 100),
     'ctime': '2017-02-08T11:09:16.120102Z',
@@ -424,7 +465,20 @@ CONFIG_YAML = """
     environment-variables:
       - name: testenvvar
         default: 'test'
-
+- endpoint:
+    name: greet
+    image: python:3.9
+    port: 8000
+    server-command: python -m wsgiref.simple_server
+- endpoint:
+    name: predict-digit
+    description: predict digits from image inputs ("file" parameter)
+    image: tensorflow/tensorflow:2.6.0
+    wsgi: predict:predict
+    files:
+      - name: model
+        description: Model output file from TensorFlow
+        path: model.h5
 """
 
 INVALID_CONFIG_YAML = """