Skip to content

Commit c607e7c

Browse files
committed
[WIP] Implement support for 'py install uri/path' to directly install package.
Fixes python#165
1 parent ae4e0a7 commit c607e7c

3 files changed

Lines changed: 82 additions & 22 deletions

File tree

src/manage/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,9 @@ def __init__(self):
7575
class FilesInUseError(Exception):
7676
def __init__(self, files):
7777
self.files = files
78+
79+
80+
class InvalidPackageFileError(Exception):
81+
def __init__(self, source):
82+
super().__init__(f"File at {source} is not a valid package. "
83+
"See the log file for additional information.")

src/manage/install_command.py

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@
66
AutomaticInstallDisabledError,
77
HashMismatchError,
88
FilesInUseError,
9+
InvalidPackageFileError,
910
NoInstallFoundError,
1011
)
1112
from .fsutils import ensure_tree, rmtree, unlink
1213
from .indexutils import Index
1314
from .logging import CONSOLE_MAX_WIDTH, LOGGER, ProgressPrinter, VERBOSE
1415
from .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
1617
from .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+
394402
def _same_install(i, j):
395403
return i["id"] == j["id"] and i["sort-version"] == j["sort-version"]
396404

397405

398406
def _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

444462
def _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+
671704
def 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

src/manage/pathutils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ def parts(self):
7070
bits.pop(i - 1)
7171
return bits
7272

73+
def is_absolute(self):
74+
drive, root, tail = os.path.splitroot(self._p)
75+
return drive and root
76+
7377
def __truediv__(self, other):
7478
other = str(other)
7579
# Quick hack to hide leading ".\" on paths. We don't fully normalise

0 commit comments

Comments
 (0)