Skip to content

Commit ec2b28b

Browse files
cottsaymxgrey
andauthored
Add support for cargo workspaces (#59)
* Specify package name in cargo operations When inspecting the output from `cargo metadata` and when invoking other cargo commands like `build`, `test`, and `fmt`, we should specify the name of the package we're operating on. When a package is part of a cargo workspace, these operations will normally encompass the whole workspace but colcon operates on a per-package basis, so we need to limit the scope to the specific package we're working with. * Add support for cargo workspaces This change adds support for cargo workspaces, both virtual workspaces and ones with top-level packages. It does so by caching the paths to the workspace members as the workspaces are discovered by "primary" discovery mechanisms (like a recursive crawl) and then specifically loads packages from those directories after the primary discovery extensions have completed. Note that all of the packages in a workspace are discovered regardless of the "default-members" value in the workspace configuration. A subtle change in behavior introduced by this change is that any packages in subdirectories of a workspace which are not listed as a workspace member will not be discovered. The presence of a valid workspace configuration, while not strictly a package itself, should serve as a declaration of what packages live in subdirectories of said workspace and colcon should respect that. * Use a separate identification extension for workspaces * Filter out false detection of circular dependencies (#62) * Add a comment about ignoring other workspace members Co-authored-by: Michael X. Grey <mxgrey@intrinsic.ai>
1 parent c01fa0b commit ec2b28b

18 files changed

Lines changed: 281 additions & 28 deletions

File tree

colcon_cargo/package_augmentation/cargo.py

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,21 @@ def _augment_package(
4040
if not package:
4141
return
4242

43+
package_name = package.get('name')
4344
version = package.get('version', '0.0.0')
4445
if not metadata.metadata.get('version'):
4546
metadata.metadata['version'] = version
4647

47-
dependencies = extract_dependencies(content, metadata.path)
48+
dependencies = extract_dependencies(
49+
package_name, content, metadata.path
50+
)
4851
for k, v in dependencies.items():
4952
metadata.dependencies[k] |= v
5053

5154
for category, spec in content.get('target', {}).items():
52-
dependencies = extract_dependencies(spec, metadata.path)
55+
dependencies = extract_dependencies(
56+
package_name, spec, metadata.path
57+
)
5358
for k, v in dependencies.items():
5459
metadata.dependencies[k] |= v
5560

@@ -59,7 +64,7 @@ def _augment_package(
5964
metadata.metadata['maintainers'] += authors
6065

6166

62-
def extract_dependencies(content, path):
67+
def extract_dependencies(package_name, content, path):
6368
"""
6469
Get the dependencies of a Cargo package.
6570
@@ -68,29 +73,56 @@ def extract_dependencies(content, path):
6873
:returns: The dependencies
6974
:rtype: dict(string, set(DependencyDescriptor))
7075
"""
71-
name = content.get('name')
7276
depends = {
7377
create_dependency_descriptor(k, v, path)
74-
for k, v in content.get('dependencies', {}).items()
75-
if k != name
78+
for k, v in filter_dependency_list(
79+
content.get('dependencies', {}).items()
80+
)
7681
}
7782
build_depends = {
7883
create_dependency_descriptor(k, v, path)
79-
for k, v in content.get('build-dependencies', {}).items()
80-
if k != name
84+
for k, v in filter_dependency_list(
85+
content.get('build-dependencies', {}).items()
86+
)
8187
}
8288
dev_depends = {
8389
create_dependency_descriptor(k, v, path)
84-
for k, v in content.get('dev-dependencies', {}).items()
85-
if k != name
90+
for k, v in filter_dependency_list(
91+
content.get('dev-dependencies', {}).items(),
92+
filter_out=package_name,
93+
)
8694
}
8795
return {
8896
'build': depends | build_depends | dev_depends,
8997
'run': depends | build_depends,
9098
}
9199

92100

93-
def create_dependency_descriptor(name, constraints, path):
101+
def filter_dependency_list(dependencies, filter_out=None):
102+
"""
103+
Filter dependency names.
104+
105+
Find the external names of dependencies and optionally filter out any that
106+
match a pattern. The filtering is used for dev-dependencies which may have
107+
the package itself as a dependency.
108+
109+
:param dependencies: The dictionary of every dependency and its constraints
110+
:param filter_out: The name of a dependency to filter out.
111+
:returns: The filtered dependency list with the external names as keys
112+
:rtype: dict(string, dict)
113+
"""
114+
filtered_dependencies = {}
115+
for dependency, constraints in dependencies:
116+
if isinstance(constraints, dict):
117+
dependency = constraints.get('package', dependency)
118+
119+
if dependency != filter_out:
120+
filtered_dependencies[dependency] = constraints
121+
122+
return filtered_dependencies.items()
123+
124+
125+
def create_dependency_descriptor(dependency_name, constraints, path):
94126
"""
95127
Create a dependency descriptor from a Cargo dependency specification.
96128
@@ -109,7 +141,6 @@ def create_dependency_descriptor(name, constraints, path):
109141
else:
110142
source = constraints.get('git') or \
111143
constraints.get('registry')
112-
name = constraints.get('package', name)
113144
else:
114145
source = None
115146
metadata = {
@@ -118,4 +149,4 @@ def create_dependency_descriptor(name, constraints, path):
118149
}
119150
# TODO: Interpret SemVer constraints and add appropriate constraint
120151
# metadata. Handling arbitrary wildcards will be non-trivial.
121-
return DependencyDescriptor(name, metadata=metadata)
152+
return DependencyDescriptor(dependency_name, metadata=metadata)

colcon_cargo/package_discovery/__init__.py

Whitespace-only changes.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Copyright 2025 Open Source Robotics Foundation, Inc.
2+
# Licensed under the Apache License, Version 2.0
3+
4+
from colcon_cargo.package_identification.cargo_workspace \
5+
import CargoWorkspaceIdentification
6+
from colcon_core.package_discovery import PackageDiscoveryExtensionPoint
7+
from colcon_core.package_identification import identify
8+
from colcon_core.package_identification import IgnoreLocationException
9+
from colcon_core.plugin_system import satisfies_version
10+
11+
12+
class CargoWorkspacePackageDiscovery(PackageDiscoveryExtensionPoint):
13+
"""Discover packages which are part of a cargo workspace."""
14+
15+
# the priority should be very low because we need to discover the
16+
# workspaces themselves before we can enumerate their sub-packages
17+
PRIORITY = 10
18+
19+
def __init__(self): # noqa: D107
20+
super().__init__()
21+
satisfies_version(
22+
PackageDiscoveryExtensionPoint.EXTENSION_POINT_VERSION,
23+
'^1.1')
24+
25+
def has_parameters(self, *, args): # noqa: D102
26+
return None
27+
28+
def discover(self, *, args, identification_extensions): # noqa: D102
29+
# Nested workspaces are not currently supported by cargo. If they ever
30+
# are, this code should be updated to run the whole process again until
31+
# no additional member package paths are found.
32+
33+
paths = set()
34+
for extensions_same_prio in identification_extensions.values():
35+
for extension in extensions_same_prio.values():
36+
if isinstance(extension, CargoWorkspaceIdentification):
37+
paths.update(extension.workspace_package_paths)
38+
extension.workspace_package_paths.clear()
39+
40+
descs = set()
41+
for path in paths:
42+
try:
43+
result = identify(identification_extensions, path)
44+
except IgnoreLocationException:
45+
continue
46+
if result:
47+
descs.add(result)
48+
return descs

colcon_cargo/package_identification/cargo.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,10 @@ def identify(self, metadata): # noqa: D102
3939
return
4040

4141
content = read_cargo_toml(cargo_toml)
42-
if 'workspace' in content:
43-
logger.debug(
44-
f'Ignoring unsupported Cargo Workspace at {metadata.path}')
42+
package = content.get('package', {})
43+
if not package:
4544
return
4645

47-
package = content.get('package', {})
4846
name = package.get('name')
4947
if not name and not metadata.name:
5048
raise RuntimeError(
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Copyright 2025 Open Source Robotics Foundation, Inc.
2+
# Licensed under the Apache License, Version 2.0
3+
4+
from colcon_cargo.package_identification.cargo import read_cargo_toml
5+
from colcon_core.package_identification import IgnoreLocationException
6+
from colcon_core.package_identification \
7+
import PackageIdentificationExtensionPoint
8+
from colcon_core.plugin_system import satisfies_version
9+
10+
11+
class CargoWorkspaceIdentification(PackageIdentificationExtensionPoint):
12+
"""
13+
Identify Cargo workspaces with `Cargo.toml` files.
14+
15+
This extension does not actually identify any packages per se, but is a
16+
necessary component for CargoWorkspacePackageDiscovery to function.
17+
"""
18+
19+
# This
20+
PRIORITY = 990
21+
22+
def __init__(self): # noqa: D107
23+
super().__init__()
24+
satisfies_version(
25+
PackageIdentificationExtensionPoint.EXTENSION_POINT_VERSION,
26+
'^1.0')
27+
self.workspace_package_paths = set()
28+
29+
def identify(self, metadata): # noqa: D102
30+
if metadata.type is not None and metadata.type != 'cargo':
31+
return
32+
33+
cargo_toml = metadata.path / 'Cargo.toml'
34+
if not cargo_toml.is_file():
35+
return
36+
37+
content = read_cargo_toml(cargo_toml)
38+
if 'workspace' not in content:
39+
return
40+
41+
ws_members = {
42+
member
43+
for pattern in content['workspace'].get('members', ())
44+
for member in metadata.path.glob(pattern)
45+
}
46+
ws_members.difference_update(
47+
exclude
48+
for pattern in content['workspace'].get('exclude', ())
49+
for exclude in metadata.path.glob(pattern)
50+
)
51+
self.workspace_package_paths.update(ws_members)
52+
53+
if 'package' not in content:
54+
# Prevent any further attempts to discover packages in this
55+
# directory and let the workspace dictate where to look for
56+
# packages later on
57+
raise IgnoreLocationException()

colcon_cargo/task/cargo/build.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,25 +79,26 @@ async def build( # noqa: D102
7979

8080
self.progress('build')
8181

82+
pkg = self.context.pkg
8283
rc = await run(
83-
self.context, cmd, cwd=self.context.pkg.path, env=env)
84+
self.context, cmd, cwd=pkg.path, env=env)
8485
if rc and rc.returncode:
8586
return rc.returncode
8687

8788
# colcon-ros-cargo overrides install command to return None.
8889
# We also need to check if the package has any binaries, because if it
8990
# has no binaries then cargo install will return an error.
9091
cmd = self._install_cmd(cargo_args)
91-
if cmd is not None and self._has_binaries(metadata):
92+
if cmd is not None and self._has_binaries(metadata, pkg.name):
9293
self.progress('install')
9394
rc = await run(
94-
self.context, cmd, cwd=self.context.pkg.path, env=env)
95+
self.context, cmd, cwd=pkg.path, env=env)
9596
if rc and rc.returncode:
9697
return rc.returncode
9798

9899
if not skip_hook_creation:
99100
create_environment_scripts(
100-
self.context.pkg, args, additional_hooks=additional_hooks)
101+
pkg, args, additional_hooks=additional_hooks)
101102

102103
# Overridden by colcon-ros-cargo
103104
def _prepare(self, env, additional_hooks):
@@ -111,10 +112,12 @@ def _prepare(self, env, additional_hooks):
111112
# Overridden by colcon-ros-cargo
112113
def _build_cmd(self, cargo_args):
113114
args = self.context.args
115+
pkg = self.context.pkg
114116
cmd = [
115117
CARGO_EXECUTABLE,
116118
'build',
117119
'--quiet',
120+
'--package', pkg.name,
118121
'--target-dir', args.build_base,
119122
]
120123
if not any(
@@ -177,8 +180,13 @@ async def _get_metadata(self, env):
177180

178181
# Identify if there are any binaries to install for the current package
179182
@staticmethod
180-
def _has_binaries(metadata):
183+
def _has_binaries(metadata, package_name):
181184
for package in metadata.get('packages', {}):
185+
# If the package is part of a cargo workspace, the metadata
186+
# contains all members. We're only interested in our target
187+
# package - ignore the other workspace members here.
188+
if package.get('name') != package_name:
189+
continue
182190
for target in package.get('targets', {}):
183191
for kind in target.get('kind', {}):
184192
if kind == 'bin':

colcon_cargo/task/cargo/test.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,23 +93,26 @@ async def test(self, *, additional_hooks=None): # noqa: D102
9393

9494
def _test_cmd(self, cargo_args):
9595
args = self.context.args
96+
pkg = self.context.pkg
9697
return [
9798
CARGO_EXECUTABLE,
9899
'test',
99100
'--quiet',
100-
'--target-dir',
101-
args.build_base,
101+
'--package', pkg.name,
102+
'--target-dir', args.build_base,
102103
] + cargo_args + [
103104
'--',
104105
'--color=never',
105106
]
106107

107108
# Ignore cargo args for rustfmt
108109
def _fmt_cmd(self):
110+
pkg = self.context.pkg
109111
return [
110112
CARGO_EXECUTABLE,
111113
'fmt',
112114
'--check',
115+
'--package', pkg.name,
113116
'--',
114117
'--color=never',
115118
]

setup.cfg

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ keywords = colcon
2424
[options]
2525
python_requires = >=3.6
2626
install_requires =
27-
colcon-core
27+
colcon-core>=0.19.0
2828
# toml is also supported but deprecated
2929
tomli>=1.0.0; python_version < "3.11"
3030
packages = find:
@@ -67,8 +67,11 @@ colcon_argcomplete.argcomplete_completer =
6767
cargo_args = colcon_cargo.argcomplete_completer.cargo_args:CargoArgcompleteCompleter
6868
colcon_core.package_augmentation =
6969
cargo = colcon_cargo.package_augmentation.cargo:CargoPackageAugmentation
70+
colcon_core.package_discovery =
71+
cargo_workspace = colcon_cargo.package_discovery.cargo_workspace:CargoWorkspacePackageDiscovery
7072
colcon_core.package_identification =
7173
cargo = colcon_cargo.package_identification.cargo:CargoPackageIdentification
74+
cargo_workspace = colcon_cargo.package_identification.cargo_workspace:CargoWorkspaceIdentification
7275
colcon_core.task.build =
7376
cargo = colcon_cargo.task.cargo.build:CargoBuildTask
7477
colcon_core.task.test =

stdeb.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[colcon-cargo]
22
No-Python2:
3-
Depends3: python3-colcon-core, python3 (>= 3.11) | python3-tomli (>= 1) | python3-toml
3+
Depends3: python3-colcon-core (>= 0.19.0), python3 (>= 3.11) | python3-tomli (>= 1) | python3-toml
44
Suite: focal jammy noble bookworm trixie
55
X-Python3-Version: >= 3.6
66
Upstream-Version-Suffix: +upstream

test/rust-workspace/Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[package]
2+
name = "rust-workspace"
3+
version = "0.1.0"
4+
authors = ["Test<test@test.com>"]
5+
edition = "2018"
6+
7+
[workspace]
8+
members = ["workspace-mem*"]
9+
default-members = ["workspace-mem*"]

0 commit comments

Comments
 (0)