Repository: ESSS/conda-devenv

Issue:
Support additional command line arguments
I often find myself specifying flags in the devenv file via environment variables, e.g. I'd have a section like:

```
# development
{% if "PKG_DEV" in os.environ %}
- pytest
- bump2version
{% endif %}
```

for development dependencies that are separate from those which a normal user of the environment needs.

That means that I have to set this environment variable for the dev setup, but I'd much prefer something like this:

    $ conda devenv -a dev

and a function like:

```
{% if "dev" in additional_args() %}
- ...
{% endif %}
```

I'd be open to work on a PR for this.

Discussion:
phausamann: Open questions:
- Should we revert `os.environ` at the end?
- Is there a reason to support env vars whose values contain equal signs?
prusse-martin: > * Should we revert `os.environ` at the end?

Maybe not. We can let those envs var "leak" into the `conda env update`  execution IMO.

> * Is there a reason to support env vars whose values contain equal signs?

Good point. Doing `split_arg = arg.split("=", 1)` will handle that.
prusse-martin: > Actually as mentioned, we just need some docs. 👍

He is probably speaking of the section [Command-line reference in `docs/usage.rst`](https://github.com/ESSS/conda-devenv/blob/master/docs/usage.rst#command-line-reference).

nicoddemus: > He is probably speaking of the section Command-line reference in docs/usage.rst.

True, but maybe also add a new section below "Checking minimum conda-devenv version" in the README.
phausamann: Added documentation for `get_env` and the command line option. I've put the new section above the minimum devenv section so that users don't confuse it with the "Environment variables" section.
nicoddemus: (We should squash/merge this btw)
nicoddemus: Thanks a lot @phausamann for the suggestion and very well written PR!
nicoddemus: Thanks everyone! I will prepare a new release tomorrow. 👍 
phausamann: Happy to help!

Patch:
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index de61cbf..9a2969e 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -64,7 +64,7 @@ Ready to contribute? Here's how to set up ``conda-devenv`` for local development
 
 3. Create a new conda environment for developing::
 
-    $ conda create -n devenv --file requirements_dev.txt
+    $ conda create -c conda-forge -n devenv --file requirements_dev.txt
     $ source activate devenv
     $ pre-commit install
     $ pip install -e .
@@ -101,6 +101,6 @@ Before you submit a pull request, check that it meets these guidelines:
 2. If the pull request adds functionality, the docs should be updated. Put
    your new functionality into a function with a docstring, and add the
    feature to the list in README.rst.
-3. The pull request should work for Python 2.7, 3.5 and 3.6. Check
-   https://travis-ci.org/ESSS/conda-devenv/pull_requests
+3. The pull request should work for Python 3.6, 3.7 and 3.8. Check
+   https://github.com/ESSS/conda-devenv/actions?query=event%3Apull_request
    and make sure that the tests pass for all supported Python versions.
diff --git a/conda_devenv/devenv.py b/conda_devenv/devenv.py
index 9320c53..621c0f8 100644
--- a/conda_devenv/devenv.py
+++ b/conda_devenv/devenv.py
@@ -40,6 +40,33 @@ def _min_conda_devenv_version(min_version):
     return ""
 
 
+def _get_env(var_name, default=None, valid=None):
+    """ Get env var value or default value and check against allowed values.
+
+    :param str var_name:
+        Name of the environment variable.
+    :param Optional[str] default:
+        Default value for the variable. If not specified, the method raises
+        an error when the variable is not set.
+    :param Optional[List[str]] valid:
+        List of allowed values of the variable.
+    :return: str
+        Value of the environment variable or default
+    """
+    value = os.environ.get(var_name, default)
+
+    if value is None:
+        raise ValueError(f"Environment variable {var_name} is not set.")
+
+    if valid is not None and value not in valid:
+        raise ValueError(
+            f"Allowed values for environment variable {var_name} are {valid}, "
+            f"got {value}"
+        )
+
+    return value
+
+
 def render_jinja(contents, filename, is_included):
     import jinja2
     import sys
@@ -69,6 +96,7 @@ def render_jinja(contents, filename, is_included):
         "win32": iswin and is32bit,
         "win64": iswin and is64bit,
         "min_conda_devenv_version": _min_conda_devenv_version,
+        "get_env": _get_env,
     }
 
     contents = preprocess_selectors(contents)
