portage_testables: Let test Packages define arbitrary variables

Allow users of the Package class to specify arbitrary keys and string
values as **kwargs that will be written out to the ebuild on disk in
the synthetic test overlay. The intended use for this is to be able to
specify CROS_WORKON_* style variables to exercise those code paths in
tests.

BUG=chromium:1139412, chromium:1071391, chromium:1078251
TEST=`run_pytest`

Change-Id: I58d10290ab8628ea22973d5a6ddad38cb8a062e3
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/chromite/+/2481001
Tested-by: Chris McDonald <cjmcdonald@chromium.org>
Reviewed-by: Michael Mortensen <mmortensen@google.com>
Commit-Queue: Chris McDonald <cjmcdonald@chromium.org>
diff --git a/test/portage_testables.py b/test/portage_testables.py
index a2c0a5b..7bd1789 100644
--- a/test/portage_testables.py
+++ b/test/portage_testables.py
@@ -11,6 +11,8 @@
 import itertools
 import os
 import pathlib  # pylint: disable=import-error
+import shlex
+from typing import Dict, Iterable, Tuple, Union
 
 from chromite.lib import constants
 from chromite.lib import cros_build_lib
@@ -35,8 +37,8 @@
 def _dict_to_ebuild(dictionary):
   """Helper to format a dictionary into an ebuild file."""
   output = []
-  for key in sorted(dictionary.keys()):
-    output.append('%s="%s"' % (key, dictionary[key]))
+  for key in dictionary.keys():
+    output.append(f'{key}={shlex.quote(dictionary[key])}')
 
   output.append('\n')
   return '\n'.join(output)
@@ -58,11 +60,16 @@
 
     self._write_layout_conf()
 
-  def __contains__(self, item: package_info.CPV):
-    if not isinstance(item, package_info.CPV):
+  def __contains__(self, item: Union[package_info.CPV,
+                                     package_info.PackageInfo]):
+    if not isinstance(item, (package_info.CPV, package_info.PackageInfo)):
       raise TypeError(f'Expected a CPV but received a {type(item)}')
 
-    ebuild_path = self.path / item.category / item.package / f'{item.pv}.ebuild'
+    if isinstance(item, package_info.CPV):
+      ebuild_path = (
+          self.path / item.category / item.package / f'{item.pv}.ebuild')
+    else:
+      ebuild_path = self.path / item.relative_path
 
     return ebuild_path.is_file()
 
@@ -71,7 +78,7 @@
     layout_conf_path = self.path / 'metadata' / 'layout.conf'
     master_names = ' '.join(m.name for m in self.masters or [])
     conf = {
-        'masters': master_names,
+        'masters': 'portage-stable chromiumos eclass-overlay' + master_names,
         'profile-formats': 'portage-2 profile-default-eapi',
         'profile_eapi_when_unspecified': '5-progress',
         'repo-name': str(self.name),
@@ -97,7 +104,7 @@
           mode='a',
           makedirs=True)
 
-  def _write_ebuild(self, pkg):
+  def _write_ebuild(self, pkg: 'Package'):
     """Write a Package object out to an ebuild file in this Overlay."""
     ebuild_path = self.path / pkg.category / pkg.package / (
         pkg.package + '-' + pkg.version + '.ebuild')
@@ -112,6 +119,14 @@
 
     osutils.WriteFile(ebuild_path, _dict_to_ebuild(base_conf), makedirs=True)
 
+    # Write additional miscellaneous variables declared in the Package object.
+    for k, v in pkg.variables.items():
+      osutils.WriteFile(ebuild_path, f'{k}="{v}"\n', mode='a')
+
+    # Write an eclass inheritance line, if needed.
+    if pkg.format_eclass_line():
+      osutils.WriteFile(ebuild_path, pkg.format_eclass_line(), mode='a')
+
     extra_conf = {
         'DEPEND': pkg.depend,
         'RDEPEND': pkg.rdepend,
@@ -235,8 +250,7 @@
     extra_env.update(kwargs.pop('extra_env', {}))
     kwargs.setdefault('encoding', 'utf-8')
 
-    return cros_build_lib.run(
-        cmd, extra_env=extra_env, **kwargs)
+    return cros_build_lib.run(cmd, extra_env=extra_env, **kwargs)
 
 
 class Profile(object):
@@ -253,6 +267,9 @@
 class Package(object):
   """Portage package, lives in an overlay."""
 
+  inherit: Tuple[str]
+  variables: Dict[str, str]
+
   def __init__(self,
                category,
                package,
@@ -261,7 +278,9 @@
                keywords='*',
                slot='0',
                depend='',
-               rdepend=''):
+               rdepend='',
+               inherit: Union[Iterable[str], str] = tuple(),
+               **kwargs):
     self.category = category
     self.package = package
     self.version = version
@@ -270,6 +289,8 @@
     self.slot = slot
     self.depend = depend
     self.rdepend = rdepend
+    self.inherit = (inherit,) if isinstance(inherit, str) else tuple(inherit)
+    self.variables = kwargs
 
   @classmethod
   def from_cpv(cls, pkg_str: str):
@@ -282,3 +303,12 @@
     """Returns a CPV object constructed from this package's metadata."""
     return package_info.SplitCPV(self.category + '/' + self.package + '-' +
                                  self.version)
+
+  def format_eclass_line(self) -> str:
+    """Returns a string containing this package's eclass inheritance line."""
+    if self.inherit and isinstance(self.inherit, str):
+      return f'inherit {self.inherit}\n'
+    elif self.inherit:
+      return f'inherit {" ".join(self.inherit)}\n'
+    else:
+      return ''