99import shutil
1010import subprocess
1111import sys
12+ import time
1213from pathlib import Path
1314from typing import Dict # noqa: F401
1415
2728 'PIXI_ENVIRONMENT_NAME' ,
2829 'PIXI_IN_SHELL' ,
2930)
31+ BOOTSTRAP_SETUPTOOLS_SPEC = '>=60'
32+ BOOTSTRAP_WHEEL_SPEC = '*'
3033
3134
3235def check_call (
@@ -210,6 +213,39 @@ def check_call(
210213 return result
211214
212215
216+ def check_call_with_retries (
217+ commands ,
218+ log_filename ,
219+ quiet ,
220+ * ,
221+ retries = 3 ,
222+ retry_delay = 2.0 ,
223+ ):
224+ """Run a command with a few retries for transient pixi/network failures."""
225+
226+ last_error = None
227+ for attempt in range (1 , retries + 1 ):
228+ try :
229+ return check_call (commands , log_filename , quiet )
230+ except subprocess .CalledProcessError as exc :
231+ last_error = exc
232+ if attempt >= retries :
233+ raise
234+
235+ message = (
236+ f'Command failed on attempt { attempt } /{ retries } ; '
237+ f'retrying in { retry_delay :.0f} s...\n '
238+ )
239+ with open (log_filename , 'a' , encoding = 'utf-8' ) as log_file :
240+ log_file .write (message )
241+ if not quiet :
242+ print (message )
243+ time .sleep (retry_delay )
244+
245+ if last_error is not None :
246+ raise last_error
247+
248+
213249def build_pixi_shell_hook_prefix (* , pixi_exe : str , pixi_toml : str ) -> str :
214250 """Build a shell prefix to activate a pixi env in the current shell.
215251
@@ -369,14 +405,15 @@ def _run(log_filename):
369405 _copy_mache_pixi_toml (
370406 dest_pixi_toml = pixi_toml_path ,
371407 source_repo_dir = Path ('deploy_tmp/build_mache/mache' ),
408+ python_version = args .python ,
372409 )
373410
374411 cmd_install = (
375412 f'cd "{ bootstrap_dir } " && '
376413 f'{ build_pixi_env_unset_prefix ()} '
377414 f'"{ pixi_exe } " install'
378415 )
379- check_call (cmd_install , log_filename , quiet )
416+ check_call_with_retries (cmd_install , log_filename , quiet )
380417
381418 pixi_toml = str (pixi_toml_path .resolve ())
382419 pixi_shell_hook_prefix = build_pixi_shell_hook_prefix (
@@ -403,7 +440,7 @@ def _run(log_filename):
403440 f'{ build_pixi_env_unset_prefix ()} '
404441 f'"{ pixi_exe } " install'
405442 )
406- check_call (cmd_install , log_filename , quiet )
443+ check_call_with_retries (cmd_install , log_filename , quiet )
407444
408445
409446def _parse_args ():
@@ -735,13 +772,63 @@ def _format_pixi_version_specifier(version: str) -> str:
735772 return f'=={ normalized } '
736773
737774
738- def _copy_mache_pixi_toml (* , dest_pixi_toml , source_repo_dir ):
775+ def _copy_mache_pixi_toml (* , dest_pixi_toml , source_repo_dir , python_version ):
739776 src = Path (source_repo_dir ) / 'pixi.toml'
740777 if not src .is_file ():
741778 raise RuntimeError (
742779 f'Expected mache pixi.toml not found in cloned repo: { src } '
743780 )
744- shutil .copyfile (str (src ), str (dest_pixi_toml ))
781+ source_text = src .read_text (encoding = 'utf-8' )
782+ channels = _get_pixi_channels_from_text (source_text )
783+ dependencies = _get_pixi_dependencies_from_text (source_text )
784+
785+ _write_bootstrap_pixi_toml_with_local_source (
786+ pixi_toml_path = Path (dest_pixi_toml ),
787+ channels = channels ,
788+ dependencies = dependencies ,
789+ python_version = python_version ,
790+ )
791+
792+
793+ def _write_bootstrap_pixi_toml_with_local_source (
794+ * ,
795+ pixi_toml_path : Path ,
796+ channels : list [str ],
797+ dependencies : dict [str , str ],
798+ python_version : str ,
799+ ) -> None :
800+ """Write a slim bootstrap pixi manifest for a local mache source tree.
801+
802+ The full repo ``pixi.toml`` may contain CI-only features, named
803+ environments, and multiple platforms. The bootstrap environment only needs
804+ the current platform, the requested Python, and the runtime/build
805+ dependencies required to install the local mache checkout.
806+ """
807+
808+ merged_channels = channels [:] if channels else ['conda-forge' ]
809+ merged_dependencies = dict (dependencies )
810+ merged_dependencies .setdefault ('pip' , '*' )
811+ merged_dependencies .setdefault ('rattler-build' , '*' )
812+ merged_dependencies .setdefault ('setuptools' , BOOTSTRAP_SETUPTOOLS_SPEC )
813+ merged_dependencies .setdefault ('wheel' , BOOTSTRAP_WHEEL_SPEC )
814+
815+ lines = [
816+ '[workspace]' ,
817+ 'name = "mache-bootstrap-local"' ,
818+ f'channels = [{ ", " .join (json .dumps (c ) for c in merged_channels )} ]' ,
819+ f'platforms = ["{ _get_pixi_platform ()} "]' ,
820+ 'channel-priority = "strict"' ,
821+ '' ,
822+ '[dependencies]' ,
823+ f'python = "{ python_version } .*"' ,
824+ ]
825+
826+ for name , spec in merged_dependencies .items ():
827+ if name == 'python' :
828+ continue
829+ lines .append (f'{ name } = { json .dumps (spec )} ' )
830+
831+ pixi_toml_path .write_text ('\n ' .join (lines ) + '\n ' , encoding = 'utf-8' )
745832
746833
747834def merge_pixi_toml_dependencies (
@@ -767,10 +854,7 @@ def merge_pixi_toml_dependencies(
767854
768855 source_text = source_pixi_toml .read_text (encoding = 'utf-8' )
769856 channels = _get_pixi_channels_from_text (source_text )
770- dependencies = _get_pixi_dependencies_from_text (
771- source_text ,
772- python_version = python_version ,
773- )
857+ dependencies = _get_pixi_dependencies_from_text (source_text )
774858
775859 target_path = Path (target_pixi_toml )
776860 text = target_path .read_text (encoding = 'utf-8' )
@@ -793,28 +877,24 @@ def _get_pixi_channels_from_text(source_text):
793877 return re .findall (r'"([^"]+)"' , match .group (1 ))
794878
795879
796- def _get_pixi_dependencies_from_text (source_text , * , python_version ):
880+ def _get_pixi_dependencies_from_text (source_text ):
797881 dependencies = {} # type: Dict[str, str]
798882
799- for table in (
800- 'dependencies' ,
801- f'feature.py{ python_version .replace ("." , "" )} .dependencies' ,
883+ section = _find_toml_table_block (text = source_text , table = 'dependencies' )
884+ if section is None :
885+ return dependencies
886+
887+ start , end = section
888+ section_text = source_text [start :end ]
889+ for match in re .finditer (
890+ r'(?m)^([A-Za-z0-9_.-]+)\s*=\s*"([^"\n]+)"\s*$' ,
891+ section_text ,
802892 ):
803- section = _find_toml_table_block (text = source_text , table = table )
804- if section is None :
893+ name = match .group (1 )
894+ spec = match .group (2 )
895+ if name == 'python' :
805896 continue
806-
807- start , end = section
808- section_text = source_text [start :end ]
809- for match in re .finditer (
810- r'(?m)^([A-Za-z0-9_.-]+)\s*=\s*"([^"\n]+)"\s*$' ,
811- section_text ,
812- ):
813- name = match .group (1 )
814- spec = match .group (2 )
815- if name == 'python' :
816- continue
817- dependencies .setdefault (name , spec )
897+ dependencies .setdefault (name , spec )
818898
819899 return dependencies
820900
@@ -925,20 +1005,10 @@ def _clone_mache_repo(
9251005 f'Local mache source override does not exist: { source_repo } '
9261006 )
9271007
928- try :
929- os .symlink (source_repo , repo_dir , target_is_directory = True )
930- except OSError :
931- shutil .copytree (
932- source_repo ,
933- repo_dir ,
934- ignore = shutil .ignore_patterns (
935- '.git' ,
936- '.pixi' ,
937- 'deploy_tmp' ,
938- '__pycache__' ,
939- '*.pyc' ,
940- ),
941- )
1008+ _copy_local_mache_source_snapshot (
1009+ source_repo = source_repo ,
1010+ repo_dir = repo_dir ,
1011+ )
9421012 return
9431013
9441014 commands = (
@@ -950,5 +1020,50 @@ def _clone_mache_repo(
9501020 check_call (commands , log_filename , quiet )
9511021
9521022
1023+ def _copy_local_mache_source_snapshot (
1024+ * , source_repo : Path , repo_dir : Path
1025+ ) -> None :
1026+ """Copy a clean local source snapshot for bootstrap installs.
1027+
1028+ Using the live developer worktree directly can pull in untracked files or
1029+ local symlinks that break setuptools packaging. Prefer a tracked-file
1030+ snapshot when the source is a git checkout, and fall back to a filtered
1031+ copytree otherwise.
1032+ """
1033+
1034+ try :
1035+ tracked = subprocess .check_output (
1036+ ['git' , '-C' , str (source_repo ), 'ls-files' , '-z' ],
1037+ text = False ,
1038+ )
1039+ except (OSError , subprocess .CalledProcessError ):
1040+ shutil .copytree (
1041+ source_repo ,
1042+ repo_dir ,
1043+ ignore = shutil .ignore_patterns (
1044+ '.git' ,
1045+ '.pixi' ,
1046+ 'deploy_tmp' ,
1047+ '__pycache__' ,
1048+ '*.pyc' ,
1049+ ),
1050+ )
1051+ return
1052+
1053+ repo_dir .mkdir (parents = True , exist_ok = True )
1054+ for raw_path in tracked .split (b'\x00 ' ):
1055+ if not raw_path :
1056+ continue
1057+ rel_path = Path (raw_path .decode ('utf-8' ))
1058+ src_path = source_repo / rel_path
1059+ dest_path = repo_dir / rel_path
1060+ dest_path .parent .mkdir (parents = True , exist_ok = True )
1061+
1062+ if src_path .is_symlink ():
1063+ os .symlink (os .readlink (src_path ), dest_path )
1064+ else :
1065+ shutil .copy2 (src_path , dest_path )
1066+
1067+
9531068if __name__ == '__main__' :
9541069 main ()
0 commit comments