Repository: brettcannon/gidgethub (python)

Issue:
Add support for Actions environment files
https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/
https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-commands-for-github-actions#environment-files
https://github.com/actions/toolkit/blob/main/packages/core/src/file-command.ts

Pull request description:
Add support for Actions environment files
Closes #132 

TODO:
- [x] Update docs
- [x] Update changelog

Patch:
diff --git a/README.rst b/README.rst
index 071d6ff..564c5d8 100644
--- a/README.rst
+++ b/README.rst
@@ -1,6 +1,6 @@
 gidgethub
 =========
-An asynchronous `GitHub API <https://developer.github.com/>`_ library.
+An asynchronous `GitHub API <https://docs.github.com/>`_ library.
 
 
 Development status
@@ -32,7 +32,7 @@ Goals
 -----
 
 The key goal is to provide a base library for the
-`GitHub API <https://developer.github.com/>`_ which performs no I/O of its own (a
+`GitHub API <https://docs.github.com/>`_ which performs no I/O of its own (a
 `sans-I/O <https://sans-io.readthedocs.io/>`_ library). This allows users to
 choose whatever HTTP library they prefer while parceling out GitHub-specific
 details to this library. This base library is then built upon to provide an
@@ -45,7 +45,7 @@ Alternative libraries
 ---------------------
 
 If you think you want a different approach to the GitHub API,
-`GitHub maintains a list of libraries <https://developer.github.com/libraries/>`_.
+`GitHub maintains a list of libraries <https://developer.github.com/v3/libraries/>`_.
 
 
 *Aside*: what's with the name?
@@ -62,4 +62,3 @@ Changelog
 ---------
 
 See the documentation for the `full changelog <https://gidgethub.readthedocs.io/en/latest/changelog.html>`_.
-
diff --git a/docs/actions.rst b/docs/actions.rst
index 0e07e1e..de70b4c 100644
--- a/docs/actions.rst
+++ b/docs/actions.rst
@@ -34,4 +34,23 @@ This module is to help provide support for `GitHub Actions`_ when writing a
      gidgethub.actions.command("warning", "Missing semicolon", file="app.js", line="1", col="5")
 
 
+.. function:: setenv(name, value)
+
+    Creates or updates an environment variable for this action and all the subsequent actions
+    running in the job.
+
+    Note that no automatic string conversion is performed on any arguments.
+
+    .. versionadded:: 5.0.0
+
+
+.. function:: addpath(path)
+
+    Prepends the given *path* to the system PATH variable for this action and all the
+    subsequent actions running in the job. The *path* argument can be either :class:`str` or
+    :class:`os.PathLike`/:class:`pathlib.Path`.
+
+    .. versionadded:: 5.0.0
+
+
 .. _GitHub Actions: https://help.github.com/en/actions
diff --git a/docs/changelog.rst b/docs/changelog.rst
index ace9d15..d0dcc62 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -7,9 +7,12 @@ Changelog
 - Add :meth:`gidgethub.routing.Router.fetch` for obtaining a frozenset of functions
   registered to the router that the event would be called on.
   (`Issue #74 <https://github.com/brettcannon/gidgethub/issues/74>`_).
+- Add support for GitHub Actions Environment Files with :meth:`gidgethub.actions.setenv`
+  and :meth:`gidgethub.actions.addpath`.
+  (`Issue #137 <https://github.com/brettcannon/gidgethub/issues/132>`_).
 - Make router callback execution order non-deterministic to avoid relying on
-   registration order.
-   (`Issue #74 <https://github.com/brettcannon/gidgethub/issues/74>`_).
+  registration order.
+  (`Issue #74 <https://github.com/brettcannon/gidgethub/issues/74>`_).
 - Fix mypy errors in ``gidgethub.httpx.GitHubAPI._request``
   (`Issue #133 <https://github.com/brettcannon/gidgethub/issues/133>`_).
 
diff --git a/gidgethub/actions.py b/gidgethub/actions.py
index 76da838..ef28b63 100644
--- a/gidgethub/actions.py
+++ b/gidgethub/actions.py
@@ -3,8 +3,7 @@ import functools
 import json
 import os
 import pathlib
-from typing import Any
-import urllib.parse
+from typing import Any, Union
 
 
 @functools.lru_cache(maxsize=1)
@@ -43,3 +42,30 @@ def command(cmd: str, data: str = "", **parameters: str) -> None:
     )
     cmd_parts.append(f"::{data}")
     print("".join(cmd_parts))
+
+
+_DELIMITER = "__GIDGETHUB_DELIMITER__"
+
+
+def setenv(name: str, value: str) -> None:
+    """Create or update an environment variable.
+
+    The change applies to this action and future actions running in the job.
+    """
+    # https://github.com/actions/toolkit/blob/af821474235d3c5e1f49cee7c6cf636abb0874c4/packages/core/src/core.ts#L35-L53
+    os.environ[name] = value
+    # https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#multiline-strings
+    write_value = f"{name}<<{_DELIMITER}{os.linesep}{value}{os.linesep}{_DELIMITER}"
+    with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as file:
+        file.write(write_value + os.linesep)
+
+
+def addpath(path: Union[str, "os.PathLike[str]"]) -> None:
+    """Prepend to PATH.
+
+    This affects this action and all subsequent actions in the current job.
+    """
+    # https://github.com/actions/toolkit/blob/af821474235d3c5e1f49cee7c6cf636abb0874c4/packages/core/src/core.ts#L63-L75
+    os.environ["PATH"] = f"{path!s}{os.pathsep}{os.environ['PATH']}"
+    with open(os.environ["GITHUB_PATH"], "a", encoding="utf-8") as file:
+        file.write(os.fspath(path) + os.linesep)


