Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions conan/api/subapi/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,46 @@ def packages(self):
)
return packages

def open(self, ref, remotes, cwd=None):
cwd = cwd or os.getcwd()
def open_missing(self, remotes):
"""
For each package in the current workspace definition, if its folder does not
exist, open the package into it. If the folder exists, ensure it contains a
conanfile.py, raising otherwise.
"""
self._check_ws()
opened = []
# Disable the workspace while opening: packages() validation would fail on
# the very folders we are about to create
self.enable(False)
try:
for package_info in self._ws.packages():
rel_path = package_info["path"]
ref = package_info.get("ref")
abs_path = os.path.normpath(os.path.join(self._folder, rel_path))
if os.path.exists(abs_path):
if not os.path.isfile(os.path.join(abs_path, "conanfile.py")):
raise ConanException(f"Folder '{abs_path}' exists but does not "
f"contain a conanfile.py")
ConanOutput().info(f"Package folder already exists, skipping: {abs_path}")
continue
if not ref:
raise ConanException(f"Cannot open workspace package at '{rel_path}': "
f"missing 'ref' in workspace definition")
reference = RecipeReference.loads(ref)
parent = os.path.dirname(abs_path) or self._folder
os.makedirs(parent, exist_ok=True)
ConanOutput().info(f"Opening package '{ref}' into: {abs_path}")
self.open(reference, remotes, cwd=parent,
folder=os.path.basename(abs_path))
opened.append(reference)
finally:
self.enable(True)
return opened

def open(self, ref, remotes, cwd=None, folder=None):
# Default target is the workspace root when inside a workspace, so running
# from a subfolder doesn't clone into that subfolder
cwd = cwd or self._folder or os.getcwd()
proxy, _, loader, _ = self._conan_api._api_helpers.get_loader() # noqa
ref = RecipeReference.loads(ref) if isinstance(ref, str) else ref
recipe = proxy.get_recipe(ref, remotes, update=False, check_update=False)
Expand All @@ -162,7 +200,8 @@ def open(self, ref, remotes, cwd=None):
conanfile, module = 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, ref.name)
target = folder or ref.name
dst_path = os.path.join(cwd, target)
if scm is None:
conanfile.output.warning("conandata doesn't contain 'scm' information\n"
"doing a local copy!!!")
Expand All @@ -176,8 +215,8 @@ def open(self, ref, remotes, cwd=None):
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.clone(url=scm["url"], target=target)
git.folder = target # change to the cloned folder
git.checkout(commit=scm["commit"])
return dst_path

Expand All @@ -186,7 +225,7 @@ def _check_ws(self):
raise ConanException(f"Workspace not defined, please create a "
f"'{WORKSPACE_PY}' or '{WORKSPACE_YML}' file")

