From f5a15b54819f17cbe928d3492e19308931a28b28 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 22 Nov 2024 10:34:46 +0100 Subject: [PATCH] Feature/workspace base (#17272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: move editables to local api * workspaces back * wip * Git.to_conandata and from_conandata * Raise error if `check_type=int` and conf value is set to `bool` (#15378) raise if bool on int check * auto -FS in AutotoolsToolchain (#15375) * auto -FS in AutotoolsToolchain * moved test * fix winsdk_version bug (#15373) * allow to copy & paste from compact format into conan-lock-add (#15262) * allow to copy & paste from compact format into conan-lock-add * Revert, and just print the %timestamp too * Remove float timestamp from conan list --format=text * Fix test --------- Co-authored-by: Rubén Rincón Blanco * wip * wip * open + add + remove proposal * wip * wip * wip * wip * wip * wip * wip * initial minimal workspace * wip * new test * minor change to make test fail * making workspace decoupled from editables * fix docstring * new test for dynamic workspace * UserWorkpaceAPI * fix test * review --------- Co-authored-by: Carlos Zoido Co-authored-by: Rubén Rincón Blanco --- conan/api/conan_api.py | 4 +- conan/api/subapi/local.py | 13 +- conan/api/subapi/workspace.py | 82 ++++++ conan/cli/commands/workspace.py | 108 +++++++ conan/internal/workspace.py | 154 ++++++++++ conan/test/utils/tools.py | 6 + test/integration/workspace/__init__.py | 0 test/integration/workspace/test_workspace.py | 292 +++++++++++++++++++ 8 files changed, 654 insertions(+), 5 deletions(-) create mode 100644 conan/api/subapi/workspace.py create mode 100644 conan/cli/commands/workspace.py create mode 100644 conan/internal/workspace.py create mode 100644 test/integration/workspace/__init__.py create mode 100644 test/integration/workspace/test_workspace.py diff --git a/conan/api/conan_api.py b/conan/api/conan_api.py index cfa9740f9b3..0b280e5ed58 100644 --- a/conan/api/conan_api.py +++ b/conan/api/conan_api.py @@ -5,6 +5,7 @@ from conan.api.subapi.command import CommandAPI from conan.api.subapi.local import LocalAPI from conan.api.subapi.lockfile import LockfileAPI +from conan.api.subapi.workspace import WorkspaceAPI from conan import conan_version from conan.api.subapi.config import ConfigAPI from conan.api.subapi.download import DownloadAPI @@ -32,7 +33,8 @@ def __init__(self, cache_folder=None): raise ConanException("Conan needs Python >= 3.6") init_colorama(sys.stderr) - self.cache_folder = cache_folder or get_conan_user_home() + self.workspace = WorkspaceAPI(self) + self.cache_folder = self.workspace.home_folder() or cache_folder or get_conan_user_home() self.home_folder = self.cache_folder # Lets call it home, deprecate "cache" # Migration system diff --git a/conan/api/subapi/local.py b/conan/api/subapi/local.py index 1b4c1962ab2..5cff434a684 100644 --- a/conan/api/subapi/local.py +++ b/conan/api/subapi/local.py @@ -17,6 +17,9 @@ class LocalAPI: def __init__(self, conan_api): self._conan_api = conan_api self.editable_packages = EditablePackages(conan_api.home_folder) + editables = conan_api.workspace.editables() + if editables: + self.editable_packages.edited_refs.update(editables) @staticmethod def get_conanfile_path(path, cwd, py): @@ -55,17 +58,20 @@ def editable_add(self, path, name=None, version=None, user=None, channel=None, c target_path = self._conan_api.local.get_conanfile_path(path=path, cwd=cwd, py=True) output_folder = make_abs_path(output_folder) if output_folder else None # Check the conanfile is there, and name/version matches - self.editable_packages.add(ref, target_path, output_folder=output_folder) + editable_packages = EditablePackages(self._conan_api.home_folder) + editable_packages.add(ref, target_path, output_folder=output_folder) return ref def editable_remove(self, path=None, requires=None, cwd=None): if path: path = make_abs_path(path, cwd) path = os.path.join(path, "conanfile.py") - return self.editable_packages.remove(path, requires) + editable_packages = EditablePackages(self._conan_api.home_folder) + return editable_packages.remove(path, requires) def editable_list(self): - return self.editable_packages.edited_refs + editable_packages = EditablePackages(self._conan_api.home_folder) + return editable_packages.edited_refs def source(self, path, name=None, version=None, user=None, channel=None, remotes=None): """ calls the 'source()' method of the current (user folder) conanfile.py @@ -116,4 +122,3 @@ def inspect(self, conanfile_path, remotes, lockfile, name=None, version=None, us conanfile = app.loader.load_named(conanfile_path, name=name, version=version, user=user, channel=channel, remotes=remotes, graph_lock=lockfile) return conanfile - diff --git a/conan/api/subapi/workspace.py b/conan/api/subapi/workspace.py new file mode 100644 index 00000000000..3a5fe6c49ef --- /dev/null +++ b/conan/api/subapi/workspace.py @@ -0,0 +1,82 @@ +import os +import shutil + +from conan.cli import make_abs_path +from conan.internal.conan_app import ConanApp +from conan.internal.workspace import Workspace +from conan.tools.scm import Git +from conan.errors import ConanException +from conans.client.graph.graph import RECIPE_EDITABLE +from conans.client.source import retrieve_exports_sources +from conans.model.recipe_ref import RecipeReference +from conans.util.files import merge_directories + + +class WorkspaceAPI: + + def __init__(self, conan_api): + self._conan_api = conan_api + self._workspace = Workspace() + + def home_folder(self): + return self._workspace.home_folder() + + def folder(self): + return self._workspace.folder + + def config_folder(self): + return self._workspace.config_folder() + + def editables(self): + return self._workspace.editables() + + def open(self, require, remotes, cwd=None): + app = ConanApp(self._conan_api) + ref = RecipeReference.loads(require) + recipe = app.proxy.get_recipe(ref, remotes, update=False, check_update=False) + + layout, recipe_status, remote = recipe + if recipe_status == RECIPE_EDITABLE: + raise ConanException(f"Can't open a dependency that is already an editable: {ref}") + ref = layout.reference + conanfile_path = layout.conanfile() + conanfile, module = app.loader.load_basic_module(conanfile_path, remotes=remotes) + + scm = conanfile.conan_data.get("scm") if conanfile.conan_data else None + dst_path = os.path.join(cwd or os.getcwd(), ref.name) + if scm is None: + conanfile.output.warning("conandata doesn't contain 'scm' information\n" + "doing a local copy!!!") + shutil.copytree(layout.export(), dst_path) + retrieve_exports_sources(app.remote_manager, layout, conanfile, ref, remotes) + export_sources = layout.export_sources() + if os.path.exists(export_sources): + conanfile.output.warning("There are export-sources, copying them, but the location" + " might be incorrect, use 'scm' approach") + merge_directories(export_sources, dst_path) + else: + git = Git(conanfile, folder=cwd) + git.clone(url=scm["url"], target=ref.name) + git.folder = ref.name # change to the cloned folder + git.checkout(commit=scm["commit"]) + return dst_path + + def add(self, path, name=None, version=None, user=None, channel=None, cwd=None, + output_folder=None, remotes=None): + path = self._conan_api.local.get_conanfile_path(path, cwd, py=True) + app = ConanApp(self._conan_api) + conanfile = app.loader.load_named(path, name, version, user, channel, remotes=remotes) + if conanfile.name is None or conanfile.version is None: + raise ConanException("Editable package recipe should declare its name and version") + ref = RecipeReference(conanfile.name, conanfile.version, conanfile.user, conanfile.channel) + ref.validate_ref() + output_folder = make_abs_path(output_folder) if output_folder else None + # Check the conanfile is there, and name/version matches + self._workspace.add(ref, path, output_folder=output_folder) + return ref + + def remove(self, path): + return self._workspace.remove(path) + + def info(self): + return self._workspace.serialize() diff --git a/conan/cli/commands/workspace.py b/conan/cli/commands/workspace.py new file mode 100644 index 00000000000..bbf19b607d8 --- /dev/null +++ b/conan/cli/commands/workspace.py @@ -0,0 +1,108 @@ +import json +import os + +from conan.api.conan_api import ConanAPI +from conan.api.output import ConanOutput, cli_out_write +from conan.cli import make_abs_path +from conan.cli.args import add_reference_args +from conan.cli.command import conan_command, conan_subcommand +from conan.cli.commands.list import print_serial +from conan.errors import ConanException + + +@conan_subcommand(formatters={"text": cli_out_write}) +def workspace_root(conan_api: ConanAPI, parser, subparser, *args): + """ + Return the folder containing the conanws.py/conanws.yml workspace file + """ + ws = conan_api.workspace + if not ws.folder(): + raise ConanException("No workspace defined, conanws.py file not found") + return ws.folder() + + +@conan_subcommand() +def workspace_open(conan_api: ConanAPI, parser, subparser, *args): + """ + Open specific references + """ + subparser.add_argument("reference", + help="Open this package source repository") + group = subparser.add_mutually_exclusive_group() + group.add_argument("-r", "--remote", action="append", default=None, + help='Look in the specified remote or remotes server') + group.add_argument("-nr", "--no-remote", action="store_true", + help='Do not use remote, resolve exclusively in the cache') + args = parser.parse_args(*args) + remotes = conan_api.remotes.list(args.remote) if not args.no_remote else [] + cwd = os.getcwd() + conan_api.workspace.open(args.reference, remotes=remotes, cwd=cwd) + + +@conan_subcommand() +def workspace_add(conan_api: ConanAPI, parser, subparser, *args): + """ + Add packages to current workspace + """ + subparser.add_argument('path', nargs="?", + help='Path to the package folder in the user workspace') + add_reference_args(subparser) + subparser.add_argument("--ref", nargs="?", + help="Open and add this reference") + subparser.add_argument("-of", "--output-folder", + help='The root output folder for generated and build files') + group = subparser.add_mutually_exclusive_group() + group.add_argument("-r", "--remote", action="append", default=None, + help='Look in the specified remote or remotes server') + group.add_argument("-nr", "--no-remote", action="store_true", + help='Do not use remote, resolve exclusively in the cache') + args = parser.parse_args(*args) + if args.path and args.ref: + raise ConanException("Do not use both 'path' and '--ref' argument") + remotes = conan_api.remotes.list(args.remote) if not args.no_remote else [] + cwd = os.getcwd() + path = args.path + if args.ref: + # TODO: Use path here to open in this path + path = conan_api.workspace.open(args.ref, remotes, cwd=cwd) + ref = conan_api.workspace.add(path, + args.name, args.version, args.user, args.channel, + cwd, args.output_folder, remotes=remotes) + ConanOutput().success("Reference '{}' added to workspace".format(ref)) + + +@conan_subcommand() +def workspace_remove(conan_api: ConanAPI, parser, subparser, *args): + """ + Remove packages to current workspace + """ + subparser.add_argument('path', help='Path to the package folder in the user workspace') + args = parser.parse_args(*args) + removed = conan_api.workspace.remove(make_abs_path(args.path)) + ConanOutput().info(f"Removed from workspace: {removed}") + + +def print_json(data): + results = data["info"] + myjson = json.dumps(results, indent=4) + cli_out_write(myjson) + + +def _print_workspace_info(data): + print_serial(data["info"]) + + +@conan_subcommand(formatters={"text": _print_workspace_info, "json": print_json}) +def workspace_info(conan_api: ConanAPI, parser, subparser, *args): + """ + Display info for current workspace + """ + parser.parse_args(*args) + return {"info": conan_api.workspace.info()} + + +@conan_command(group="Consumer") +def workspace(conan_api, parser, *args): + """ + Manage Conan workspaces (group of packages in editable mode) + """ diff --git a/conan/internal/workspace.py b/conan/internal/workspace.py new file mode 100644 index 00000000000..74cc1ffbb17 --- /dev/null +++ b/conan/internal/workspace.py @@ -0,0 +1,154 @@ +import os +from pathlib import Path + +import yaml + +from conan.api.output import ConanOutput +from conans.client.loader import load_python_file +from conan.errors import ConanException +from conans.model.recipe_ref import RecipeReference +from conans.util.files import load, save + + +def _find_ws_folder(): + path = Path(os.getcwd()) + while path.is_dir() and len(path.parts) > 1: # finish at '/' + if (path / "conanws.yml").is_file() or (path / "conanws.py").is_file(): + return str(path) + else: + path = path.parent + + +class _UserWorkspaceAPI: + def __init__(self, folder): + self.folder = folder + + def load(self, conanfile_path): + conanfile_path = os.path.join(self.folder, conanfile_path) + from conans.client.loader import ConanFileLoader + loader = ConanFileLoader(pyreq_loader=None, conanfile_helpers=None) + conanfile = loader.load_named(conanfile_path, name=None, version=None, user=None, + channel=None, remotes=None, graph_lock=None) + return conanfile + + +class Workspace: + TEST_ENABLED = False + + def __init__(self): + self._folder = _find_ws_folder() + if self._folder: + ConanOutput().warning(f"Workspace found: {self._folder}") + if (Workspace.TEST_ENABLED or os.getenv("CONAN_WORKSPACE_ENABLE")) != "will_break_next": + ConanOutput().warning("Workspace ignored as CONAN_WORKSPACE_ENABLE is not set") + self._folder = None + else: + ConanOutput().warning(f"Workspace is a dev-only feature, exclusively for testing") + + self._yml = None + self._py = None + if self._folder is not None: + self._yml_file = os.path.join(self._folder, "conanws.yml") + if os.path.exists(self._yml_file): + try: + self._yml = yaml.safe_load(load(self._yml_file)) + except Exception as e: + raise ConanException(f"Invalid workspace yml format at {self._folder}: {e}") + + py_file = os.path.join(self._folder, "conanws.py") + if os.path.exists(py_file): + self._py, _ = load_python_file(py_file) + setattr(self._py, "workspace_api", _UserWorkspaceAPI(self._folder)) + setattr(self._py, "conanws_data", self._yml) + + @property + def name(self): + return self._attr("name") or os.path.basename(self._folder) + + @property + def folder(self): + return self._folder + + def _attr(self, value): + if self._py and getattr(self._py, value, None): + attr = getattr(self._py, value) + return attr() if callable(attr) else attr + if self._yml: + return self._yml.get(value) + + def home_folder(self): + if not self._folder: + return + home = self._attr("home_folder") + if home is None or os.path.isabs(home): + return home + return os.path.normpath(os.path.join(self._folder, home)) + + def config_folder(self): + folder = self._attr("config_folder") + if folder is None or os.path.isabs(folder): + return folder + return os.path.normpath(os.path.join(self._folder, folder)) + + def _check_ws(self): + if not self._folder: + raise ConanException("Workspace not defined, please create a " + "'conanws.py' or 'conanws.yml' file") + + def add(self, ref, path, output_folder): + """ + Add a new editable to the current workspace 'conanws.yml' file. + If existing, the 'conanws.py' must use this via 'conanws_data' attribute + """ + self._check_ws() + self._yml = self._yml or {} + editable = {"path": self._rel_path(path)} + if output_folder: + editable["output_folder"] = self._rel_path(output_folder) + self._yml.setdefault("editables", {})[str(ref)] = editable + save(self._yml_file, yaml.dump(self._yml)) + + def _rel_path(self, path): + if path is None: + return None + if not os.path.isabs(path): + raise ConanException(f"Editable path must be absolute: {path}") + path = os.path.relpath(path, self._folder) + if path.startswith(".."): + raise ConanException(f"Editable path must be inside the workspace folder: " + f"{self._folder}") + return path.replace("\\", "/") # Normalize to unix path + + def remove(self, path): + self._check_ws() + self._yml = self._yml or {} + found_ref = None + path = self._rel_path(path) + for ref, info in self._yml.get("editables", {}).items(): + if os.path.dirname(info["path"]).replace("\\", "/") == path: + found_ref = ref + break + if not found_ref: + raise ConanException(f"No editable package to remove from this path: {path}") + self._yml["editables"].pop(found_ref) + save(self._yml_file, yaml.dump(self._yml)) + return found_ref + + def editables(self): + if not self._folder: + return + editables = self._attr("editables") + if editables: + editables = {RecipeReference.loads(r): v.copy() for r, v in editables.items()} + for v in editables.values(): + v["path"] = os.path.normpath(os.path.join(self._folder, v["path"])) + if v.get("output_folder"): + v["output_folder"] = os.path.normpath(os.path.join(self._folder, + v["output_folder"])) + return editables + + def serialize(self): + self._check_ws() + return {"name": self.name, + "folder": self._folder, + "editables": self._attr("editables")} diff --git a/conan/test/utils/tools.py b/conan/test/utils/tools.py index 7b998aaeeb5..da021b2d872 100644 --- a/conan/test/utils/tools.py +++ b/conan/test/utils/tools.py @@ -466,6 +466,12 @@ def __init__(self, cache_folder=None, current_folder=None, servers=None, inputs= def load(self, filename): return load(os.path.join(self.current_folder, filename)) + def load_home(self, filename): + try: + return load(os.path.join(self.cache_folder, filename)) + except IOError: + return None + @property def cache(self): # Returns a temporary cache object intended for inspecting it diff --git a/test/integration/workspace/__init__.py b/test/integration/workspace/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/workspace/test_workspace.py b/test/integration/workspace/test_workspace.py new file mode 100644 index 00000000000..fc78c08473c --- /dev/null +++ b/test/integration/workspace/test_workspace.py @@ -0,0 +1,292 @@ +import json +import os +import textwrap + +import pytest + +from conan.internal.workspace import Workspace +from conan.test.assets.genconanfile import GenConanfile +from conan.test.utils.scm import create_local_git_repo +from conan.test.utils.test_files import temp_folder +from conan.test.utils.tools import TestClient +from conans.util.files import save + +Workspace.TEST_ENABLED = "will_break_next" + + +class TestHomeRoot: + @pytest.mark.parametrize("ext, content", [("py", "home_folder = 'myhome'"), + ("yml", "home_folder: myhome")]) + def test_workspace_home(self, ext, content): + folder = temp_folder() + cwd = os.path.join(folder, "sub1", "sub2") + save(os.path.join(folder, f"conanws.{ext}"), content) + c = TestClient(current_folder=cwd, light=True) + c.run("config home") + assert os.path.join(folder, "myhome") in c.stdout + + def test_workspace_home_user_py(self): + folder = temp_folder() + cwd = os.path.join(folder, "sub1", "sub2") + conanwspy = textwrap.dedent(""" + def home_folder(): + return "new" + conanws_data["home_folder"] + """) + save(os.path.join(folder, f"conanws.py"), conanwspy) + save(os.path.join(folder, "conanws.yml"), "home_folder: myhome") + c = TestClient(current_folder=cwd, light=True) + c.run("config home") + assert os.path.join(folder, "newmyhome") in c.stdout + + def test_workspace_root(self): + c = TestClient(light=True) + # Just check the root command works + c.run("workspace root", assert_error=True) + assert "ERROR: No workspace defined, conanws.py file not found" in c.out + c.save({"conanws.py": ""}) + c.run("workspace root") + assert c.current_folder in c.stdout + + c.save({"conanws.yml": ""}, clean_first=True) + c.run("workspace root") + assert c.current_folder in c.stdout + + +class TestAddRemove: + + def test_add(self): + c = TestClient(light=True) + c.save({"conanws.py": "name='myws'", + "dep1/conanfile.py": GenConanfile("dep1", "0.1"), + "dep2/conanfile.py": GenConanfile("dep2", "0.1"), + "dep3/conanfile.py": GenConanfile("dep3", "0.1")}) + c.run("workspace add dep1") + assert "Reference 'dep1/0.1' added to workspace" in c.out + c.run("workspace info") + assert "dep1/0.1" in c.out + assert "dep2" not in c.out + c.run("editable list") # No editables in global + assert "dep1" not in c.out + assert "dep2" not in c.out + c.run("workspace add dep2") + assert "Reference 'dep2/0.1' added to workspace" in c.out + c.run("workspace info") + assert "dep1/0.1" in c.out + assert "dep2/0.1" in c.out + + with c.chdir(temp_folder()): # If we move to another folder, outside WS, no editables + c.run("editable list") + assert "dep1" not in c.out + assert "dep2" not in c.out + + c.run("workspace info") + assert "dep1/0.1" in c.out + assert "dep2/0.1" in c.out + + c.run("workspace remove dep1") + c.run("workspace info") + assert "dep1/0.1" not in c.out + assert "dep2/0.1" in c.out + + c.run("workspace remove dep2") + c.run("workspace info") + assert "dep1/0.1" not in c.out + assert "dep2/0.1" not in c.out + + def test_add_from_outside(self): + c = TestClient(light=True) + c.save({"sub/conanws.py": "name='myws'", + "sub/dep1/conanfile.py": GenConanfile("dep1", "0.1"), + "sub/dep2/conanfile.py": GenConanfile("dep2", "0.1")}) + with c.chdir("sub"): + c.run("workspace add dep1") + assert "Reference 'dep1/0.1' added to workspace" in c.out + c.run("workspace add dep2") + assert "Reference 'dep2/0.1' added to workspace" in c.out + c.run("workspace info") + assert "dep1/0.1" in c.out + assert "dep2/0.1" in c.out + assert c.load_home("editable_packages.json") is None + + c.run("editable list") + assert "dep1" not in c.out + assert "dep2" not in c.out + assert c.load_home("editable_packages.json") is None + with c.chdir("sub"): + c.run("editable add dep1") + assert c.load_home("editable_packages.json") is not None + c.run("editable list") + assert "dep1/0.1" in c.out + assert "dep2/0.1" not in c.out + c.run("workspace info") + assert "dep1" in c.out + assert "dep2" in c.out + + c.run("editable list") + assert "dep1/0.1" in c.out + assert "dep2" not in c.out + + @pytest.mark.parametrize("api", [False, True]) + def test_dynamic_editables(self, api): + c = TestClient(light=True) + conanfile = textwrap.dedent(""" + import os + from conan import ConanFile + from conan.tools.files import load + class Lib(ConanFile): + def set_name(self): + self.name = load(self, os.path.join(self.recipe_folder, "name.txt")) + def set_version(self): + self.version = load(self, os.path.join(self.recipe_folder, "version.txt")) + """) + if not api: + workspace = textwrap.dedent("""\ + import os + name = "myws" + + workspace_folder = os.path.dirname(os.path.abspath(__file__)) + + def editables(): + result = {} + for f in os.listdir(workspace_folder): + if os.path.isdir(os.path.join(workspace_folder, f)): + name = open(os.path.join(workspace_folder, f, "name.txt")).read().strip() + version = open(os.path.join(workspace_folder, f, + "version.txt")).read().strip() + p = os.path.join(f, "conanfile.py").replace("\\\\", "/") + result[f"{name}/{version}"] = {"path": p} + return result + """) + else: + workspace = textwrap.dedent("""\ + import os + name = "myws" + + def editables(*args, **kwargs): + result = {} + for f in os.listdir(workspace_api.folder): + if os.path.isdir(os.path.join(workspace_api.folder, f)): + f = os.path.join(f, "conanfile.py").replace("\\\\", "/") + conanfile = workspace_api.load(f) + result[f"{conanfile.name}/{conanfile.version}"] = {"path": f} + return result + """) + + c.save({"conanws.py": workspace, + "dep1/conanfile.py": conanfile, + "dep1/name.txt": "pkg", + "dep1/version.txt": "2.1"}) + c.run("workspace info --format=json") + info = json.loads(c.stdout) + assert info["editables"] == {"pkg/2.1": {"path": "dep1/conanfile.py"}} + c.save({"dep1/name.txt": "other", + "dep1/version.txt": "14.5"}) + c.run("workspace info --format=json") + info = json.loads(c.stdout) + assert info["editables"] == {"other/14.5": {"path": "dep1/conanfile.py"}} + c.run("install --requires=other/14.5") + # Doesn't fail + assert "other/14.5 - Editable" in c.out + with c.chdir("dep1"): + c.run("install --requires=other/14.5") + # Doesn't fail + assert "other/14.5 - Editable" in c.out + + def test_error_uppercase(self): + c = TestClient(light=True) + c.save({"conanws.py": "name='myws'", + "conanfile.py": GenConanfile("Pkg", "0.1")}) + c.run("workspace add .", assert_error=True) + assert "ERROR: Conan packages names 'Pkg/0.1' must be all lowercase" in c.out + c.save({"conanfile.py": GenConanfile()}) + c.run("workspace add . --name=Pkg --version=0.1", assert_error=True) + assert "ERROR: Conan packages names 'Pkg/0.1' must be all lowercase" in c.out + + def test_add_open_error(self): + c = TestClient(light=True) + c.save({"conanws.py": "name='myws'", + "dep/conanfile.py": GenConanfile("dep", "0.1")}) + c.run("workspace add dep") + c.run("workspace open dep/0.1", assert_error=True) + assert "ERROR: Can't open a dependency that is already an editable: dep/0.1" in c.out + + +class TestOpenAdd: + def test_without_git(self): + t = TestClient(default_server_user=True, light=True) + t.save({"conanfile.py": GenConanfile("pkg", "0.1")}) + t.run("create .") + t.run("upload * -r=default -c") + + c = TestClient(servers=t.servers, light=True) + c.run(f"workspace open pkg/0.1") + assert "name = 'pkg'" in c.load("pkg/conanfile.py") + + # The add should work the same + c2 = TestClient(servers=t.servers, light=True) + c2.save({"conanws.py": ""}) + c2.run(f"workspace add --ref=pkg/0.1") + assert "name = 'pkg'" in c2.load("pkg/conanfile.py") + c2.run("workspace info") + assert "pkg/0.1" in c2.out + + def test_without_git_export_sources(self): + t = TestClient(default_server_user=True, light=True) + t.save({"conanfile.py": GenConanfile("pkg", "0.1").with_exports_sources("*.txt"), + "CMakeLists.txt": "mycmake"}) + t.run("create .") + t.run("upload * -r=default -c") + + c = TestClient(servers=t.servers) + c.run("workspace open pkg/0.1") + assert "name = 'pkg'" in c.load("pkg/conanfile.py") + assert "mycmake" in c.load("pkg/CMakeLists.txt") + + def test_workspace_git_scm(self): + folder = temp_folder() + conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.scm import Git + + class Pkg(ConanFile): + name = "pkg" + version = "0.1" + def export(self): + git = Git(self) + git.coordinates_to_conandata() + """) + url, commit = create_local_git_repo(files={"conanfile.py": conanfile}, folder=folder, + branch="mybranch") + t1 = TestClient(default_server_user=True, light=True) + t1.run_command('git clone "file://{}" .'.format(url)) + t1.run("create .") + t1.run("upload * -r=default -c") + + c = TestClient(servers=t1.servers, light=True) + c.run("workspace open pkg/0.1") + assert c.load("pkg/conanfile.py") == conanfile + + c2 = TestClient(servers=t1.servers, light=True) + c2.save({"conanws.py": ""}) + c2.run(f"workspace add --ref=pkg/0.1") + assert 'name = "pkg"' in c2.load("pkg/conanfile.py") + c2.run("workspace info") + assert "pkg/0.1" in c2.out + + def test_workspace_build_editables(self): + c = TestClient(light=True) + c.save({"conanws.yml": ""}) + + c.save({"pkga/conanfile.py": GenConanfile("pkga", "0.1").with_build_msg("BUILD PKGA!"), + "pkgb/conanfile.py": GenConanfile("pkgb", "0.1").with_build_msg("BUILD PKGB!") + .with_requires("pkga/0.1")}) + c.run("workspace add pkga") + c.run("workspace add pkgb") + + c.run("install --requires=pkgb/0.1 --build=editable") + c.assert_listed_binary({"pkga/0.1": ("da39a3ee5e6b4b0d3255bfef95601890afd80709", + "EditableBuild"), + "pkgb/0.1": ("47a5f20ec8fb480e1c5794462089b01a3548fdc5", + "EditableBuild")}) + assert "pkga/0.1: WARN: BUILD PKGA!" in c.out + assert "pkgb/0.1: WARN: BUILD PKGB!" in c.out