Test patch:
diff --git a/tests/test_actions.py b/tests/test_actions.py
index 671198a..ecff0e5 100644
--- a/tests/test_actions.py
+++ b/tests/test_actions.py
@@ -4,6 +4,7 @@ import os
 import pytest
 
 from gidgethub import actions
+from gidgethub.actions import _DELIMITER
 
 
 @pytest.fixture
@@ -14,6 +15,22 @@ def tmp_webhook(tmp_path, monkeypatch):
     return tmp_file_path
 
 
+@pytest.fixture
+def tmp_envfile(tmp_path, monkeypatch):
+    """Create a temporary environment file."""
+    tmp_file_path = tmp_path / "setenv.txt"
+    monkeypatch.setenv("GITHUB_ENV", os.fspath(tmp_file_path))
+    return tmp_file_path
+
+
+@pytest.fixture
+def tmp_pathfile(tmpdir, tmp_path, monkeypatch):
+    """Create a temporary system path file and a temporary directory path."""
+    tmp_file_path = tmp_path / "addpath.txt"
+    monkeypatch.setenv("GITHUB_PATH", os.fspath(tmp_file_path))
+    return tmp_file_path, tmpdir
+
+
 class TestWorkspace:
 
     """Tests for gidgethub.actions.workspace()."""
@@ -61,18 +78,10 @@ class TestCommand:
         assert stdout
         return stdout.strip()
 
-    def test_set_env(self, capsys):
-        actions.command("set-env", "yellow", name="action_state")
-        assert self._stdout(capsys) == "::set-env name=action_state::yellow"
-
     def test_set_output(self, capsys):
         actions.command("set-output", "strawberry", name="action_fruit")
         assert self._stdout(capsys) == "::set-output name=action_fruit::strawberry"
 
-    def test_add_path(self, capsys):
-        actions.command("add-path", "/path/to/dir")
-        assert self._stdout(capsys) == "::add-path::/path/to/dir"
-
     def test_debug(self, capsys):
         actions.command(
             "debug", "Entered octocatAddition method", file="app.js", line="1"
@@ -111,3 +120,77 @@ class TestCommand:
     def test_resume_command(self, capsys):
         actions.command("pause-logging")
         assert self._stdout(capsys) == "::pause-logging::"
+
+
+class TestSetenv:
+
+    """Tests for gidgethub.actions.setenv()."""
+
+    def test_creating(self, tmp_envfile):
+        actions.setenv("HELLO", "WORLD")
+        data = tmp_envfile.read_text(encoding="utf-8")
+        assert os.environ["HELLO"] == "WORLD"
+        assert (
+            data == f"HELLO<<{_DELIMITER}{os.linesep}WORLD{os.linesep}"
+            f"{_DELIMITER}{os.linesep}"
+        )
+
+    def test_updating(self, tmp_envfile):
+        actions.setenv("CHANGED", "FALSE")
+        data = tmp_envfile.read_text(encoding="utf-8")
+        assert os.environ["CHANGED"] == "FALSE"
+        assert (
+            data == f"CHANGED<<{_DELIMITER}{os.linesep}FALSE{os.linesep}"
+            f"{_DELIMITER}{os.linesep}"
+        )
+        actions.setenv("CHANGED", "TRUE")
+        updated = tmp_envfile.read_text(encoding="utf-8")
+        assert os.environ["CHANGED"] == "TRUE"
+        # Rendering of the updated variable is done by GitHub.
+        assert updated.endswith(
+            f"CHANGED<<{_DELIMITER}{os.linesep}TRUE{os.linesep}"
+            f"{_DELIMITER}{os.linesep}"
+        )
+
+    def test_creating_multiline(self, tmp_envfile):
+        multiline = """This
+                        is
+                        a
+                        multiline
+                        string."""
+        actions.setenv("MULTILINE", multiline)
+        data = tmp_envfile.read_text(encoding="utf-8")
+        assert os.environ["MULTILINE"] == multiline
+        assert (
+            data == f"MULTILINE<<{_DELIMITER}{os.linesep}{multiline}"
+            f"{os.linesep}{_DELIMITER}{os.linesep}"
+        )
+
+
+class TestAddpath:
+
+    """Tests for gidgethub.actions.addpath()."""
+
+    def test_string_path(self, tmp_pathfile):
+        actions.addpath("/path/to/random/dir")
+        data = tmp_pathfile[0].read_text(encoding="utf-8")
+        assert f"/path/to/random/dir{os.pathsep}" in os.environ["PATH"]
+        assert data == f"/path/to/random/dir{os.linesep}"
+
+    def test_path_object(self, tmp_pathfile):
+        actions.addpath(tmp_pathfile[1])
+        data = tmp_pathfile[0].read_text(encoding="utf-8")
+        assert f"{tmp_pathfile[1]!s}{os.pathsep}" in os.environ["PATH"]
+        assert data == f"{tmp_pathfile[1]!s}{os.linesep}"
+
+    def test_multiple_paths(self, tmp_pathfile):
+        actions.addpath("/path/to/random/dir")
+        random_path = tmp_pathfile[1] / "random.txt"
+        actions.addpath(random_path)
+        data = tmp_pathfile[0].read_text(encoding="utf-8")
+        # Last path added comes first.
+        assert (
+            f"{random_path!s}{os.pathsep}/path/to/random/dir{os.pathsep}"
+            in os.environ["PATH"]
+        )
+        assert data == f"/path/to/random/dir{os.linesep}{random_path!s}{os.linesep}"