Skip to content

Commit 0d58687

Browse files
cpagravelpull[bot]
authored andcommitted
Chef - Add BUILD.gn and unit tests for stateful_shell.py (#19205)
1 parent 127caf5 commit 0d58687

File tree

6 files changed

+154
-11
lines changed

6 files changed

+154
-11
lines changed

BUILD.gn

+1
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ if (current_toolchain != "${dir_pw_toolchain}/default:default") {
166166
if (chip_link_tests) {
167167
deps = [
168168
"//:fake_platform_tests",
169+
"//examples/chef:chef.tests",
169170
"//scripts/build:build_examples.tests",
170171
"//scripts/idl:idl.tests",
171172
"//src:tests_run",

examples/chef/BUILD.gn

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Copyright (c) 2022 Project CHIP Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import("//build_overrides/build.gni")
16+
import("//build_overrides/chip.gni")
17+
18+
import("//build_overrides/pigweed.gni")
19+
import("$dir_pw_build/python.gni")
20+
21+
pw_python_package("chef") {
22+
setup = [ "setup.py" ]
23+
24+
sources = [
25+
"__init__.py",
26+
"chef.py",
27+
"constants.py",
28+
"stateful_shell.py",
29+
]
30+
31+
tests = [ "test_stateful_shell.py" ]
32+
}

examples/chef/__init__.py

Whitespace-only changes.

examples/chef/setup.py

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright (c) 2022 Project CHIP Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
"""The chef package."""
17+
18+
import setuptools # type: ignore
19+
20+
setuptools.setup(
21+
name='chef',
22+
version='0.0.1',
23+
author='Project CHIP Authors',
24+
description='Build custom sample apps for supported platforms',
25+
packages=setuptools.find_packages(),
26+
package_data={'chef': ['py.typed']},
27+
zip_safe=False,
28+
)

examples/chef/stateful_shell.py

+45-11
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,26 @@
1616
import shlex
1717
import subprocess
1818
import sys
19+
import time
1920
from typing import Dict, Optional
2021

2122
import constants
2223

2324
_ENV_FILENAME = ".shell_env"
2425
_OUTPUT_FILENAME = ".shell_output"
2526
_HERE = os.path.dirname(os.path.abspath(__file__))
27+
_TEE_WAIT_TIMEOUT = 3
2628

2729
TermColors = constants.TermColors
2830

2931

3032
class StatefulShell:
31-
"""A Shell that tracks state changes of the environment."""
33+
"""A Shell that tracks state changes of the environment.
34+
35+
Attributes:
36+
env: Env variables passed to command. It gets updated after every command.
37+
cwd: Current working directory of shell.
38+
"""
3239

3340
def __init__(self) -> None:
3441
if sys.platform == "linux" or sys.platform == "linux2":
@@ -44,8 +51,8 @@ def __init__(self) -> None:
4451

4552
# This file holds the env after running a command. This is a better approach
4653
# than writing to stdout because commands could redirect the stdout.
47-
self.envfile_path: str = os.path.join(_HERE, _ENV_FILENAME)
48-
self.cmd_output_path: str = os.path.join(_HERE, _OUTPUT_FILENAME)
54+
self._envfile_path: str = os.path.join(_HERE, _ENV_FILENAME)
55+
self._cmd_output_path: str = os.path.join(_HERE, _OUTPUT_FILENAME)
4956

5057
def print_env(self) -> None:
5158
"""Print environment variables in commandline friendly format for export.
@@ -87,13 +94,25 @@ def run_cmd(
8794
if return_cmd_output:
8895
# Piping won't work here because piping will affect how environment variables
8996
# are propagated. This solution uses tee without piping to preserve env variables.
90-
redirect = f" > >(tee \"{self.cmd_output_path}\") 2>&1 " # include stderr
97+
redirect = f" > >(tee \"{self._cmd_output_path}\") 2>&1 " # include stderr
98+
99+
# Delete the file before running the command so we can later check if the file
100+
# exists as a signal that tee has finished writing to the file.
101+
if os.path.isfile(self._cmd_output_path):
102+
os.remove(self._cmd_output_path)
91103
else:
92104
redirect = ""
93105

106+
# TODO: Use env -0 when `macos-latest` refers to macos-12 in github actions.
107+
# env -0 is ideal because it will support cases where an env variable that has newline
108+
# characters. The flag "-0" is requires MacOS 12 which is still in beta in Github Actions.
109+
# The less ideal `env` command is used by itself, with the caveat that newline chars
110+
# are unsupported in env variables.
111+
save_env_cmd = f"env > {self._envfile_path}"
112+
94113
command_with_state = (
95-
f"OLDPWD={self.env.get('OLDPWD', '')}; {cmd} {redirect}; RETCODE=$?;"
96-
f" env -0 > {self.envfile_path}; exit $RETCODE")
114+
f"OLDPWD={self.env.get('OLDPWD', '')}; {cmd} {redirect}; RETCODE=$?; "
115+
f"{save_env_cmd}; exit $RETCODE")
97116
with subprocess.Popen(
98117
[command_with_state],
99118
env=self.env, cwd=self.cwd,
@@ -102,9 +121,9 @@ def run_cmd(
102121
returncode = proc.wait()
103122

104123
# Load env state from envfile.
105-
with open(self.envfile_path, encoding="latin1") as f:
106-
# Split on null char because we use env -0.
107-
env_entries = f.read().split("\0")
124+
with open(self._envfile_path, encoding="latin1") as f:
125+
# TODO: Split on null char after updating to env -0 - requires MacOS 12.
126+
env_entries = f.read().split("\n")
108127
for entry in env_entries:
109128
parts = entry.split("=")
110129
# Handle case where an env variable contains text with '='.
@@ -119,6 +138,21 @@ def run_cmd(
119138
f"\nCmd: {cmd}")
120139

121140
if return_cmd_output:
122-
with open(self.cmd_output_path, encoding="latin1") as f:
123-
output = f.read()
141+
# Poll for file due to give 'tee' time to close.
142+
# This is necessary because 'tee' waits for all subshells to finish before writing.
143+
start_time = time.time()
144+
while time.time() - start_time < _TEE_WAIT_TIMEOUT:
145+
try:
146+
with open(self._cmd_output_path, encoding="latin1") as f:
147+
output = f.read()
148+
break
149+
except FileNotFoundError:
150+
pass
151+
time.sleep(0.1)
152+
else:
153+
raise TimeoutError(
154+
f"Error. Output file: {self._cmd_output_path} not created within "
155+
f"the alloted time of: {_TEE_WAIT_TIMEOUT}s"
156+
)
157+
124158
return output

examples/chef/test_stateful_shell.py

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Tests for stateful_shell.py
2+
3+
Usage:
4+
python -m unittest
5+
"""
6+
7+
import unittest
8+
9+
import stateful_shell
10+
11+
12+
class TestStatefulShell(unittest.TestCase):
13+
"""Testcases for stateful_shell.py."""
14+
15+
def setUp(self):
16+
"""Prepares stateful shell instance for tests."""
17+
self.shell = stateful_shell.StatefulShell()
18+
19+
def test_cmd_output(self):
20+
"""Tests shell command output."""
21+
resp = self.shell.run_cmd("echo test123", return_cmd_output=True).strip()
22+
self.assertEqual(resp, "test123")
23+
24+
def test_set_env_in_shell(self):
25+
"""Tests setting env variables in shell."""
26+
self.shell.run_cmd("export TESTVAR=123")
27+
self.assertEqual(self.shell.env["TESTVAR"], "123")
28+
29+
def test_set_env_outside_shell(self):
30+
"""Tests setting env variables outside shell call."""
31+
self.shell.env["TESTVAR"] = "1234"
32+
resp = self.shell.run_cmd("echo $TESTVAR", return_cmd_output=True).strip()
33+
self.assertEqual(resp, "1234")
34+
35+
def test_env_var_set_get(self):
36+
"""Tests setting and getting env vars between calls."""
37+
self.shell.run_cmd("export TESTVAR=123")
38+
resp = self.shell.run_cmd("echo $TESTVAR", return_cmd_output=True).strip()
39+
self.assertEqual(resp, "123")
40+
41+
def test_raise_on_returncode(self):
42+
"""Tests raising errors when returncode is nonzero."""
43+
with self.assertRaises(RuntimeError):
44+
self.shell.run_cmd("invalid_cmd > /dev/null 2>&1", raise_on_returncode=True)
45+
46+
47+
if __name__ == "__main__":
48+
unittest.main()

0 commit comments

Comments
 (0)