| # Copyright 2019 The LUCI Authors. All rights reserved. |
| # Use of this source code is governed under the Apache License, Version 2.0 |
| # that can be found in the LICENSE file. |
| |
| """Provides a 'fake' version of the RecipeDeps object. |
| |
| This is pretty heavyweight and should be used for integration-testing recipe |
| functionality. |
| |
| The objects here manipulate full 'recipe repos' on disk (complete with git |
| repositories and the ability to make commits in them). |
| |
| Access this via test_env.RecipeEngineUnitTest.FakeRecipeDeps(). |
| """ |
| |
| import contextlib |
| import errno |
| from io import StringIO |
| import json |
| import os |
| import shutil |
| import subprocess |
| import sys |
| import textwrap |
| |
| import attr |
| |
| from google.protobuf import json_format as jsonpb |
| |
| from PB.recipe_engine.recipes_cfg import RepoSpec |
| from recipe_engine import __path__ as RECIPE_ENGINE_PATH |
| from recipe_engine.internal.fetch import GitBackend, CommitMetadata |
| from recipe_engine.internal.simple_cfg import RECIPES_CFG_LOCATION_REL |
| from recipe_engine.internal.test.test_util import filesystem_safe |
| |
| ROOT_DIR = os.path.dirname(RECIPE_ENGINE_PATH[0]) |
| DEVNULL = open(os.devnull, 'w') |
| REAL_STDERR = sys.stderr # capture stderr before tests potentially mess with it |
| |
| |
| def _get_suite(buf, default, indent=' '): |
| """This is a helper function to extract a python code suite from a StringIO |
| object. |
| |
| This will dedent the buffer's content, split it into lines, and then re-indent |
| it by the amount indicated. |
| |
| Args: |
| * buf (StringIO) - The buffer to take the content from. |
| * default (str) - The content to use if `buf` is empty. |
| * indent (str) - The string to prepend to all de-indented lines. |
| |
| Returns the transformed string. |
| """ |
| value = textwrap.dedent(buf.getvalue() or default).strip('\n') |
| return '\n'.join(indent + l for l in value.splitlines()) |
| |
| |
| @attr.s |
| class FakeRecipeRepo: |
| """Manipulates a recipe repo on disk (as a git repository).""" |
| |
| # The FakeRecipeDeps which owns this repo. |
| fake_recipe_deps = attr.ib() # type: FakeRecipeDeps |
| |
| # The name of this repo. |
| name = attr.ib() # type: str |
| |
| # The absolute path on disk to the root of this repo. |
| path = attr.ib() |
| |
| # The GitBackend for this FakeRecipeRepo. |
| backend = attr.ib(default=attr.Factory( |
| lambda self: GitBackend(self.path, None), |
| takes_self=True)) |
| |
| @contextlib.contextmanager |
| def edit_recipes_cfg_pb2(self): |
| """Context manager for read/modify/write'ing the recipes.cfg file in this |
| repo. |
| |
| Usage: |
| |
| with repo.edit_recipes_cfg_pb2() as pb: |
| pb.deps['some_repo'].revision = 'abcdefg' |
| |
| Yields a recipes_cfg_pb2.RepoSpec object decoded from the current state of |
| the recipes.cfg file. Any modifications done to this object will be recorded |
| back to disk. |
| """ |
| spec = self.recipes_cfg_pb2 |
| yield spec |
| cfg_path = os.path.join(self.path, RECIPES_CFG_LOCATION_REL) |
| with open(cfg_path, 'w') as fil: |
| fil.write(jsonpb.MessageToJson(spec, preserving_proto_field_name=True)) |
| |
| @property |
| def recipes_cfg_pb2(self): |
| """Returns the current recipes_cfg_pb2.RepoSpec decoded from the recipes.cfg |
| file in this repo.""" |
| cfg_path = os.path.join(self.path, RECIPES_CFG_LOCATION_REL) |
| with open(cfg_path, 'r') as fil: |
| return jsonpb.Parse(fil.read(), RepoSpec()) |
| |
| @contextlib.contextmanager |
| def write_file(self, path): |
| """Context manager for writing a file inside the repo. |
| |
| Any missing directories will be automatically created. |
| |
| Usage: |
| |
| with repo.write_file('relative/path/filename.ext') as fil: |
| fil.write(''' |
| Content is de-indented. |
| So you don't have ugly tests. |
| ''') |
| |
| Args: |
| * path (str) - Path relative to the root of the repo of the file to write. |
| |
| Yields a StringIO object which you may write to. For convenience, the data |
| written to this StringIO object will be `textwrap.dedent`d to allow nice |
| inline strings in test files. |
| """ |
| full_path = os.path.join(self.path, path) |
| try: |
| os.makedirs(os.path.dirname(full_path)) |
| except OSError as ex: |
| if ex.errno != errno.EEXIST: |
| raise |
| |
| buf = StringIO() |
| yield buf |
| with open(full_path, 'w') as fil: |
| fil.write(textwrap.dedent(buf.getvalue())) |
| |
| def read_file(self, path): |
| """Reads a file inside the repo. |
| |
| Args: |
| * path (str) - Path relative to the root of the repo of the file to read. |
| |
| Returns the file contents as a str. If there is an error reading the file |
| this will return None. |
| """ |
| full_path = os.path.join(self.path, path) |
| try: |
| with open(full_path, 'r') as fil: |
| return fil.read() |
| except: # pylint: disable=bare-except |
| return None |
| |
| def exists(self, path): |
| """Checks to see if a path exists in the repo. |
| |
| Args: |
| * path (str) - Path relative to the root of the repo to check. |
| |
| Returns True iff the item exists. |
| """ |
| return os.path.exists(os.path.join(self.path, path)) |
| |
| def is_dir(self, path): |
| """Checks to see if a path is a directory in the repo. |
| |
| Args: |
| * path (str) - Path relative to the root of the repo to check. |
| |
| Returns True iff the item exists and is a directory. |
| """ |
| return os.path.isdir(os.path.join(self.path, path)) |
| |
| def is_file(self, path): |
| """Checks to see if a path is a file in the repo. |
| |
| Args: |
| * path (str) - Path relative to the root of the repo to check. |
| |
| Returns True iff the item exists and is a regular file. |
| """ |
| return os.path.isfile(os.path.join(self.path, path)) |
| |
| @attr.s(slots=True) |
| class WriteableRecipe: |
| """Yielded from the `write_recipe` method. Used to generate a recipe on |
| disk. |
| |
| This contains two string buffers which you may write to, RunSteps and |
| GenTests. These represent the body of the RunSteps and GenTests functions in |
| the recipe, respectively. Both buffers will be dedent'd as described in |
| `write_file`. If you do not write to them, the function bodies will be |
| generated with default values: |
| |
| RunSteps: pass |
| GenTests: yield api.test('basic') |
| |
| The RunSteps_args attribute is a list of argument names to be used in the |
| RunSteps function definition. It defaults to ['api'] and should generally |
| only be extended. |
| |
| The imports attribute is a list of 'import' lines you want at the top of the |
| recipe. These lines should be an entire valid python import line (i.e |
| starting with `import` or `from`). |
| |
| The DEPS attribute corresponds exactly to the recipe DEPS field, and you may |
| put a python list or dict here, depending on how you want your recipe's DEPS |
| to look. The DEPS comes, by default, as a list containing the |
| 'recipe_engine/step' module. This list may be appended to, or replaced |
| entirely (with assignment), if you choose. |
| |
| The PROPERTIES attribute is a Python expression to assign to the recipe |
| PROPERTIES field. |
| |
| The `expectation` dictionary can be populated with expectation JSON data to |
| write to disk. The key is the test name (e.g. "basic", "test_on_luci", etc.) |
| and the value is list of dicts (i.e. the expectation format). It defaults to |
| a single expectation for the test "basic" which has an empty (`None`) |
| final result (i.e. the RunSteps function returned None without running |
| steps). |
| """ |
| base_path = attr.ib() |
| |
| imports = attr.ib(factory=list) |
| RunSteps = attr.ib(factory=StringIO) |
| RunSteps_args = attr.ib(factory=lambda: ['api']) |
| GenTests = attr.ib(factory=StringIO) |
| DEPS = attr.ib(factory=lambda: ['recipe_engine/step']) |
| PROPERTIES = attr.ib(default='{}') |
| ENV_PROPERTIES = attr.ib(default='None') |
| expectation = attr.ib(factory=lambda: { |
| 'basic': [{'name': '$result'}], |
| }) |
| |
| @property |
| def path(self): |
| """Returns the repo-relative path to the recipe file.""" |
| return self.base_path + '.py' |
| |
| @property |
| def expect_path(self): |
| """Returns the repo-relative path to the recipe's expectation dir.""" |
| return self.base_path + '.expected' |
| |
| @contextlib.contextmanager |
| def write_recipe(self, name_or_module, name=None): |
| """Context manager for writing a recipe to the disk in this testing repo. |
| |
| Overwrites any existing recipe. This can be called like: |
| |
| write_recipe('recipe_name') |
| write_recipe('module_name', 'recipe_name') |
| |
| If you need to write a malformed recipe, or one with additional top-level |
| functions, please use `write_file` to directly write the entire recipe file. |
| |
| Usage: |
| |
| with repo.write_recipe('my_recipe') as recipe: |
| recipe.DEPS.append('my_module') |
| recipe.RunSteps.write(''' |
| api.step('stepname', ['echo', 'something']) |
| api.my_module.cool_function() |
| ''') |
| |
| Args: |
| * name_or_module (str) - If the only argument, this is the name of the |
| recipe to write. If provided in tandem with `name`, then this is the |
| module to write the recipe under. |
| * name (str|None) - If provided, this is the name of the recipe, and |
| `name_or_module` is the name of the recipe module. |
| |
| Yields a WriteableRecipe object. You may manipulate the DEPS, GenTests and |
| RunSteps members to craft the recipe you want for your test. See |
| WriteableRecipe for details. |
| """ |
| if name: |
| base_path = os.path.join('recipe_modules', name_or_module, name) |
| else: |
| base_path = os.path.join('recipes', name_or_module) |
| |
| recipe = self.WriteableRecipe(base_path) |
| yield recipe |
| |
| dump = self.fake_recipe_deps._ambient_toplevel_code_dump() |
| |
| with self.write_file(base_path + '.py') as buf: |
| buf.write(textwrap.dedent(''' |
| {imports} |
| |
| {ambient_toplevel_code} |
| |
| DEPS = {DEPS!r} |
| |
| PROPERTIES = {PROPERTIES} |
| ENV_PROPERTIES = {ENV_PROPERTIES} |
| |
| def RunSteps({RunSteps_args}): |
| {RunSteps} |
| |
| def GenTests(api): |
| {GenTests} |
| ''').format( |
| imports='\n'.join(recipe.imports), |
| ambient_toplevel_code=dump, |
| DEPS=recipe.DEPS, |
| PROPERTIES=recipe.PROPERTIES, |
| ENV_PROPERTIES=recipe.ENV_PROPERTIES, |
| RunSteps_args=', '.join(recipe.RunSteps_args), |
| RunSteps=_get_suite(recipe.RunSteps, 'pass'), |
| GenTests=_get_suite( |
| recipe.GenTests, "yield api.test('basic')"))) |
| |
| for test_name, expectation in recipe.expectation.items(): |
| test_name = filesystem_safe(test_name) |
| expect_path = os.path.join(base_path + '.expected', test_name + '.json') |
| with self.write_file(expect_path) as buf: |
| json.dump(expectation, buf, indent=2) |
| self.recipes_py('doc') |
| |
| @attr.s(slots=True) |
| class WriteableModule: |
| """Yielded from the `write_module` method. Used to generate a recipe module |
| on disk. |
| |
| This contains three string buffers which you may write to, `api`, `test_api` |
| and `config`. All buffers will be dedent'd as described in `write_file`. |
| |
| * `api` represents the body of the {modname.title}Api class inside of |
| `api.py`. If not written to, then the body of this class will be `pass`. |
| * `test_api` represents the body of the {modname.title}TestApi class |
| inside of `test_api.py`. If not written to, then the body of this class |
| will be `pass`. |
| * `config` represents the body of `config.py`. If not written to then |
| `config.py` will not be written to disk. |
| |
| The imports attribute is a list of 'import' lines you want at the top of the |
| module files. These lines should be an entire valid python import line (i.e |
| starting with `import` or `from`). |
| |
| The DEPS attribute corresponds exactly to the recipe module's DEPS field, |
| and you may put a python list or dict here, depending on how you want your |
| recipe's DEPS to look. The DEPS comes, by default, as a list containing the |
| 'recipe_engine/step' module. This list may be appended to, or replaced |
| entirely (with assignment), if you choose. |
| |
| The PROPERTIES attribute is a Python expression to assign to the module's |
| PROPERTIES field. |
| |
| The DISABLE_STRICT_COVERAGE maps directly to the same-named option in |
| `__init__.py`. |
| """ |
| path = attr.ib() # base path of the module folder |
| |
| api = attr.ib(factory=StringIO) |
| test_api = attr.ib(factory=StringIO) |
| config = attr.ib(factory=StringIO) |
| imports = attr.ib(factory=list) |
| DEPS = attr.ib(factory=lambda: ['recipe_engine/step']) |
| PROPERTIES = attr.ib(default='{}') |
| GLOBAL_PROPERTIES = attr.ib(default='None') |
| ENV_PROPERTIES = attr.ib(default='None') |
| WARNINGS = attr.ib(factory=list) |
| DISABLE_STRICT_COVERAGE = attr.ib(default=False) |
| |
| @contextlib.contextmanager |
| def write_module(self, mod_name): |
| """Context manager for writing a recipe module to the disk in this testing |
| repo. |
| |
| Overwrites any existing recipe module (i.e. the existing module, if any, |
| will be removed). |
| |
| If you need to write a malformed recipe module, or one with additional |
| customizations, please use this method, and then use `write_file` to |
| overwrite whichever files need to be adjusted. |
| |
| Args: |
| * mod_name (str) - The name of the module to write. |
| |
| Yields a WriteableModule object. You may manipulate its various fields to |
| craft the recipe module you want for your test. See WriteableModule for |
| details. |
| """ |
| base = os.path.join('recipe_modules', mod_name) |
| |
| mod = self.WriteableModule(base) |
| yield mod |
| |
| if self.exists(base): |
| shutil.rmtree(os.path.join(self.path, base)) |
| |
| with self.write_file(os.path.join(base, '__init__.py')) as buf: |
| buf.write(''' |
| {imports} |
| |
| DEPS = {DEPS!r} |
| |
| WARNINGS = {WARNINGS!r} |
| |
| DISABLE_STRICT_COVERAGE = {DISABLE_STRICT_COVERAGE!r} |
| |
| PROPERTIES = {PROPERTIES} |
| GLOBAL_PROPERTIES = {GLOBAL_PROPERTIES} |
| ENV_PROPERTIES = {ENV_PROPERTIES} |
| '''.format( |
| imports='\n'.join(mod.imports), |
| DEPS=mod.DEPS, |
| WARNINGS = mod.WARNINGS, |
| DISABLE_STRICT_COVERAGE=mod.DISABLE_STRICT_COVERAGE, |
| PROPERTIES=mod.PROPERTIES, |
| GLOBAL_PROPERTIES=mod.GLOBAL_PROPERTIES, |
| ENV_PROPERTIES=mod.ENV_PROPERTIES, |
| )) |
| |
| dump = self.fake_recipe_deps._ambient_toplevel_code_dump() |
| |
| with self.write_file(os.path.join(base, 'api.py')) as buf: |
| buf.write(textwrap.dedent(''' |
| from recipe_engine.recipe_api import RecipeApi |
| |
| {imports} |
| |
| {ambient_toplevel_code} |
| |
| class {mod_name}Api(RecipeApi): |
| {api} |
| ''').format( |
| imports='\n'.join(mod.imports), |
| ambient_toplevel_code=dump, |
| mod_name=mod_name.title().replace(' ', ''), |
| api=_get_suite(mod.api, 'pass'))) |
| |
| with self.write_file(os.path.join(base, 'test_api.py')) as buf: |
| buf.write(textwrap.dedent(''' |
| from recipe_engine.recipe_test_api import RecipeTestApi |
| |
| {imports} |
| |
| {ambient_toplevel_code} |
| |
| class {mod_name}Api(RecipeTestApi): |
| {test_api} |
| ''').format( |
| imports='\n'.join(mod.imports), |
| ambient_toplevel_code=dump, |
| mod_name=mod_name.title().replace(' ', ''), |
| test_api=_get_suite(mod.test_api, 'pass'))) |
| |
| config_body = _get_suite(mod.config, '', indent='') |
| if config_body: |
| with self.write_file(os.path.join(base, 'config.py')) as buf: |
| buf.write(textwrap.dedent(''' |
| from recipe_engine.config import ConfigGroup, ConfigList, Dict, List |
| from recipe_engine.config import Set, Single, config_item_context |
| |
| {imports} |
| |
| {config_body} |
| ''').format( |
| imports='\n'.join(mod.imports), |
| config_body=config_body, |
| )) |
| |
| def add_dep(self, *depnames): |
| """Adds new repo-level dependencies to this repo. |
| |
| The recipes_cfg_pb2 file will be updated to contain all the new entries; |
| they'll point to the current HEAD version of the repos. |
| |
| Args: |
| * depnames (List[str]) - The names of the other repos to depend on. These |
| must already have been created with `RecipeDeps.add_repo`. |
| """ |
| with self.edit_recipes_cfg_pb2() as pkg_pb: |
| for depname in depnames: |
| dep_repo = self.fake_recipe_deps.repos[depname] |
| dep_entry = pkg_pb.deps[depname] |
| dep_entry.url = 'file://' + dep_repo.path |
| dep_entry.branch = 'refs/heads/main' |
| dep_entry.revision = dep_repo.backend.commit_metadata('HEAD').revision |
| |
| def recipes_py(self, *args, **kwargs): |
| """Runs `recipes.py` in this repo with the given args, just like a user |
| might run it. |
| |
| Args: |
| * args (List[str]) - the arguments to pass to recipes.py. |
| |
| Kwargs: |
| * env (Dict[str, str]) - Extra environment variables to set while invoking |
| recipes.py. |
| * py3 (bool) - Instruct recipe to run with python3 interpreter. |
| |
| Returns (output, retcode) where 'output' is the combined stdout/stderr from |
| the command and retcode it's return code. |
| """ |
| env = os.environ.copy() |
| env.update(kwargs.pop('env', {})) |
| proc = subprocess.Popen( |
| ('python3', 'recipes.py') + args, |
| cwd=self.path, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| env=env, |
| text=True) |
| output, _ = proc.communicate() |
| return output, proc.returncode |
| |
| class TestCommitMetadata(CommitMetadata): |
| """Commit metadata for a git commit. |
| |
| Identical to `fetch.CommitMetadata`, except that it has a helper function |
| to make writing autoroller tests less tedious. |
| """ |
| def as_roll_info(self): |
| """Returns a dict of author_email, message_lines and revision which |
| is JSON serializable. |
| |
| In particular, message_lines is a list instead of a tuple. |
| """ |
| return { |
| 'author_email': self.author_email, |
| 'message_lines': list(self.message_lines), |
| 'revision': self.revision, |
| } |
| |
| def commit(self, msg, |
| author_name='Phinley Pfeiffer', |
| author_email='ph.pf@example.com'): |
| """Adds all files in the repo, then commits it with the given message. |
| |
| Returns a TestCommitMetadata object describing the commit we just created. |
| """ |
| # pylint: disable=protected-access |
| self.backend._git('add', '.') |
| self.backend._git( |
| '-c', 'user.name='+author_name, |
| '-c', 'user.email='+author_email, |
| 'commit', '--allow-empty', '-m', msg) |
| |
| return self.TestCommitMetadata( |
| **self.backend.commit_metadata('HEAD')._asdict()) |
| |
| |
| @attr.s |
| class FakeRecipeDeps: |
| """FakeRecipeDeps is a heavyweight testing object which controls a collection |
| of git repos on disk. |
| |
| Every FakeRecipeDeps has a 'main' repo. This is analogous to the user-facing |
| recipe repo that you might run recipes from (i.e. it has deps on 'upstream' |
| repos, including, but not limited to, the recipe engine repo). |
| |
| When you create a new FakeRecipeDeps, it auto-creates this main repo with |
| a dependency on the recipe_engine (the repo you're reading this from). |
| |
| You can add additional repos with the add_repo method, and create dependencies |
| between repos with the 'FakeRecipeRepo.add_dep' method. |
| """ |
| |
| # _root will contain: |
| # * sources/ - All non-main repos are created here, with their name |
| # as a subfolder. |
| # * main/ - The main repo is created here |
| # * main/.recipe_deps - The .recipe_deps folder of the main repo. |
| _root = attr.ib() |
| |
| # A map of repo_name -> FakeRecipeRepo |
| repos = attr.ib(factory=dict) |
| |
| # A list of textwrap.deindent-able python snippets which will be injected into |
| # every recipe, module api and test_api written with this FakeRecipeDeps. |
| # |
| # This is useful to add helper methods and imports which are implicitly |
| # available everywhere in the test. |
| ambient_toplevel_code = attr.ib(factory=list) |
| |
| def _ambient_toplevel_code_dump(self): |
| return '\n'.join(map(textwrap.dedent, self.ambient_toplevel_code)) |
| |
| ENGINE_REVISION = None |
| @classmethod |
| def _get_engine_revision(cls): |
| if not cls.ENGINE_REVISION: |
| if subprocess.call(['git', 'diff-index', '--quiet', 'HEAD', '--']): |
| print('*' * 6, file=REAL_STDERR) |
| print(textwrap.dedent(''' |
| WARNING: Tests may rely on current recipe engine repo, but you have |
| un-committed changes. If you see unexpected behavior in the tests please |
| try committing your changes to the engine repo first and then running |
| the tests again. |
| ''').lstrip(), end=' ', file=REAL_STDERR) |
| print('*' * 6, file=REAL_STDERR) |
| print(file=REAL_STDERR) |
| REAL_STDERR.flush() |
| |
| cls.ENGINE_REVISION = subprocess.check_output( |
| ['git', 'rev-parse', 'HEAD'], text=True).strip() |
| return cls.ENGINE_REVISION |
| |
| def _create_repo(self, name, path): |
| """Creates a recipe repo with the given name at the given path. |
| |
| This generates an `infra/config/recipes.cfg` with a file:// dependency on |
| the current recipe engine, repo_name set to $name. |
| |
| Returns the commit of the created repo. |
| """ |
| assert isinstance(name, str) |
| assert name not in self.repos, ( |
| 'duplicate repo_name: %r' % (name,)) |
| os.makedirs(path) |
| subprocess.check_call( |
| ['git', 'init', '-b', 'main'], cwd=path, stdout=DEVNULL) |
| cfg_path = os.path.join(path, RECIPES_CFG_LOCATION_REL) |
| os.makedirs(os.path.dirname(cfg_path)) |
| with open(cfg_path, 'w') as fil: |
| json.dump( |
| { |
| 'api_version': 2, |
| 'repo_name': name, |
| 'deps': { |
| 'recipe_engine': { |
| 'url': 'file://' + ROOT_DIR, |
| 'branch': 'HEAD', |
| 'revision': self._get_engine_revision(), |
| }, |
| }, |
| }, fil) |
| readme_path = os.path.join(path, 'README.recipes.md') |
| readme_str = ('<!--- AUTOGENERATED BY `./recipes.py test train` -->\n' |
| '# Repo documentation for [main]()\n## Table of Contents') |
| with open(readme_path, 'w') as f: |
| f.write(readme_str) |
| shutil.copy(os.path.join(ROOT_DIR, 'recipes.py'), path) |
| subprocess.check_call(['git', 'add', '.'], cwd=path, stdout=DEVNULL) |
| subprocess.check_call(['git', 'commit', '-m', 'init '+name], cwd=path, |
| stdout=DEVNULL) |
| self.repos[name] = FakeRecipeRepo(self, name, path) |
| return subprocess.check_output( |
| ['git', 'rev-parse', 'HEAD'], cwd=path).strip() |
| |
| def __attrs_post_init__(self): |
| """Makes a new RecipeDeps temp folder on disk with a single (main) repo |
| called 'main' which has no dependencies.""" |
| self._create_repo('main', os.path.join(self._root, 'main')) |
| |
| @property |
| def recipe_deps_path(self): |
| """Returns the absolute path to the `.recipe_deps` folder of the main repo. |
| """ |
| return os.path.join(self.main_repo.path, '.recipe_deps') |
| |
| def add_repo(self, name, detached=False): |
| """Adds a new repo to the RecipeDeps. |
| |
| This is created in `{FakeRecipeDeps.recipe_deps_path}/{name}`. |
| |
| This adds a dependency of the main repo onto this new repo, AND COMMITS THE |
| CHANGE TO recipes.cfg. |
| |
| Returns created FakeRecipeRepo. |
| """ |
| assert isinstance(name, str) |
| self._create_repo(name, os.path.join(self._root, 'sources', name)) |
| |
| if not detached: |
| self.main_repo.add_dep(name) |
| self.main_repo.commit('add dep on ' + name) |
| return self.repos[name] |
| |
| @property |
| def main_repo(self): |
| """Returns the main FakeRecipeRepo for this FakeRecipeDeps.""" |
| return self.repos['main'] |