def add(self, path, name=None, version=None, user=None, channel=None, cwd=None,
def add(self, path, name=None, version=None, user=None, channel=None,
output_folder=None, remotes=None):
"""
Add a new editable package to the current workspace (the current workspace must exist)
Expand All @@ -201,7 +240,7 @@ def add(self, path, name=None, version=None, user=None, channel=None, cwd=None,
@return: The reference of the added package
"""
self._check_ws()
full_path = self._conan_api.local.get_conanfile_path(path, cwd, py=True)
full_path = self._conan_api.local.get_conanfile_path(path, cwd=None, py=True)
loader = self._conan_api._api_helpers.loader # noqa
conanfile = loader.load_named(full_path, name, version, user, channel, remotes=remotes)
if conanfile.name is None or conanfile.version is None:
Expand Down
53 changes: 43 additions & 10 deletions conan/cli/commands/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from conan.api.conan_api import ConanAPI
from conan.api.model import RecipeReference
from conan.api.output import ConanOutput, cli_out_write
from conan.api.subapi.workspace import WorkspaceAPI
from conan.cli import make_abs_path
from conan.cli.args import add_reference_args, add_common_install_arguments, add_lockfile_args
from conan.cli.command import conan_command, conan_subcommand, OnceArgument
Expand All @@ -16,6 +15,24 @@
from conan.internal.graph.install_graph import ProfileArgs


def _resolve_ws_relative_folder(conan_api, folder):
"""Split a workspace-root-relative path into (parent_cwd, leaf_folder_name).
Returns (None, None) when folder is not set, letting the API pick defaults.
Creates any missing intermediate directories.
"""
if not folder:
return None, None
if os.path.isabs(folder):
raise ConanException(f"'--folder' must be relative to the workspace root: {folder}")
ws_folder = conan_api.workspace.folder()
abs_target = os.path.normpath(os.path.join(ws_folder, folder))
if os.path.commonpath([abs_target, ws_folder]) != os.path.normpath(ws_folder):
raise ConanException(f"'--folder' escapes the workspace root: {folder}")
parent = os.path.dirname(abs_target)
os.makedirs(parent, exist_ok=True)
return parent, os.path.basename(abs_target)


@conan_subcommand(formatters={"text": cli_out_write})
def workspace_root(conan_api: ConanAPI, parser, subparser, *args): # noqa
"""
Expand All @@ -29,19 +46,30 @@ def workspace_root(conan_api: ConanAPI, parser, subparser, *args): # noqa
@conan_subcommand()
def workspace_open(conan_api: ConanAPI, parser, subparser, *args):
"""
Open specific references
Open specific references. If no reference is provided, open every package
in the current workspace definition whose folder does not yet exist.
"""
subparser.add_argument("reference",
help="Open this package source repository")
subparser.add_argument("reference", nargs="?",
help="Open this package source repository. If omitted, "
"open all packages in the current workspace definition")
subparser.add_argument("--folder",
help="Target folder for the opened package, relative to the "
"workspace root. Subfolders are allowed (e.g. libs/mypkg). "
"Only valid together with a 'reference' argument")
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)
if args.folder and not args.reference:
raise ConanException("'--folder' requires a 'reference' argument")
if args.reference:
cwd, folder = _resolve_ws_relative_folder(conan_api, args.folder)
conan_api.workspace.open(args.reference, remotes=remotes, cwd=cwd, folder=folder)
else:
conan_api.workspace.open_missing(remotes=remotes)


@conan_subcommand()
Expand All @@ -53,6 +81,10 @@ def workspace_add(conan_api: ConanAPI, parser, subparser, *args):
help='Path to the package folder in the user workspace')
add_reference_args(subparser)
subparser.add_argument("--ref", help="Open and add this reference")
subparser.add_argument("--folder",
help="Target folder for the opened package, relative to the "
"workspace root. Subfolders are allowed (e.g. libs/mypkg). "
"Only valid together with '--ref'")
subparser.add_argument("-of", "--output-folder",
help='The root output folder for generated and build files')
group = subparser.add_mutually_exclusive_group()
Expand All @@ -63,15 +95,16 @@ def workspace_add(conan_api: ConanAPI, parser, subparser, *args):
args = parser.parse_args(*args)
if args.path and args.ref:
raise ConanException("Do not use both 'path' and '--ref' argument")
if args.folder and not args.ref:
raise ConanException("'--folder' requires '--ref'")
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)
cwd, folder = _resolve_ws_relative_folder(conan_api, args.folder)
path = conan_api.workspace.open(args.ref, remotes, cwd=cwd, folder=folder)
ref = conan_api.workspace.add(path,
args.name, args.version, args.user, args.channel,
cwd, args.output_folder, remotes=remotes)
args.output_folder, remotes=remotes)
ConanOutput().success("Reference '{}' added to workspace".format(ref))


Expand Down
134 changes: 134 additions & 0 deletions test/integration/workspace/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,140 @@ def export(self):
c2.run("workspace info")
assert "pkg/0.1" in c2.out

def test_open_missing_all_from_workspace_def(self):
# Upload pkga and pkgb so they can be opened from remote
t = TestClient(default_server_user=True, light=True)
t.save({"pkga/conanfile.py": GenConanfile("pkga", "0.1"),
"pkgb/conanfile.py": GenConanfile("pkgb", "0.1")})
t.run("create pkga")
t.run("create pkgb")
t.run("upload * -r=default -c")

# Workspace defines both packages but only pkga folder exists locally
c = TestClient(servers=t.servers, light=True)
c.save({"conanws.yml": textwrap.dedent("""\
packages:
- path: pkga
ref: pkga/0.1
- path: pkgb
ref: pkgb/0.1
"""),
"pkga/conanfile.py": GenConanfile("pkga", "0.1")})
c.run("workspace open")
assert "already exists, skipping" in c.out
assert "Opening package 'pkgb/0.1'" in c.out
assert "name = 'pkgb'" in c.load("pkgb/conanfile.py")

# Re-running is a no-op: both folders are present now
c.run("workspace open")
assert "Opening package" not in c.out

def test_open_from_subfolder_clones_into_workspace_root(self):
# A workspace exists and the user runs 'open'/'add --ref' from a package subfolder.
# The clone must land in the workspace root, not inside the subfolder.
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.save({"conanws.yml": "",
"sub/placeholder.txt": ""})
with c.chdir("sub"):
c.run("workspace open pkg/0.1")
assert "name = 'pkg'" in c.load("pkg/conanfile.py")
assert not os.path.exists(os.path.join(c.current_folder, "sub", "pkg"))

# Same for 'workspace add --ref'
c2 = TestClient(servers=t.servers, light=True)
c2.save({"conanws.yml": "",
"sub/placeholder.txt": ""})
with c2.chdir("sub"):
c2.run("workspace add --ref=pkg/0.1")
assert "name = 'pkg'" in c2.load("pkg/conanfile.py")
assert not os.path.exists(os.path.join(c2.current_folder, "sub", "pkg"))

def test_open_missing_respects_workspace_path(self):
# The workspace paths use a custom folder name and a nested subfolder;
# 'workspace open' with no args must clone into those exact paths.
t = TestClient(default_server_user=True, light=True)
t.save({"pkga/conanfile.py": GenConanfile("pkga", "0.1"),
"pkgb/conanfile.py": GenConanfile("pkgb", "0.1")})
t.run("create pkga")
t.run("create pkgb")
t.run("upload * -r=default -c")

c = TestClient(servers=t.servers, light=True)
c.save({"conanws.yml": textwrap.dedent("""\
packages:
- path: custom_a
ref: pkga/0.1
- path: libs/nested_b
ref: pkgb/0.1
""")})
c.run("workspace open")
assert "name = 'pkga'" in c.load("custom_a/conanfile.py")
assert "name = 'pkgb'" in c.load("libs/nested_b/conanfile.py")
# No stray default-named folders were created
assert not os.path.exists(os.path.join(c.current_folder, "pkga"))
assert not os.path.exists(os.path.join(c.current_folder, "pkgb"))

def test_open_and_add_folder_argument(self):
# '--folder' places the clone at a workspace-root-relative path,
# including subfolders. Applies to both 'open <ref>' and 'add --ref'.
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.save({"conanws.yml": ""})
c.run("workspace open pkg/0.1 --folder=libs/mypkg")
assert "name = 'pkg'" in c.load("libs/mypkg/conanfile.py")
assert not os.path.exists(os.path.join(c.current_folder, "pkg"))

# From a subfolder the target is still workspace-root-relative
c.save({"sub/placeholder.txt": ""})
with c.chdir("sub"):
c.run("workspace open pkg/0.1 --folder=other/here")
assert "name = 'pkg'" in c.load("other/here/conanfile.py")
assert not os.path.exists(os.path.join(c.current_folder, "sub", "other"))

# 'workspace add --ref' with --folder
c2 = TestClient(servers=t.servers, light=True)
c2.save({"conanws.yml": ""})
c2.run("workspace add --ref=pkg/0.1 --folder=nested/leaf")
assert "name = 'pkg'" in c2.load("nested/leaf/conanfile.py")
c2.run("workspace info")
assert "pkg/0.1" in c2.out
assert "nested/leaf" in c2.out

def test_folder_argument_validation(self):
c = TestClient(light=True)
c.save({"conanws.yml": ""})
c.run("workspace open --folder=libs/mypkg", assert_error=True)
assert "'--folder' requires a 'reference' argument" in c.out
c.run("workspace add --folder=libs/mypkg", assert_error=True)
assert "'--folder' requires '--ref'" in c.out
# Absolute path is rejected
abs_path = os.path.join(c.current_folder, "abs")
c.run(f'workspace open pkg/0.1 --folder="{abs_path}"', assert_error=True)
assert "'--folder' must be relative to the workspace root" in c.out
# Escaping the workspace root is rejected
c.run("workspace open pkg/0.1 --folder=../outside", assert_error=True)
assert "'--folder' escapes the workspace root" in c.out

def test_open_missing_folder_without_conanfile_raises(self):
c = TestClient(light=True)
c.save({"conanws.yml": textwrap.dedent("""\
packages:
- path: pkga
ref: pkga/0.1
"""),
"pkga/README.md": "not a conanfile"})
c.run("workspace open", assert_error=True)
assert "exists but does not contain a conanfile.py" in c.out

def test_workspace_build_editables(self):
c = TestClient(light=True)
c.save({"conanws.yml": ""})
Expand Down
Loading