@@ -483,6 +511,25 @@ def get_env_directory(env_name):
     return None
 
 
+def parse_env_var_args(env_var_args):
+    """
+    :param List[str] env_var_args:
+        List of arguments in the form "VAR_NAME" or "VAR_NAME=VALUE"
+    :return: Dict[str,str]
+        Mapping from "VAR_NAME" to "VALUE" or empty str.
+    """
+    env_vars = {}
+    if env_var_args is not None:
+        for arg in env_var_args:
+            split_arg = arg.split("=", 1)
+            if len(split_arg) == 1:
+                env_vars[split_arg[0]] = ""
+            elif len(split_arg) == 2:
+                env_vars[split_arg[0]] = split_arg[1]
+
+    return env_vars
+
+
 def main(args=None):
     if args is None:
         args = sys.argv[1:]
@@ -516,6 +563,13 @@ def main(args=None):
     parser.add_argument(
         "--quiet", action="store_true", default=False, help="Do not show progress"
     )
+    parser.add_argument(
+        "--env-var",
+        "-e",
+        action="append",
+        help="Define or override environment variables in the form VAR_NAME or "
+        "VAR_NAME=VALUE.",
+    )
     parser.add_argument(
         "--verbose",
         "-v",
@@ -543,6 +597,9 @@ def main(args=None):
 
     is_devenv_input_file = filename.endswith(".devenv.yml")
     if is_devenv_input_file:
+        # update environment variables
+        os.environ.update(parse_env_var_args(args.env_var))
+
         # render conda-devenv file
         conda_yaml_dict, environment = load_yaml_dict(filename)
         rendered_contents = render_for_conda_env(conda_yaml_dict)
diff --git a/docs/usage.rst b/docs/usage.rst
index 4796e97..145705f 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -74,6 +74,39 @@ The following variables are available in the Jinja 2 namespace:
      - True if the current file is processed because it was included by another file.
 
 
+Using environment variables in the devenv file
+----------------------------------------------
+
+Environment variables that are defined when calling ``conda devenv`` can be read normally using ``os.environ``, but for convenience and better error messages, you can use the
+``get_env`` function:
+
+.. code-block:: yaml
+
+    dependencies:
+      - python ={{ get_env("PY") }}
+
+This will raise an error if ``PY`` is not defined. Alternatively, it is also possible to set a default value:
+
+.. code-block:: yaml
+
+    dependencies:
+      - python ={{ get_env("PY", default="3.6") }}
+
+You can also provide a list of allowed or supported values:
+
+.. code-block:: yaml
+
+    dependencies:
+      - python ={{ get_env("PY", valid=["3.6", "3.7"]) }}
+
+This will raise an error if ``PY`` is set to a different value.
+
+.. note::
+
+    Environment variables can also be set with the ``-e/--env-var`` command line option,
+    see the `Command-line reference`_ section.
+
+
 Checking minimum conda-devenv version
 -------------------------------------
 
@@ -199,21 +232,33 @@ Options
     $ conda devenv --help
 
     usage: conda-devenv [-h] [--file [FILE]] [--name [NAME]] [--print]
-                        [--no-prune] [--output-file [OUTPUT_FILE]] [--force]
+                        [--print-full] [--no-prune] [--output-file [OUTPUT_FILE]]
+                        [--quiet] [--env-var ENV_VAR] [--verbose] [--version]
 
     Work with multiple conda-environment-like yaml files in dev mode.
 
     optional arguments:
       -h, --help            show this help message and exit
       --file [FILE], -f [FILE]
-                            The environment.devenv.yml file to process.
+                            The environment.devenv.yml file to process. The
+                            default value is 'environment.devenv.yml'.
       --name [NAME], -n [NAME]
                             Name of environment.
-      --print               Only prints the rendered file to stdout and exits.
+      --print               Prints the rendered file as will be sent to conda-env
+                            to stdout and exits.
+      --print-full          Similar to --print, but also includes the
+                            'environment' section.
       --no-prune            Don't pass --prune flag to conda-env.
       --output-file [OUTPUT_FILE]
                             Output filename.
-      --force               Overrides the output file, even if it already exists.
+      --quiet               Do not show progress
+      --env-var ENV_VAR, -e ENV_VAR
+                            Define or override environment variables in the form
+                            VAR_NAME or VAR_NAME=VALUE.
+      --verbose, -v         Use once for info, twice for debug, three times for
+                            trace.
+      --version             Show version and exit
+
 
 
 ``--file``
@@ -235,3 +280,9 @@ Don't pass the ``--prune`` flag when calling ``conda env update``
 ~~~~~~~~~~~~~~~~~
 
 Specifies the ``conda-env`` file which will be created.
+
+``--env-var``
+~~~~~~~~~~~~~~~~~
+
+Define or override environment variables in the form ``VAR_NAME`` or ``VAR_NAME=VALUE``.
+Can be used multiple times for different variables.


Test patch:
diff --git a/tests/test_jinja.py b/tests/test_jinja.py
index 4a36b19..e15ea99 100644
--- a/tests/test_jinja.py
+++ b/tests/test_jinja.py
@@ -283,6 +283,25 @@ def test_render_jinja_with_preprocessing_selectors(monkeypatch):
     assert actual_win == expected_win
 
 
+def test_jinja_get_env(monkeypatch):
+    template = "{{ get_env('PY', valid=['3.6']) }}"
+    template_with_default = "{{ get_env('PY', default='3.6') }}"
+
+    monkeypatch.setenv("PY", "3.6")
+    assert render_jinja(template, filename="", is_included=False) == "3.6"
+
+    monkeypatch.setenv("PY", "3.7")
+    with pytest.raises(ValueError):
+        render_jinja(template, filename="", is_included=False)
+
+    monkeypatch.delenv("PY")
+    with pytest.raises(ValueError):
+        render_jinja(template, filename="", is_included=False)
+
+    monkeypatch.delenv("PY", raising=False)
+    assert render_jinja(template_with_default, filename="", is_included=False) == "3.6"
+
+
 def test_jinja_invalid_template():
     with pytest.raises(jinja2.exceptions.TemplateSyntaxError):
         render_jinja(
diff --git a/tests/test_main.py b/tests/test_main.py
index ff5231f..9c87a4c 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -253,3 +253,57 @@ def test_verbose(mocker, tmp_path):
     assert devenv.main(devenv_cmdline_args) == 0
     assert devenv._call_conda.call_count == 1
     assert argv == expected_conda_cmdline_args
+
+
+def test_parse_env_var_args():
+    """
+    Test that env var args are parsed correctly.
+    """
+    assert devenv.parse_env_var_args(None) == {}
+    assert devenv.parse_env_var_args(["DEV", "PY=3.6"]) == {"DEV": "", "PY": "3.6"}
+
+
+@pytest.mark.usefixtures("patch_conda_calls")
+def test_env_var_cmdline_args(tmpdir):
+    """
+    Test env vars passed via -e/--env_var.
+    """
+    import os
+
+    filename = tmpdir.join("environment.devenv.yml")
+    filename.write(
+        textwrap.dedent(
+            """\
+        name: a
+        dependencies:
+          - python ={{ os.environ["PY"] }}
+    """
+        )
+    )
+    assert (
+        devenv.main(
+            ["--file", str(filename), "--quiet", "-e", "DEV", "--env-var", "PY=3.6"]
+        )
+        == 0
+    )
+    assert os.environ["DEV"] == ""
+    assert os.environ["PY"] == "3.6"
+
+
+@pytest.mark.usefixtures("patch_conda_calls")
+def test_get_env(tmpdir, monkeypatch):
+    """
+    Test get_env jinja function with required env var passed via command line.
+    """
+    filename = tmpdir.join("environment.devenv.yml")
+    filename.write(
+        textwrap.dedent(
+            """\
+        name: a
+        dependencies:
+          - python ={{ get_env("PY", valid=["3.6"]) }}
+    """
+        )
+    )
+    monkeypatch.delenv("PY", raising=False)
+    assert devenv.main(["--file", str(filename), "--quiet", "-e", "PY=3.6"]) == 0