3737import tempfile
3838from glob import glob
3939from pathlib import Path
40+ from typing import Dict , List , Union
4041
4142import easybuild .tools .environment as env
4243import easybuild .tools .systemtools as systemtools
4344from easybuild .framework .easyconfig import CUSTOM
4445from easybuild .framework .extensioneasyblock import ExtensionEasyBlock
46+ from easybuild .tools import LooseVersion
4547from easybuild .tools .build_log import EasyBuildError , print_warning
4648from easybuild .tools .config import build_option
4749from easybuild .tools .filetools import CHECKSUM_TYPE_SHA256 , compute_checksum , copy_dir , extract_file , mkdir
4850from easybuild .tools .filetools import read_file , remove_dir , write_file , which
51+ from easybuild .tools .modules import get_software_version
4952from easybuild .tools .run import run_shell_cmd
5053from easybuild .tools .toolchain .compiler import OPTARCH_GENERIC
5154
5255CRATESIO_SOURCE = "https://crates.io/api/v1/crates"
56+ CRATES_REGISTRY_URL = 'registry+https://github.com/rust-lang/crates.io-index'
5357
5458CONFIG_TOML_SOURCE_VENDOR = """
5559[source.vendored-sources]
7579replace-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+
7890CARGO_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+
136283def 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
0 commit comments