66 AutomaticInstallDisabledError ,
77 HashMismatchError ,
88 FilesInUseError ,
9+ InvalidPackageFileError ,
910 NoInstallFoundError ,
1011)
1112from .fsutils import ensure_tree , rmtree , unlink
1213from .indexutils import Index
1314from .logging import CONSOLE_MAX_WIDTH , LOGGER , ProgressPrinter , VERBOSE
1415from .pathutils import Path , PurePath
15- from .tagutils import install_matches_any , tag_or_range
16+ from .tagutils import CompanyTag , install_matches_any , tag_or_range
1617from .urlutils import (
18+ is_valid_url ,
1719 sanitise_url ,
1820 urlopen as _urlopen ,
1921 urlretrieve as _urlretrieve ,
@@ -391,20 +393,36 @@ def print_cli_shortcuts(cmd):
391393 LOGGER .info ("Installed %s to %s" , i ["display-name" ], i ["prefix" ])
392394
393395
396+ def read_bundled_install (package ):
397+ import zipfile
398+ with zipfile .ZipFile (package , "r" ) as zf :
399+ return json .loads (zf .read ("__install__.json" ))
400+
401+
394402def _same_install (i , j ):
395403 return i ["id" ] == j ["id" ] and i ["sort-version" ] == j ["sort-version" ]
396404
397405
398406def _find_one (cmd , source , tag , * , installed = None , by_id = False ):
407+ install = None
399408 if by_id :
400409 LOGGER .debug ("Searching for Python with ID %s" , tag )
410+ elif isinstance (tag , Path ):
411+ LOGGER .verbose ("Using package from %s" , tag )
412+ try :
413+ install = read_bundled_install (tag )
414+ install ["url" ] = tag .as_uri ()
415+ install ["source" ] = ""
416+ except (KeyError , OSError ) as ex :
417+ raise InvalidPackageFileError (tag ) from ex
401418 elif tag :
402419 LOGGER .verbose ("Searching for Python matching %s" , tag )
403420 else :
404421 LOGGER .verbose ("Searching for default Python version" )
405422
406- downloader = IndexDownloader (source , Index , {}, DOWNLOAD_CACHE )
407- install = select_package (downloader , tag , cmd .default_platform , by_id = by_id )
423+ if not install :
424+ downloader = IndexDownloader (source , Index , {}, DOWNLOAD_CACHE )
425+ install = select_package (downloader , tag , cmd .default_platform , by_id = by_id )
408426
409427 if by_id :
410428 return install
@@ -442,7 +460,10 @@ def _find_one(cmd, source, tag, *, installed=None, by_id=False):
442460
443461
444462def _download_one (cmd , source , install , download_dir , * , must_copy = False ):
445- package = download_dir / f"{ install ['id' ]} -{ install ['sort-version' ]} .zip"
463+ if "id" in install and "sort-version" in install :
464+ package = download_dir / f"{ install ['id' ]} -{ install ['sort-version' ]} .zip"
465+ else :
466+ package = download_dir / PurePath (install ["url" ]).name
446467 # Preserve nupkg extensions so we can directly reference Nuget packages
447468 if install ["url" ].casefold ().endswith (".nupkg" .casefold ()):
448469 package = package .with_suffix (".nupkg" )
@@ -620,7 +641,7 @@ def _install_one(cmd, source, install, *, target=None):
620641 install ["shortcuts" ] = shortcuts
621642
622643 install ["url" ] = sanitise_url (install ["url" ])
623- if source != cmd .fallback_source :
644+ if "source" not in install and source != cmd .fallback_source :
624645 install ["source" ] = sanitise_url (source )
625646
626647 LOGGER .debug ("Write __install__.json to %s" , dest )
@@ -668,6 +689,18 @@ def _fatal_install_error(cmd, ex):
668689 raise SystemExit (getattr (ex , "winerror" , getattr (ex , "errno" , 0 )) or 1 ) from ex
669690
670691
692+ def _as_local_file (cmd , arg ):
693+ if is_valid_url (arg ):
694+ install = {"id" : arg , "url" : arg }
695+ return _download_one (cmd , None , install , cmd .download_dir )
696+ try :
697+ p = Path (arg )
698+ if p .is_absolute () and p .exists ():
699+ return p
700+ except (OSError , ValueError ):
701+ pass
702+
703+
671704def execute (cmd ):
672705 LOGGER .debug ("BEGIN install_command.execute: %r" , cmd .args )
673706
@@ -702,33 +735,48 @@ def execute(cmd):
702735
703736 download_index = {"versions" : []}
704737
738+ # Either tag, range, or Path, referencing the package to install.
739+ to_install = []
740+ # cmd.tags will have tags or ranges to filter things we're installing now.
741+ cmd .tags = []
742+
705743 if not cmd .by_id :
706744 for arg in cmd .args :
707745 if arg .casefold () == "default" .casefold ():
708746 LOGGER .debug ("Replacing 'default' with '%s'" , cmd .default_install_tag )
709- cmd .tags .append (tag_or_range (cmd .default_install_tag ))
747+ tag = tag_or_range (cmd .default_install_tag )
748+ cmd .tags .append (tag )
749+ to_install .append (tag )
750+ elif f := _as_local_file (cmd , arg ):
751+ # Will update cmd.tags later
752+ to_install .append (f )
710753 else :
711754 try :
712- cmd .tags .append (tag_or_range (arg ))
755+ tag = tag_or_range (arg )
756+ cmd .tags .append (tag )
757+ to_install .append (tag )
713758 except ValueError as ex :
714759 LOGGER .warn ("%s" , ex )
715760
716- if not cmd .tags and cmd .automatic :
717- cmd .tags = [tag_or_range (cmd .default_install_tag )]
761+ if not to_install and cmd .automatic :
762+ tag = tag_or_range (cmd .default_install_tag )
763+ cmd .tags .append (tag )
764+ to_install .append (tag )
718765 else :
719766 if cmd .from_script :
720767 raise ArgumentError ("Cannot use --by-id and --from-script together" )
721- cmd .tags = [arg .casefold () for arg in cmd .args ]
722- if not cmd .tags :
768+ tags = [arg .casefold () for arg in cmd .args ]
769+ cmd .tags .extend (tags )
770+ to_install .extend (tags )
771+ if not to_install :
723772 raise ArgumentError ("One or more IDs are required with --by-id" )
724773
725-
726774 try :
727775 if cmd .target :
728- if len (cmd . tags ) > 1 :
776+ if len (to_install ) > 1 :
729777 raise ArgumentError ("Unable to install multiple versions with --target" )
730778 try :
731- tag = cmd . tags [0 ]
779+ tag = to_install [0 ]
732780 except IndexError :
733781 if cmd .default_install_tag :
734782 LOGGER .debug ("No tags provided, installing default tag %s" , cmd .default_install_tag )
@@ -769,10 +817,9 @@ def execute(cmd):
769817 spec = find_install_from_script (cmd , cmd .from_script )
770818 except LookupError :
771819 spec = None
772- if spec :
773- cmd .tags .append (tag_or_range (spec ))
774- else :
775- cmd .tags .append (tag_or_range (cmd .default_install_tag ))
820+ tag = tag_or_range (spec if spec else cmd .default_install_tag )
821+ cmd .tags .append (tag )
822+ to_install .append (tag )
776823
777824 installed = list (cmd .get_installs ())
778825
@@ -784,13 +831,13 @@ def execute(cmd):
784831 installed = []
785832
786833 try :
787- if not cmd . tags :
834+ if not to_install :
788835 if cmd .repair :
789836 LOGGER .verbose ("No tags provided, repairing all installs:" )
790837 for install in installed :
791838 # Only try to redownload from the same source
792839 _install_one (cmd , install .get ('source' ), install )
793- # Fallthrough is safe - cmd.tags is empty
840+ # Fallthrough is safe - to_install is empty
794841 elif cmd .update :
795842 LOGGER .verbose ("No tags provided, updating all installs:" )
796843 for install in installed :
@@ -822,7 +869,7 @@ def execute(cmd):
822869 install ["company" ], install ["tag" ],
823870 install ["display-name" ],
824871 )
825- # Fallthrough is safe - cmd.tags is empty
872+ # Fallthrough is safe - to_install is empty
826873 else :
827874 raise ArgumentError ("Specify at least one tag to install, or 'default' for "
828875 "the latest recommended release." )
@@ -834,7 +881,7 @@ def execute(cmd):
834881 continue
835882 LOGGER .debug ("Searching %s" , source )
836883 try :
837- for tag in cmd . tags :
884+ for tag in to_install :
838885 install = _find_one (cmd , source , tag , installed = installed , by_id = cmd .by_id )
839886 if install :
840887 installs .append (install )
@@ -867,6 +914,9 @@ def execute(cmd):
867914 raise
868915 except NoInstallFoundError as ex :
869916 raise SystemExit (1 ) from ex
917+ except InvalidPackageFileError as ex :
918+ LOGGER .error ("%s" , ex )
919+ return _fatal_install_error (cmd , ex )
870920 except Exception as ex :
871921 return _fatal_install_error (cmd , ex )
872922
0 commit comments