Skip to content

Instantly share code, notes, and snippets.

@StSav012
Last active July 3, 2024 09:10
Show Gist options
  • Save StSav012/5bf0a4e5f1bfc2d2df21ac2bc52247c9 to your computer and use it in GitHub Desktop.
Save StSav012/5bf0a4e5f1bfc2d2df21ac2bc52247c9 to your computer and use it in GitHub Desktop.
# coding=utf-8
from __future__ import annotations
import logging
import ssl
from http import HTTPStatus
from http.client import HTTPResponse
from json import loads
from pathlib import Path
from platform import uname, uname_result
from subprocess import run
from sys import exc_info, version_info
from tempfile import TemporaryDirectory
from traceback import format_exception
from typing import Iterable, Literal, cast, TYPE_CHECKING
from urllib import request
from packaging.version import Version
logger: logging.Logger = logging.getLogger(Path(__file__).name)
TIMEOUT: float = 10
if TYPE_CHECKING:
CommitDataType = dict[
str,
str
| dict[str, bool | int | str]
| dict[str, int | str | dict[str, bool | str] | dict[str, str]]
| dict[str, int]
| list[dict[str, int | str]]
| list[dict[str, str]],
]
def no_cert_context() -> ssl.SSLContext:
context: ssl.SSLContext = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def get_github_commit_data(
user: str,
repo_name: str,
commit: str = "master",
) -> CommitDataType:
url: str = f"https://api.github.com/repos/{user}/{repo_name}/commits/{commit}"
logger.debug(f"Requesting {url}")
r: HTTPResponse
with request.urlopen(url, timeout=TIMEOUT) as r:
logger.debug(f"{url!r}: Response code: {r.getcode()}")
if r.getcode() != HTTPStatus.OK:
logger.warning(f"{url!r}: Response code is not OK: {r.getcode()}")
return {}
content: bytes = r.read()
if not content:
logger.warning(f"No data received from {url}")
return {}
d: CommitDataType = loads(content)
return d
def get_recent_version(
files_data: list[dict[str, int | str]],
is64bit: bool,
embed: bool = False,
extension: Literal[".exe", ".zip"] = ".exe",
restrict_to: Version | None = None,
) -> tuple[dict[str, int | str], Version] | tuple[None, None]:
newest_version: Version | None = None
newest_file: dict[str, int | str] | None = None
for file in files_data:
if not file["filename"].endswith(extension):
continue
if file["filename"].count("/") != 1:
logger.warning(f"Invalid filename: {file['filename']!r}")
continue
this_version_str: str
filename: str
this_version_str, filename = file["filename"].split("/", maxsplit=1)
if is64bit and "amd64" not in filename:
continue
if embed != ("embed" in filename):
continue
this_version: Version = Version(this_version_str)
if restrict_to is not None and not this_version.public.startswith(
restrict_to.public
):
continue
if newest_version is None or newest_version < this_version:
newest_version = this_version
newest_file = file
return newest_file, newest_version
def get_github_file(file_data: dict[str, int | str]) -> tuple[bytes, str]:
if (
"contents_url" not in file_data
and "raw_url" not in file_data
and "blob_url" not in file_data
):
raise ValueError("No URL given")
size: int | None = None
url: str = ""
filename: str = ""
content: bytes
r: HTTPResponse
if "contents_url" in file_data:
url = file_data["contents_url"]
with request.urlopen(url, timeout=TIMEOUT, context=no_cert_context()) as r:
logger.debug(f"{url!r}: Response code: {r.getcode()}")
if r.getcode() != HTTPStatus.OK:
logger.warning(f"{url!r}: Response code is not OK: {r.getcode()}")
else:
content = r.read()
if content:
file_description: dict[str, str | dict[str, str]] = loads(content)
url = file_description.get("download_url", "")
if (
"sha" in file_description
and file_description["sha"] != file_data["sha"]
):
logger.warning(f"{url!r}: SHA mismatch")
if "size" in file_description:
size = cast(int, file_description["size"])
if "name" in file_description:
filename = file_description["name"]
if not url:
url = file_data["raw_url"] or file_data["blob_url"]
if not filename:
filename = url.split("/")[-1]
with request.urlopen(url, timeout=TIMEOUT) as r:
logger.debug(f"{url!r}: Response code: {r.getcode()}")
if r.getcode() != HTTPStatus.OK:
logger.warning(f"{url!r}: Response code is not OK: {r.getcode()}")
return b"", filename
content = r.read()
if size is not None and len(content) != size:
logger.warning(f"{url!r}: size mismatch: {size} != {len(content)}")
return content, filename
def install(
micro_only: bool = True,
*,
quiet: bool = True,
all_users: bool = False,
prepend_path: bool = True,
include_doc: bool = False,
include_test: bool = False,
other_args: Iterable[str] = (),
) -> int:
u: uname_result = uname()
if (u.system, u.release) != ("Windows", "7"):
raise NotImplementedError("Only Windows 7 is supported")
most_recent_file_data: dict[str, int | str] | None = None
most_recent_file_version: Version | None = None
commit: str = "master"
while most_recent_file_data is None or most_recent_file_version is None:
commit_data: CommitDataType = get_github_commit_data("adang1345", "PythonWin7", commit)
all_files: list[dict[str, int | str]] = commit_data.get("files", [])
most_recent_file_data, most_recent_file_version = get_recent_version(
all_files,
is64bit=(u.machine in ("AMD64", "x86_64")),
restrict_to=(
Version(".".join(map(str, [version_info.major, version_info.minor])))
if micro_only
else None
),
)
parents: list[dict[str, str]] = commit_data.get("parents", [])
if parents:
commit = parents[0].get("sha", "")
if not commit:
logger.error("Malformed commit data occurred")
else:
logger.warning("Reached the oldest commit")
break
if most_recent_file_data is None:
logger.warning("No matching release found")
return 1
logger.info(f"Found Python {most_recent_file_version} installer")
print(f"Found Python {most_recent_file_version} installer")
current_python_version: Version = Version(
".".join(map(str, filter(lambda v: v != "final", version_info)))
)
if current_python_version >= most_recent_file_version:
print(
f"Current Python version {current_python_version} is new enough. Exiting…"
)
return 0
logger.info(f"Downloading Python {most_recent_file_version} installer…")
print(f"Downloading Python {most_recent_file_version} installer…")
data, name = get_github_file(most_recent_file_data)
logger.info(f"Done. Installing Python {most_recent_file_version}…")
print(f"Done. Installing Python {most_recent_file_version}…")
with TemporaryDirectory() as tmpdir:
pe_file: Path = Path(tmpdir) / name
pe_file.write_bytes(data)
try:
ret: int = run(
[pe_file]
+ (["/quiet"] if quiet else [])
+ [
f"InstallAllUsers={int(all_users)}",
f"PrependPath={int(prepend_path)}",
f"Include_doc={int(include_doc)}",
f"Include_test={int(include_test)}",
]
+ list(other_args),
cwd=tmpdir,
check=True,
).returncode
except Exception as ex:
logger.error(ex)
if isinstance(ex, FileNotFoundError):
logger.error(ex.filename or pe_file)
logger.error("".join(format_exception(*exc_info())))
input("Press Enter to continue")
else:
logger.info("Done")
print("Done")
return ret
if __name__ == "__main__":
def main() -> int:
from argparse import ArgumentParser, Namespace
parser: ArgumentParser = ArgumentParser(
description="Windows 7 only! Update Python from https://github.com/adang1345/PythonWin7.",
epilog=" ".join(
(
"Other arguments are passed directly to the Python installer",
"and may supersede the parameters above.",
"See https://docs.python.org/3/using/windows.html#installing-without-ui.",
)
),
prefix_chars="-/",
)
parser.add_argument(
*("-F", "/F"),
action="store_true",
help="Install the newest available version, regardless of the currently installed one",
)
parser.add_argument(
*("-q", "--quiet", "/quiet"),
action="store_true",
help="Install without displaying any UI",
)
parser.add_argument(
"--all-users",
action="store_true",
help="Perform a system-wide installation (InstallAllUsers=1)",
)
parser.add_argument(
"--prepend-path",
action="store_true",
help="Prepend install and Scripts directories to PATH and add .PY to PATHEXT (PrependPath=1)",
)
parser.add_argument(
"--include-doc",
action="store_true",
help="Install Python manual (Include_doc=1)",
)
parser.add_argument(
"--include-test",
action="store_true",
help="Install standard library test suite (Include_test=1)",
)
args: Namespace
leftovers: list[str]
args, leftovers = parser.parse_known_args()
for a in leftovers.copy():
if "=" not in a:
continue
k, v = a.split("=", maxsplit=1)
if k == "InstallAllUsers":
args.all_users = bool(int(v))
leftovers.remove(a)
elif k == "PrependPath":
args.prepend_path = bool(int(v))
leftovers.remove(a)
elif k == "Include_doc":
args.include_doc = bool(int(v))
leftovers.remove(a)
elif k == "Include_test":
args.include_test = bool(int(v))
leftovers.remove(a)
return install(
micro_only=not args.F,
quiet=args.quiet,
all_users=args.all_users,
prepend_path=args.prepend_path,
include_doc=args.include_doc,
include_test=args.include_test,
other_args=leftovers,
)
exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment