Skip to content

Commit de3fe7d

Browse files
committed
Create lockfile for Cargo package if missing
1 parent 979974c commit de3fe7d

File tree

2 files changed

+214
-33
lines changed

2 files changed

+214
-33
lines changed

easybuild/easyblocks/generic/cargo.py

Lines changed: 209 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,23 @@
3737
import tempfile
3838
from glob import glob
3939
from pathlib import Path
40+
from typing import Dict, List, Union
4041

4142
import easybuild.tools.environment as env
4243
import easybuild.tools.systemtools as systemtools
4344
from easybuild.framework.easyconfig import CUSTOM
4445
from easybuild.framework.extensioneasyblock import ExtensionEasyBlock
46+
from easybuild.tools import LooseVersion
4547
from easybuild.tools.build_log import EasyBuildError, print_warning
4648
from easybuild.tools.config import build_option
4749
from easybuild.tools.filetools import CHECKSUM_TYPE_SHA256, compute_checksum, copy_dir, extract_file, mkdir
4850
from easybuild.tools.filetools import read_file, remove_dir, write_file, which
51+
from easybuild.tools.modules import get_software_version
4952
from easybuild.tools.run import run_shell_cmd
5053
from easybuild.tools.toolchain.compiler import OPTARCH_GENERIC
5154

5255
CRATESIO_SOURCE = "https://crates.io/api/v1/crates"
56+
CRATES_REGISTRY_URL = 'registry+https://github.com/rust-lang/crates.io-index'
5357

5458
CONFIG_TOML_SOURCE_VENDOR = """
5559
[source.vendored-sources]
@@ -75,22 +79,147 @@
7579
replace-with = "vendored-sources"
7680
"""
7781

82+
CONFIG_LOCK_SOURCE = """
83+
[[package]]
84+
name = "{name}"
85+
version = "{version}"
86+
source = "{source}"
87+
# checksum intentionally not set
88+
"""
89+
7890
CARGO_CHECKSUM_JSON = '{{"files": {{}}, "package": "{checksum}"}}'
7991

8092

81-
def get_workspace_members(crate_dir: Path):
82-
"""Find all members of a cargo workspace in crate_dir.
93+
def parse_toml_list(value: str) -> List[str]:
94+
"""Split a TOML list value"""
95+
if not value.startswith('[') or not value.endswith(']'):
96+
raise ValueError(f"'{value}' is not a TOML list")
97+
value = value[1:-1].strip()
98+
simple_str_markers = ('"""', "'''", "'")
99+
current_value = ''
100+
result = []
101+
while value:
102+
for marker in simple_str_markers:
103+
if value.startswith(marker):
104+
idx = value.index(marker, len(marker))
105+
current_value += value[:idx + len(marker)]
106+
value = value[idx + len(marker):].lstrip()
107+
break
108+
else:
109+
if value.startswith('"'):
110+
m = re.match(r'".*?(?<!\\)"', value, re.M)
111+
current_value += m[0]
112+
value = value[m.end():].lstrip()
113+
# Not inside a string here
114+
if value.startswith(','):
115+
result.append(current_value)
116+
current_value = ''
117+
value = value[1:].lstrip()
118+
else:
119+
m = re.search('"|\'|,', value)
120+
if m:
121+
current_value += value[:m.start()].strip()
122+
value = value[m.end():]
123+
else:
124+
current_value += value.strip()
125+
break
126+
if current_value:
127+
result.append(current_value)
128+
return result
129+
130+
131+
def _clean_line(line: str, expected_end: Union[str, None]) -> str:
132+
"""Remove comments and trim line"""
133+
if '#' not in line:
134+
return line.strip()
135+
if expected_end is not None and expected_end[0] in ("'", '"'):
136+
try:
137+
idx = line.index(expected_end) + len(expected_end)
138+
except ValueError:
139+
return line.strip() # Ignore #-sign in multi-line string
140+
else:
141+
idx = 0
142+
in_str = False
143+
escaped = False
144+
while idx < len(line):
145+
c = line[idx]
146+
if in_str:
147+
if escaped:
148+
if c == '\\':
149+
escaped = False
150+
elif c == '"':
151+
in_str = False
152+
elif c == '\\':
153+
escaped = True
154+
elif c == '#':
155+
break
156+
elif c == '"':
157+
in_str = True
158+
elif c == "'":
159+
try:
160+
idx = line.index("'", idx + 1)
161+
except ValueError:
162+
idx = len(line)
163+
idx += 1
164+
return line[:idx].strip()
165+
166+
167+
def parse_toml(file_or_content: Union[Path, str]) -> Dict[str, str]:
168+
"""Minimally parse a TOML file into sections, keys and values
169+
170+
Values will be the raw strings (including quotes for string-typed values)"""
171+
172+
result: Dict[str, Union[str, List[str]]] = {}
173+
pending_key = None
174+
pending_value = None
175+
expected_end = None
176+
current_section = None
177+
content = read_file(file_or_content) if isinstance(file_or_content, Path) else file_or_content
178+
line_num = raw_line = None
179+
start_end = {
180+
'[': ']',
181+
'{': '}',
182+
'"""': '"""',
183+
"'''": "'''",
184+
}
185+
try:
186+
for line_num, raw_line in enumerate(content.splitlines()): # noqa B007: line_num used in error only
187+
line: str = _clean_line(raw_line, expected_end)
188+
if not line:
189+
continue
190+
if pending_key is None and line.startswith("[") and line.endswith("]"):
191+
current_section = line.strip()[1:-1].strip()
192+
result.setdefault(current_section, {})
193+
continue
194+
if pending_key is None:
195+
key, val = line.split("=", 1)
196+
pending_key = key.strip()
197+
pending_value = val.strip()
198+
for start, end in start_end.items():
199+
if pending_value.startswith(start):
200+
expected_end = end
201+
break
202+
else:
203+
expected_end = None
204+
else:
205+
pending_value += '\n' + line
206+
if expected_end is None or (pending_value != expected_end and pending_value.endswith(expected_end)):
207+
result[current_section][pending_key] = pending_value.strip()
208+
pending_key = None
209+
except Exception as e:
210+
raise ValueError(f'Failed to parse {file_or_content}, error {e} at line {line_num}: {raw_line}')
211+
return result
212+
83213

84-
(Minimally) parse the Cargo.toml file.
214+
def get_workspace_members(cargo_toml: Dict[str, str]):
215+
"""Find all members of a cargo workspace in the parsed the Cargo.toml file.
85216
86217
Return a tuple: (has_package, workspace-members).
87218
has_package determines if it is a virtual workspace ([workspace] and no [package])
88219
workspace-members are all members (subfolder names) if it is a workspace, otherwise None
89220
"""
90-
cargo_toml = crate_dir / 'Cargo.toml'
91-
lines = [line.strip() for line in read_file(cargo_toml).splitlines()]
92221
# A virtual (workspace) manifest has no [package], but only a [workspace] section.
93-
has_package = '[package]' in lines
222+
has_package = 'package' in cargo_toml
94223

95224
# We are looking for this:
96225
# [workspace]
@@ -101,30 +230,15 @@ def get_workspace_members(crate_dir: Path):
101230
# ]
102231

103232
try:
104-
start_idx = lines.index('[workspace]')
105-
except ValueError:
233+
workspace = cargo_toml['workspace']
234+
except KeyError:
106235
return has_package, None
107-
# Find "members = [" and concatenate the value, stop at end of section or file
108-
member_str = None
109-
for line in lines[start_idx + 1:]:
110-
if line.startswith('#'):
111-
continue # Skip comments
112-
if re.match(r'\[\w+\]', line):
113-
break # New section
114-
if member_str is None:
115-
m = re.match(r'members\s+=\s+\[', line)
116-
if m:
117-
member_str = line[m.end():]
118-
else:
119-
member_str += line
120-
# Stop if we reach the end of the list
121-
if member_str is not None and member_str.endswith(']'):
122-
member_str = member_str[:-1]
123-
break
124-
if member_str is None:
236+
try:
237+
member_strs = parse_toml_list(workspace['members'])
238+
except (KeyError, ValueError):
125239
raise EasyBuildError('Failed to find members in %s', cargo_toml)
126-
# Split at commas after removing possibly trailing ones and remove the quotes
127-
members = [member.strip().strip('"') for member in member_str.rstrip(',').split(',')]
240+
# Remove the quotes
241+
members = [member.strip('"') for member in member_strs]
128242
# Sanity check that we didn't pick up anything unexpected
129243
invalid_members = [member for member in members if not re.match(r'(\w|-)+', member)]
130244
if invalid_members:
@@ -133,6 +247,39 @@ def get_workspace_members(crate_dir: Path):
133247
return has_package, members
134248

135249

250+
def merge_sub_crate(cargo_toml_path: Path, workspace_toml: Dict[str, str]):
251+
"""Resolve workspace references in the Cargo.toml file"""
252+
# Lines such as 'authors.workspace = true' must be replaced by 'authors = <value from workspace.package>'
253+
content: str = read_file(cargo_toml_path)
254+
SUFFIX = '.workspace'
255+
if 'workspace = true' not in content:
256+
return
257+
cargo_toml = parse_toml(content)
258+
lines = content.splitlines()
259+
260+
def do_replacement(section, workspace_section):
261+
if not section or not workspace_section:
262+
return
263+
264+
for key, value in section.items():
265+
if (key.endswith(SUFFIX) and value == 'true') or value == '{ workspace = true }':
266+
real_key = key[:-len(SUFFIX)] if key.endswith(SUFFIX) else key
267+
new_value = workspace_section[real_key]
268+
try:
269+
idx = next(idx for idx, line in enumerate(lines)
270+
if line.lstrip().startswith(f'{key} =') and value in line)
271+
except StopIteration:
272+
raise ValueError(f"Failed to find line for key '{key}' while merging {cargo_toml_path}")
273+
lines[idx] = f'{real_key} = {new_value}'
274+
275+
do_replacement(cargo_toml.get('package'), workspace_toml.get('workspace.package'))
276+
do_replacement(cargo_toml.get('dependencies'), workspace_toml.get('workspace.dependencies'))
277+
do_replacement(cargo_toml.get('build-dependencies'), workspace_toml.get('workspace.dependencies'))
278+
do_replacement(cargo_toml.get('dev-dependencies'), workspace_toml.get('workspace.dependencies'))
279+
280+
write_file(cargo_toml_path, '\n'.join(lines))
281+
282+
136283
def get_checksum(src, log):
137284
"""Get the checksum from an extracted source"""
138285
checksum = src['checksum']
@@ -354,7 +501,8 @@ def _setup_offline_config(self, git_sources):
354501
tmp_dir = Path(tempfile.mkdtemp(dir=self.builddir, prefix='tmp_crate_'))
355502
# Add checksum file for each crate such that it is recognized by cargo.
356503
# Glob to catch multiple folders in a source archive.
357-
for crate_dir in (p.parent for p in Path(self.vendor_dir).glob('*/Cargo.toml')):
504+
for cargo_toml in Path(self.vendor_dir).glob('*/Cargo.toml'):
505+
crate_dir = cargo_toml.parent
358506
src = path_to_source.get(str(crate_dir))
359507
if src:
360508
try:
@@ -372,7 +520,8 @@ def _setup_offline_config(self, git_sources):
372520
# otherwise (Only "[workspace]" section and no "[package]" section)
373521
# we have to remove the top-level folder or cargo fails with:
374522
# "found a virtual manifest at [...]Cargo.toml instead of a package manifest"
375-
has_package, members = get_workspace_members(crate_dir)
523+
parsed_toml = parse_toml(cargo_toml)
524+
has_package, members = get_workspace_members(parsed_toml)
376525
if members:
377526
self.log.info(f'Found workspace in {crate_dir}. Members: ' + ', '.join(members))
378527
if not any((crate_dir / crate).is_dir() for crate in members):
@@ -397,6 +546,8 @@ def _setup_offline_config(self, git_sources):
397546
# Use copy_dir to resolve symlinks that might point to the parent folder
398547
copy_dir(tmp_crate_dir / member, target_path, symlinks=False)
399548
cargo_pkg_dirs.append(target_path)
549+
self.log.info(f'Resolving workspace values for crate {member}')
550+
merge_sub_crate(target_path / 'Cargo.toml', parsed_toml)
400551
if has_package:
401552
# Remove the copied crate folders
402553
for member in members:
@@ -470,8 +621,33 @@ def prepare_step(self, *args, **kwargs):
470621
self.set_cargo_vars()
471622

472623
def configure_step(self):
473-
"""Empty configuration step."""
474-
pass
624+
"""Create lockfile if it doesn't exist"""
625+
cargo_lock = 'Cargo.lock'
626+
if self.crates and os.path.exists('Cargo.toml') and not os.path.exists(cargo_lock):
627+
root_toml = run_shell_cmd('cargo locate-project --message-format=plain --workspace').output
628+
cargo_lock_path = os.path.join(os.path.dirname(root_toml), cargo_lock)
629+
if not os.path.exists(cargo_lock_path):
630+
rust_version = LooseVersion(get_software_version('Rust'))
631+
# File format version, oldest supported used for compatibility
632+
if rust_version <= '1.37':
633+
version = 1
634+
elif rust_version <= '1.81':
635+
version = 2
636+
else:
637+
version = 3
638+
# Use vendored crates to ensure those versions are used
639+
self.log.info(f"No {cargo_lock} file found, creating one at {cargo_lock_path}")
640+
content = f'version = {version}\n'
641+
for crate_info in self.crates:
642+
if len(crate_info) == 2:
643+
name, version = crate_info
644+
source = CRATES_REGISTRY_URL
645+
else:
646+
name, version, repo, rev = crate_info
647+
source = f'git+{repo}?rev={rev}#{rev}'
648+
649+
content += CONFIG_LOCK_SOURCE.format(name=name, version=version, source=source)
650+
write_file(cargo_lock_path, content)
475651

476652
@property
477653
def profile(self):
@@ -561,7 +737,7 @@ def generate_crate_list(sourcedir):
561737
if name == app_name:
562738
app_in_cratesio = True # exclude app itself, needs to be first in crates list or taken from pypi
563739
else:
564-
if source_url == 'registry+https://github.com/rust-lang/crates.io-index':
740+
if source_url == CRATES_REGISTRY_URL:
565741
crates.append((name, version))
566742
else:
567743
# Lock file has revision and branch in the url

easybuild/easyblocks/generic/cargopythonpackage.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,8 @@ def extra_options(extra_vars=None):
5050
def extract_step(self):
5151
"""Specifically use the overloaded variant from Cargo as is populates vendored sources with checksums."""
5252
return Cargo.extract_step(self)
53+
54+
def configure_step(self):
55+
"""Run configure for Cargo and PythonPackage"""
56+
Cargo.configure_step(self)
57+
PythonPackage.configure_step(self)

0 commit comments

Comments
 (0)