Created
September 18, 2026 12:52
-
-
Save rene-d/d042dac667633daad6495336e53ceffa to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # Download the last Visual Studio Code extension compatible with a given version | |
| import argparse | |
| import hashlib | |
| import html | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import subprocess # nosec B404 | |
| import sys | |
| import typing as t | |
| import zipfile | |
| import zlib | |
| from collections import defaultdict | |
| from dataclasses import dataclass | |
| from datetime import datetime | |
| from email.utils import parsedate_to_datetime | |
| from io import StringIO | |
| from pathlib import Path | |
| from urllib.parse import quote | |
| import requests | |
| try: | |
| from rich.progress import ( | |
| Progress, | |
| TextColumn, | |
| BarColumn, | |
| TaskProgressColumn, | |
| TimeRemainingColumn, | |
| SpinnerColumn, | |
| TransferSpeedColumn, | |
| DownloadColumn, | |
| ) | |
| except ImportError: | |
| SpinnerColumn = TextColumn = BarColumn = TaskProgressColumn = str | |
| TimeRemainingColumn = DownloadColumn = TransferSpeedColumn = str | |
| class Progress: | |
| def __init__(self, *columns, transient: bool = False): | |
| pass | |
| def __enter__(self): | |
| return self | |
| def __exit__(self, exc_type, exc_val, exc_tb): | |
| pass | |
| def add_task(self, description: str, total: float): | |
| print(f"{description} ({total} bytes)") | |
| def update(self, task_id, advance: float): | |
| pass | |
| session = requests.Session() | |
| """ | |
| Global Requests session. | |
| """ | |
| BRIGHT_GREEN, FADE, GREEN, RESET = ( | |
| ("\033[1;32m", "\033[2m", "\033[32m", "\033[0m") if sys.stdout.isatty() else ("", "", "", "") | |
| ) | |
| @dataclass | |
| class CodeVersion: | |
| version: str | |
| commit_id: str | |
| channel: str = "stable" | |
| # cf. https://github.com/microsoft/vscode/blob/main/cli/src/update_service.rs#L241 | |
| PLATFORMS = { | |
| "alpine-arm64": False, | |
| "alpine-x64": False, | |
| "darwin-arm64": False, | |
| "darwin-x64": False, | |
| "linux-arm64": False, | |
| "linux-armhf": False, | |
| "linux-x64": True, | |
| "web": False, | |
| "win32-arm64": False, | |
| "win32-ia32": False, | |
| "win32-x64": True, | |
| } | |
| # constants from vscode extension API | |
| # https://github.com/microsoft/vscode/blob/main/src/vs/platform/extensionManagement/common/extensionGalleryService.ts | |
| @dataclass | |
| class FilterType: | |
| Tag = 1 | |
| ExtensionId = 4 | |
| Category = 5 | |
| ExtensionName = 7 | |
| Target = 8 | |
| Featured = 9 | |
| SearchText = 10 | |
| ExcludeWithFlags = 12 | |
| @dataclass | |
| class Flags: | |
| _None = 0x0 | |
| IncludeVersions = 0x1 | |
| IncludeFiles = 0x2 | |
| IncludeCategoryAndTags = 0x4 | |
| IncludeSharedAccounts = 0x8 | |
| IncludeVersionProperties = 0x10 | |
| ExcludeNonValidated = 0x20 | |
| IncludeInstallationTargets = 0x40 | |
| IncludeAssetUri = 0x80 | |
| IncludeStatistics = 0x100 | |
| IncludeLatestVersionOnly = 0x200 | |
| Unpublished = 0x1000 | |
| IncludeNameConflictInfo = 0x8000 | |
| def get_property(version, name): | |
| if "properties" not in version: | |
| # print(version) | |
| return | |
| for property in version.get("properties", ()): | |
| if property["key"] == name: | |
| return property["value"] | |
| return | |
| def version_serial(version): | |
| v = version.split(".", maxsplit=2) | |
| if "-" in v[2]: | |
| r = v[2].split("-", maxsplit=1) | |
| t = (int(v[0]), int(v[1]), int(r[0]), r[1]) | |
| return t | |
| elif "x" in v[2]: | |
| t = (int(v[0]), int(v[1]), 0) | |
| return t | |
| else: | |
| return tuple(map(int, v)) | |
| def engine_match(pattern, engine): | |
| if pattern == "*": | |
| return True | |
| if pattern[0] != "^": | |
| if pattern == "0.10.x" or pattern.endswith("-insider"): | |
| return False | |
| # print("missing caret:", pattern) | |
| return False | |
| assert pattern[0] == "^" | |
| def rr(): | |
| p = version_serial(pattern[1:]) | |
| v = version_serial(engine) | |
| if len(p) == 4 and p[3] == "insiders": | |
| return False | |
| if p[0] != v[0]: # major must be the same | |
| return False | |
| if p[1] > v[1]: # minor must be greater or equal | |
| return False | |
| if p[1] == v[1] and p[2] != 0 and p[2] > v[2]: | |
| return False | |
| return True | |
| r = rr() | |
| # print(pattern, engine, r) | |
| return r | |
| @dataclass | |
| class Asset: | |
| name: str | |
| "Vame of the extension (<publisherName>.<extensionName>)." | |
| version: str | |
| "Version string." | |
| engine: str | |
| "Visual Studio Code engine." | |
| uri: str | |
| "Download link." | |
| timestamp: str | |
| "lastUpdated time." | |
| platform: t.Optional[str] | |
| "Platform string or None." | |
| ignore: bool = False | |
| "Ignore the extension in the inventory." | |
| @property | |
| def vsix(self) -> str: | |
| """Filename of the vsix.""" | |
| if self.platform: | |
| return f"{self.name}-{self.platform}-{self.version}.vsix" | |
| else: | |
| return f"{self.name}-{self.version}.vsix" | |
| def vsix_glob(self) -> str: | |
| """Pattern for all versions of the extension.""" | |
| if self.platform: | |
| return f"{self.name}-{self.platform}-*.vsix" | |
| else: | |
| return f"{self.name}-*.vsix" | |
| def download_file(url: str, file: Path, timestamp: t.Optional[datetime] = None): | |
| logging.debug(f"download {file}") | |
| try: | |
| with session.get(url, stream=True) as r: | |
| logging.debug(f"{r.headers}") | |
| if not r.ok: | |
| print(f"error downloading {url}") | |
| print(r) | |
| sys.exit(1) | |
| content_length = int(r.headers["content-length"]) | |
| file.parent.mkdir(parents=True, exist_ok=True) | |
| with file.open("wb") as f: | |
| with Progress( | |
| SpinnerColumn(), | |
| TextColumn("[progress.description]{task.description}"), | |
| "•", | |
| BarColumn(), | |
| TaskProgressColumn(), | |
| "•", | |
| DownloadColumn(), | |
| TransferSpeedColumn(), | |
| "•", | |
| TimeRemainingColumn(), | |
| transient=True, | |
| ) as progress: | |
| task = progress.add_task(f"Downloading {file}", total=content_length) | |
| for chunk in r.iter_content(chunk_size=4096): | |
| f.write(chunk) | |
| progress.update(task, advance=len(chunk)) | |
| if content_length != file.stat().st_size: | |
| file.unlink() | |
| print(f"download problem {url}") | |
| sys.exit(1) | |
| print(f"downloaded {file} ({content_length} bytes)") | |
| if timestamp is None: | |
| last_modified = r.headers.get("last-modified") | |
| if last_modified: | |
| timestamp = parsedate_to_datetime(last_modified) | |
| if timestamp: | |
| logging.debug(f"set mtime to {timestamp}") | |
| mtime_ns = round(timestamp.timestamp() * 1_000_000_000) | |
| os.utime(file, ns=(mtime_ns, mtime_ns)) | |
| except KeyboardInterrupt: | |
| print("KeyboardInterrupt") | |
| sys.exit(1) | |
| def vscode_lldb(asset: Asset, dest_dir: Path) -> t.List[Asset]: | |
| """ | |
| vscode-lldb is special: platform packages are downloaded separately. | |
| """ | |
| assert asset.name == "vadimcn.vscode-lldb" | |
| assert asset.platform is None | |
| zip = zipfile.ZipFile(dest_dir / asset.vsix) | |
| m = json.loads(zip.open("extension/package.json").read()) | |
| zip.close() | |
| version = m.get("version") | |
| platform_packages = m.get("config", {}).get("platformPackages", {}) | |
| url = platform_packages.get("url") | |
| platforms = platform_packages.get("platforms") | |
| if not url or not platforms: | |
| logging.fatal(f"{asset.name} package.json has changed") | |
| sys.exit(1) | |
| return | |
| logging.debug(f"vscode-lldb url: {url}") | |
| logging.debug(f"vscode-lldb version: {version}") | |
| logging.debug(f"vscode-lldb platforms: {list(platforms.keys())}") | |
| assets = list() | |
| for platform, vsix in platforms.items(): | |
| if PLATFORMS.get(platform) is True: | |
| uri = url.replace("${version}", version).replace("${platformPackage}", vsix) | |
| assets.append( | |
| Asset( | |
| asset.name, | |
| asset.version, | |
| asset.engine, | |
| uri, | |
| asset.timestamp, | |
| platform, | |
| ) | |
| ) | |
| return assets | |
| class Extensions: | |
| def __init__(self, engine: str, dest_dir: Path, write_cache: bool = False): | |
| self.engine = engine | |
| self.write_cache = write_cache | |
| self.dest_dir = dest_dir | |
| self.all_assets_list: t.List[Asset] = list() | |
| def run(self, extension_ids: t.Iterable[str]): | |
| """ | |
| Download all extensions and packs. | |
| """ | |
| all_assets = dict() | |
| all_extension_ids = set(extension_ids) # set of extension identifiers already fetched | |
| assets, packs = self.find_assets(extension_ids) | |
| all_assets.update(assets) | |
| self.download_vsix_files(assets.values()) | |
| # as long we have packs | |
| while packs: | |
| new_extension_ids = set() | |
| for pack in packs: | |
| # load the extension pack manifest (on disk) and get the child extensions | |
| zip = zipfile.ZipFile(self.dest_dir / assets[pack].vsix) | |
| m = json.loads(zip.open("extension/package.json").read()) | |
| new_extension_ids.update(m["extensionPack"]) | |
| zip.close() | |
| logging.debug(f"pack {pack} has {len(m['extensionPack'])} extension(s)") | |
| new_extension_ids.difference_update(all_extension_ids) | |
| # download new found extensions | |
| assets, packs = self.find_assets(new_extension_ids) | |
| self.download_vsix_files(assets.values()) | |
| all_assets.update(assets) | |
| all_assets_list = list(all_assets.values()) | |
| for asset in all_assets_list: | |
| if asset.name == "vadimcn.vscode-lldb": | |
| a = vscode_lldb(asset, self.dest_dir) | |
| self.download_vsix_files(a) | |
| asset.ignore = True | |
| all_assets_list.extend(a) | |
| break | |
| ignored = sum(1 for a in all_assets_list if a.ignore) | |
| print(f"downloaded vsix: {len(all_assets_list) - ignored}") | |
| all_extension_ids = set(asset.name.casefold() for asset in all_assets_list) | |
| missing = set(map(str.casefold, extension_ids)).difference(all_extension_ids) | |
| if missing: | |
| logging.error(f"extensions not found: {missing}") | |
| self.all_assets_list = all_assets_list | |
| def download_vsix_files(self, assets: t.Iterable[Asset]): | |
| """ | |
| Download extension archive (VSIX). | |
| """ | |
| for asset in assets: | |
| vsix = self.dest_dir / asset.vsix | |
| if not vsix.exists(): | |
| download_file(asset.uri, vsix, datetime.fromisoformat(asset.timestamp)) | |
| else: | |
| if asset.platform: | |
| logging.debug(f"already downloaded: {asset.name} {asset.version} ({asset.platform})") | |
| else: | |
| logging.debug(f"already downloaded: {asset.name} {asset.version}") | |
| def find_assets(self, extension_ids: t.Iterable[str]) -> t.Tuple[t.Dict[str, Asset], t.Set[str]]: | |
| """Build the list of extensions to download.""" | |
| assets = dict() | |
| packs: t.Set[str] = set() | |
| if extension_ids: | |
| # do the request to extension server | |
| # result is an array of extensions | |
| r = self.do_extension_query(extension_ids) | |
| for result in r["results"]: | |
| for extension in result["extensions"]: | |
| extension_assets = self.parse_extension_details(extension) | |
| if extension_assets: | |
| assets.update(extension_assets) | |
| # if the category is "Extension Packs" we will analyze the extension manifest | |
| # to find which extensions are in the pack to download them too | |
| if "Extension Packs" in extension["categories"]: | |
| packs.update(extension_assets.keys()) | |
| logging.debug(f"found {len(assets)} extension(s) and {len(packs)} pack(s)") | |
| return assets, packs | |
| def do_extension_query(self, extension_ids: t.Iterable[str]): | |
| """ | |
| Make the HTTP request to the extension server, with: | |
| - assets uri (Flags.IncludeAssetUri) | |
| - details (Flags.IncludeVersionProperties) | |
| - categories (Flags.IncludeCategoryAndTags) | |
| """ | |
| criteria = [ | |
| { | |
| "filterType": FilterType.Target, | |
| "value": "Microsoft.VisualStudio.Code", | |
| }, | |
| { | |
| "filterType": FilterType.ExcludeWithFlags, | |
| "value": str(Flags.Unpublished), | |
| }, | |
| # { | |
| # "filterType": FilterType.ExtensionName, | |
| # "value": name, | |
| # }, | |
| ] | |
| for extension_id in sorted(extension_ids): | |
| criteria.append({"filterType": FilterType.ExtensionName, "value": extension_id}) | |
| data = { | |
| "filters": [{"criteria": criteria}], | |
| "flags": Flags.IncludeAssetUri + Flags.IncludeVersionProperties + Flags.IncludeCategoryAndTags, | |
| } | |
| data_str = json.dumps(data) | |
| hash = f"{zlib.crc32(data_str.encode()):04x}" | |
| cache = Path(f"response_{hash}.json") | |
| if cache.is_file(): | |
| logging.info(f"load cached response {cache}") | |
| r = json.loads(cache.read_bytes()) | |
| else: | |
| resp = requests.post( | |
| "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery", | |
| data=data_str, | |
| headers={ | |
| "Content-Type": "application/json", | |
| "Accept": "application/json;api-version=3.0-preview.1", | |
| }, | |
| timeout=10, | |
| ) | |
| if self.write_cache: | |
| Path(f"query_{hash}.json").write_text(data_str) | |
| cache.write_bytes(resp.content) | |
| logging.debug(f"write query and response {cache}") | |
| r = resp.json() | |
| return r | |
| def parse_extension_details(self, extension: dict) -> t.Dict[str, Asset]: | |
| """ | |
| Parse the response of the query to the server to find the download links for the extension. | |
| Return a list of all .vsix filenames according to target platforms | |
| and fill the self.download_assets map | |
| The response is a JSON object with an array containing elements like this: | |
| ```json | |
| { | |
| "publisher": { | |
| "publisherId": "uuid", | |
| "publisherName": "ms-python", // the left part of the extension identifier | |
| "displayName": "Microsoft", | |
| "flags": "verified", | |
| "domain": "https://microsoft.com", | |
| "isDomainVerified": true | |
| }, | |
| "extensionId": "uuid", | |
| "extensionName": "python", // the right part of the extension identifier | |
| "displayName": "Python", | |
| "flags": "validated, public", | |
| "lastUpdated": "2025-01-24T10:42:55.8Z", | |
| "publishedDate": "2016-01-19T15:03:11.337Z", | |
| "releaseDate": "2016-01-19T15:03:11.337Z", | |
| "shortDescription": "Python....", | |
| "versions": [ | |
| { | |
| "version": "2024.23.2025012401", // the version | |
| "win32-arm64", // target platform | |
| "flags": "validated", | |
| "lastUpdated": "2025-01-24T10:42:55.8Z", | |
| "properties": [], // to filter by engine and exclude prerelease | |
| "assetUri": "...", // the download link | |
| "fallbackAssetUri": "..." | |
| }, | |
| ], | |
| "categories": [], // could be Extension packs: recursively look for extensions | |
| "tags": [], | |
| "deploymentType": 0 | |
| }, | |
| ``` | |
| """ | |
| name = extension["publisher"]["publisherName"] + "." + extension["extensionName"] | |
| def filter_version(extension, platform): | |
| has_target_platform = set() | |
| for version in extension["versions"]: | |
| # sanity check | |
| if version["flags"] != "validated" and version["flags"] != "none": | |
| logging.fatal("flags should be 'validated' or 'none'") | |
| print(json.dumps(version, indent=2)) | |
| exit() | |
| # do not use pre-release version | |
| v = get_property(version, "Microsoft.VisualStudio.Code.PreRelease") | |
| if v == "true": | |
| continue | |
| # we have to match the engine version | |
| v = get_property(version, "Microsoft.VisualStudio.Code.Engine") | |
| if not (v and engine_match(v, self.engine)): | |
| continue | |
| if version.get("targetPlatform") is not None: | |
| assert version["targetPlatform"] in PLATFORMS | |
| has_target_platform.add(version["version"]) | |
| # we have to match the platform if asked and specified for the version | |
| if version["version"] in has_target_platform and platform and version.get("targetPlatform") != platform: | |
| continue | |
| yield version | |
| def find_latest_version(extension, platform): | |
| versions = filter_version(extension, platform) | |
| versions = sorted(versions, key=lambda v: version_serial(v["version"])) | |
| if versions: | |
| return versions[-1] | |
| def find_version_vsix(extension: dict, platform: str) -> t.Optional[Asset]: | |
| version = find_latest_version(extension, platform) | |
| if not version: | |
| logging.error(f"missing {platform} for {name}") | |
| return None | |
| asset_uri = version["assetUri"] + "/Microsoft.VisualStudio.Services.VSIXPackage" | |
| target_platform = version.get("targetPlatform") | |
| asset = Asset( | |
| name, | |
| version["version"], | |
| get_property(version, "Microsoft.VisualStudio.Code.Engine"), | |
| asset_uri, | |
| version["lastUpdated"], | |
| target_platform, | |
| ) | |
| return asset | |
| assets = dict() | |
| for target_platform, wanted in PLATFORMS.items(): | |
| if wanted: | |
| asset = find_version_vsix(extension, target_platform) | |
| if asset: | |
| assets[asset.vsix] = asset | |
| return assets | |
| def prune(self): | |
| all_vsix = set(file.name for file in self.dest_dir.glob("*.vsix")) | |
| our_vsix = set(asset.vsix for asset in self.all_assets_list) | |
| for file in all_vsix.difference(our_vsix): | |
| logging.debug(f"Purge {file}") | |
| (self.dest_dir / file).unlink() | |
| def assets(self) -> t.List[Asset]: | |
| return self.all_assets_list | |
| def get_code_version(version: str, channel="stable") -> CodeVersion: | |
| """ | |
| Retrieve current VSCode version from Windows download link. | |
| """ | |
| # ref: https://code.visualstudio.com/docs/supporting/faq#_previous-release-versions | |
| url = f"https://update.code.visualstudio.com/{version}/win32-x64-archive/{channel}" | |
| try: | |
| r = requests.get(url, allow_redirects=False, timeout=10) | |
| if r is None or r.status_code != 302: | |
| logging.fatal(f"request error {r}") | |
| sys.exit(1) | |
| url = r.headers["location"] | |
| m = re.search(r"/(\w+)/([a-f0-9]{40})/VSCode-win32-x64-([\d.]+).zip", url) | |
| if not m or m[1] != channel: | |
| logging.fatal(f"Cannot extract vscode version from url {url}") | |
| sys.exit(1) | |
| logging.debug(f"found: {m.groups()}") | |
| channel, commit_id, version = m.groups() | |
| return CodeVersion(version, commit_id, channel) | |
| except requests.exceptions.ConnectionError as e: | |
| logging.fatal(f"Cannot get vscode version info: {e}") | |
| sys.exit(1) | |
| def read_code_version(files: Path) -> CodeVersion: | |
| """ | |
| TODO. | |
| """ | |
| assets = files.read_text() | |
| m_version = re.search(r"\bversion=(.+)\b", assets) | |
| if not m_version: | |
| logging.error(f"Version not found in {files}") | |
| sys.exit(1) | |
| m_commit = re.search(r"\bcommit=(.+)\b", assets) | |
| if not m_commit: | |
| logging.error(f"Commit not found in {files}") | |
| sys.exit(1) | |
| m_channel = re.search(r"\bchannel=(.+)\b", assets) | |
| if not m_channel: | |
| logging.error(f"Channel not found in {files}") | |
| sys.exit(1) | |
| return CodeVersion(m_version[1], m_commit[1], m_channel[1]) | |
| def get_installed_extensions() -> t.List[str]: | |
| """ | |
| List the installed extensions. | |
| """ | |
| try: | |
| output = subprocess.check_output(["code", "--list-extensions"]) # nosec B603 B607 | |
| return output.decode().splitlines() | |
| except Exception: | |
| return [] | |
| def compare_local(extension_ids: t.Iterable[str]): | |
| """ | |
| Compare the list of desired extensions with the list of locally installed extensions. | |
| """ | |
| set_installed = set(get_installed_extensions()) | |
| set_wanted = set(extension_ids) | |
| # check the case | |
| set_installed_lowercase = set(map(str.lower, set_installed)) | |
| for i in set_wanted: | |
| if i in set_installed: | |
| continue | |
| if i.casefold() in set_installed_lowercase: | |
| for j in set_installed: | |
| if j.casefold() == i.casefold(): | |
| logging.warning(f"Upper/lower case problem with {i}, should be {j}") | |
| return 1 | |
| set3 = set_wanted.union(set_installed) | |
| color_wanted = "93" | |
| color_installed = "95" | |
| extension, color, col_a, col_b = "extension", "37", "config", "local" | |
| print(f"\033[1;3;{color}m{extension:<55}\033[{color_wanted}m{col_a:^9}\033[{color_installed}m{col_b:^9}\033[0m") | |
| for extension in sorted(set3): | |
| a = extension in set_wanted | |
| b = extension in set_installed | |
| color = "37" | |
| if not a and b: | |
| color = color_installed | |
| if a and not b: | |
| color = color_wanted | |
| col_a = "❌✅"[a] | |
| col_b = "❌✅"[b] | |
| # see explaination here: | |
| # https://wezfurlong.org/wezterm/hyperlinks.html#explicit-hyperlinks | |
| link = f"\033]8;;https://marketplace.visualstudio.com/items?itemName={extension}\033\\{extension}\033]8;;\033\\" | |
| link += " " * (55 - len(extension)) | |
| print(f"\033[{color}m{link}\033[0m{col_a:^9}{col_b:^9}") | |
| return 0 | |
| class Config: | |
| def __init__( | |
| self, | |
| assets_file: t.Optional[Path], | |
| extension_identifiers: t.List[str], | |
| use_local_code: bool, | |
| ): | |
| # read extension list from configuration file | |
| self.sections = defaultdict(set) | |
| if assets_file and assets_file.is_file(): | |
| print(f"reading configuration from: {assets_file}") | |
| files = assets_file.read_text() | |
| for section, extension_list in re.findall(r"(\w+_extensions)=\((.+?)\)", files, flags=re.DOTALL): | |
| for name in extension_list.splitlines(): | |
| name = name.strip() | |
| if not name or name.startswith("#"): | |
| continue | |
| # remove platform | |
| name = name.replace("-${arch}", "") | |
| for platform in PLATFORMS.keys(): | |
| name = name.replace(f"-{platform}", "") | |
| # remove version | |
| name = re.sub(r"\-(\d+)\.(\d+)\.(\d+)\.vsix$", "", name) | |
| # lower case | |
| name = name.casefold() | |
| self.sections[section].add(name) | |
| # the whole list of extension identfiers we want to download | |
| extension_ids = set(extension_identifiers) | |
| if use_local_code: | |
| extension_ids.update(get_installed_extensions()) | |
| for _, v in self.sections.items(): | |
| extension_ids.update(v) | |
| # the special section | |
| self.sections["all_extensions"] = extension_ids | |
| @property | |
| def all_extensions(self) -> t.Set[str]: | |
| return self.sections["all_extensions"] | |
| def write_extension_assets(assets_file: Path, config: Config, assets: t.List[Asset]): | |
| group_by_platform = False | |
| def make_section(vsix: str) -> str: | |
| with StringIO() as f: | |
| extension_list = config.sections.get(vsix) | |
| if extension_list: | |
| print(f"{vsix}=(", file=f) | |
| for name in sorted(extension_list, key=str.casefold): | |
| # all target platforms may not be in same version | |
| all_platforms_same_version = 1 == len( | |
| set( | |
| asset.version | |
| for asset in assets | |
| if asset.platform and not asset.ignore and name.casefold() == asset.name.casefold() | |
| ) | |
| ) | |
| for asset in sorted(assets, key=lambda asset: str(asset.platform)): | |
| if asset.ignore or name.casefold() != asset.name.casefold(): | |
| continue | |
| vsix = asset.vsix | |
| if asset.platform and all_platforms_same_version and group_by_platform: | |
| vsix = vsix.replace(asset.platform, "${arch}") | |
| print(f" {vsix}", file=f) | |
| break | |
| else: | |
| print(f" {vsix}", file=f) | |
| print(")", file=f, end="") | |
| return f.getvalue() | |
| if assets_file.is_file(): | |
| inventory = re.sub( | |
| r"\b\w+_extensions=\((?:.+?)\)", | |
| "", | |
| assets_file.read_text(), | |
| flags=re.DOTALL, | |
| ) | |
| inventory = inventory.strip() + "\n\n" | |
| else: | |
| inventory = "" | |
| inventory += "\n\n".join(make_section(section) for section in config.sections) + "\n" | |
| # old = assets_file.with_suffix(".old") | |
| # if old.is_file(): | |
| # old.unlink() | |
| # if assets_file.is_file(): | |
| # assets_file.rename(old) | |
| assets_file.write_text(inventory) | |
| # for i, z in enumerate(sorted(config.all_extensions), 1): | |
| # print(i, z) | |
| print(f"extensions: {len(config.all_extensions)}") | |
| def section_vsix(config: "Config", section: str, assets: t.List[Asset]) -> t.List[str]: | |
| """ | |
| Retourne les fichiers .vsix d'une section, comme le fait `write_extension_assets`. | |
| Les sections listent des identifiants d'extensions; ce sont les noms de | |
| fichiers qui intéressent le client. | |
| """ | |
| names = config.sections.get(section) or [] | |
| vsix_files = [] | |
| for name in sorted(names, key=str.casefold): | |
| for asset in sorted(assets, key=lambda asset: str(asset.platform)): | |
| if asset.ignore or name.casefold() != asset.name.casefold(): | |
| continue | |
| vsix_files.append(asset.vsix) | |
| return vsix_files | |
| def write_json_catalog( | |
| dest_dir: Path, | |
| code_assets: t.Dict[str, str], | |
| config: t.Optional["Config"] = None, | |
| extension_assets: t.Optional[t.List[Asset]] = None, | |
| ) -> t.Dict[str, t.Any]: | |
| """ | |
| Écrit le catalogue JSON lu par la commande `cll vscode`. | |
| Il remplace le fichier `files`, un fragment shell que le script | |
| d'installation évaluait. Le fichier `files` reste écrit tant que la version | |
| Python de `cll` est en service. | |
| """ | |
| catalog_file = dest_dir / "files.json" | |
| if catalog_file.is_file(): | |
| catalog = json.loads(catalog_file.read_text()) | |
| else: | |
| catalog = {} | |
| if code_assets: | |
| for key in ("version", "commit", "channel"): | |
| catalog[key] = code_assets.get(key, catalog.get(key, "")) | |
| files = catalog.setdefault("files", {}) | |
| for key in ("code_tar", "code_deb", "code_win32", "server_linux", "cli_linux"): | |
| if key in code_assets: | |
| files[key] = code_assets[key] | |
| if config is not None and extension_assets is not None: | |
| for section in ("ide_extensions", "server_extensions", "all_extensions"): | |
| catalog[section] = section_vsix(config, section, extension_assets) | |
| catalog_file.write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n") | |
| print(f"catalogue écrit: {catalog_file}") | |
| return catalog | |
| # Les fichiers de Visual Studio Code, dans l'ordre d'affichage de la page. | |
| CODE_FILES = ( | |
| ("code_tar", "Visual Studio Code", "archive Linux x64"), | |
| ("code_deb", "Visual Studio Code", "paquet Debian x64"), | |
| ("code_win32", "Visual Studio Code", "archive Windows x64"), | |
| ("server_linux", "Code server", "Linux x64"), | |
| ("cli_linux", "Code CLI", "Linux x64"), | |
| ) | |
| # Les fichiers de la distribution qui ne sont pas des téléchargements. | |
| INDEX_SKIP = ("index.html", "files", "files.json") | |
| def index_skip(name: str) -> bool: | |
| """Un fichier que la page ne liste pas.""" | |
| # « ._nom » est le fichier AppleDouble que macOS dépose à côté du vrai | |
| # fichier sur un système qui ne porte pas ses attributs étendus. | |
| return name in INDEX_SKIP or name.startswith("._") | |
| INDEX_CSS = """\ | |
| :root { | |
| color-scheme: light dark; | |
| --fond: #fbfbfd; | |
| --fond-bloc: #ffffff; | |
| --texte: #1c1c1e; | |
| --estompe: #6a6a72; | |
| --trait: #e3e3e8; | |
| --lien: #0a5ad6; | |
| --accent: #eef2fb; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --fond: #16171a; | |
| --fond-bloc: #1e2024; | |
| --texte: #e9e9ec; | |
| --estompe: #9a9aa2; | |
| --trait: #2e3036; | |
| --lien: #7fb0ff; | |
| --accent: #262a33; | |
| } | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| padding: 2rem 1rem 4rem; | |
| background: var(--fond); | |
| color: var(--texte); | |
| font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| } | |
| main { margin: 0; } | |
| h1 { font-size: 1.6rem; margin: 0 0 .25rem; } | |
| h2 { font-size: 1.15rem; margin: 2.5rem 0 .75rem; } | |
| h2 .compte { font-weight: normal; color: var(--estompe); font-size: .9rem; margin-left: .5rem; } | |
| .entete { color: var(--estompe); font-size: .9rem; } | |
| .entete code { color: var(--texte); } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| background: var(--fond-bloc); | |
| border: 1px solid var(--trait); | |
| border-radius: 8px; | |
| overflow: hidden; | |
| } | |
| th, td { | |
| text-align: left; | |
| padding: .5rem .75rem; | |
| border-bottom: 1px solid var(--trait); | |
| white-space: nowrap; | |
| } | |
| th { | |
| font-size: .78rem; | |
| text-transform: uppercase; | |
| letter-spacing: .04em; | |
| color: var(--estompe); | |
| background: var(--accent); | |
| } | |
| tr:last-child td { border-bottom: none; } | |
| td.taille, td.date { text-align: right; color: var(--estompe); font-variant-numeric: tabular-nums; } | |
| td.fichier, code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .86rem; } | |
| td.desc { color: var(--estompe); white-space: normal; } | |
| a { color: var(--lien); text-decoration: none; } | |
| a:hover { text-decoration: underline; } | |
| .absent td { opacity: .55; } | |
| .badge { | |
| display: inline-block; | |
| padding: .05rem .4rem; | |
| margin-right: .25rem; | |
| border: 1px solid var(--trait); | |
| border-radius: 999px; | |
| font-size: .72rem; | |
| color: var(--estompe); | |
| } | |
| footer { margin-top: 2.5rem; color: var(--estompe); font-size: .82rem; } | |
| @media (max-width: 40rem) { | |
| body { padding: 1rem .5rem 3rem; } | |
| th, td { padding: .4rem .5rem; } | |
| } | |
| """ | |
| def human_size(size: int) -> str: | |
| """Taille lisible, en unités binaires.""" | |
| if size < 1024: | |
| return f"{size} o" | |
| value = float(size) | |
| for unit in ("Kio", "Mio", "Gio"): | |
| value /= 1024 | |
| if value < 1024: | |
| return f"{value:.1f} {unit}" | |
| return f"{value / 1024:.1f} Tio" | |
| def pluriel(n: int) -> str: | |
| return "s" if n > 1 else "" | |
| def file_stat(file: Path) -> t.Optional[t.Tuple[int, str]]: | |
| """Taille et date d'un fichier, ou None s'il n'est pas là.""" | |
| if not file.is_file(): | |
| return None | |
| st = file.stat() | |
| return st.st_size, datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M") | |
| def write_index_html( | |
| dest_dir: Path, | |
| catalog: t.Dict[str, t.Any], | |
| config: t.Optional["Config"] = None, | |
| extension_assets: t.Optional[t.List[Asset]] = None, | |
| ): | |
| """ | |
| Écrit « index.html », la page qui liste les fichiers téléchargés. | |
| La page est autonome: feuille de style en ligne, aucun script, aucune | |
| ressource extérieure. Les liens désignent les fichiers voisins, si bien | |
| qu'elle se lit depuis le disque comme depuis un serveur. | |
| Les fichiers sont relevés sur le disque, et non dans le catalogue: ce qui | |
| est affiché est ce qui a réellement été téléchargé, taille et date | |
| comprises. Un fichier annoncé par le catalogue mais absent est signalé. | |
| """ | |
| esc = html.escape | |
| assets = {asset.vsix: asset for asset in (extension_assets or [])} | |
| # les extensions demandées par chaque section, pour l'étiquette IDE/serveur | |
| sections = {} | |
| if config is not None: | |
| for section, etiquette in (("ide_extensions", "IDE"), ("server_extensions", "Serveur")): | |
| sections[etiquette] = set(name.casefold() for name in config.sections.get(section) or ()) | |
| cites = set() # les fichiers déjà portés par un tableau | |
| def ligne(name: str, cellules: t.List[str]) -> t.Tuple[str, int, int]: | |
| """Une ligne de tableau, la taille du fichier, et 1 s'il est bien là.""" | |
| cites.add(name) | |
| stat = file_stat(dest_dir / name) | |
| if stat is None: | |
| cellules = cellules + ["<td class=taille>absent</td>", "<td class=date>—</td>"] | |
| return "<tr class=absent>" + "".join(cellules) + "</tr>", 0, 0 | |
| size, date = stat | |
| cellules = cellules + [ | |
| f"<td class=taille>{esc(human_size(size))}</td>", | |
| f"<td class=date>{esc(date)}</td>", | |
| ] | |
| return "<tr>" + "".join(cellules) + "</tr>", size, 1 | |
| def lien(name: str) -> str: | |
| return f'<td class=fichier><a href="{esc(quote(name))}">{esc(name)}</a></td>' | |
| # Visual Studio Code | |
| code_rows, code_total, code_count = [], 0, 0 | |
| files = catalog.get("files", {}) | |
| for key, produit, precision in CODE_FILES: | |
| name = files.get(key) | |
| if not name: | |
| continue | |
| row, size, present = ligne( | |
| name, [lien(name), f"<td>{esc(produit)}</td>", f"<td class=desc>{esc(precision)}</td>"] | |
| ) | |
| code_rows.append(row) | |
| code_total += size | |
| code_count += present | |
| # extensions: tous les .vsix présents, décrits par leur asset quand on l'a | |
| def cle(name: str): | |
| asset = assets.get(name) | |
| if asset: | |
| return asset.name.casefold(), str(asset.platform), version_serial(asset.version) | |
| return name.casefold(), "", () | |
| ext_rows, ext_total, ext_count = [], 0, 0 | |
| vsix_files = sorted((file.name for file in dest_dir.glob("*.vsix") if not index_skip(file.name)), key=cle) | |
| for name in vsix_files: | |
| asset = assets.get(name) | |
| if asset: | |
| identifiant = asset.name | |
| version = asset.version | |
| platform = asset.platform or "toutes" | |
| engine = asset.engine or "—" | |
| else: | |
| identifiant, version, platform, engine = name[:-5], "—", "—", "—" | |
| badges = "".join( | |
| f"<span class=badge>{esc(etiquette)}</span>" | |
| for etiquette, noms in sections.items() | |
| if identifiant.casefold() in noms | |
| ) | |
| row, size, present = ligne( | |
| name, | |
| [ | |
| f'<td><a href="{esc(quote(name))}">{esc(identifiant)}</a></td>', | |
| f"<td class=desc>{esc(version)}</td>", | |
| f"<td class=desc>{esc(platform)}</td>", | |
| f"<td class=desc>{esc(engine)}</td>", | |
| f"<td>{badges}</td>", | |
| ], | |
| ) | |
| ext_rows.append(row) | |
| ext_total += size | |
| ext_count += present | |
| # ce qui reste: tout fichier téléchargé qui n'entre dans aucune des deux listes | |
| autres_rows, autres_total = [], 0 | |
| for file in sorted(dest_dir.iterdir(), key=lambda f: f.name.casefold()): | |
| if not file.is_file() or file.name in cites or index_skip(file.name): | |
| continue | |
| row, size, _ = ligne(file.name, [lien(file.name)]) | |
| autres_rows.append(row) | |
| autres_total += size | |
| # la page | |
| version = catalog.get("version") or "?" | |
| commit = catalog.get("commit") or "" | |
| channel = catalog.get("channel") or "" | |
| titre = f"Visual Studio Code {version} — distribution hors ligne" | |
| entete = [f"Version <code>{esc(version)}</code>"] | |
| if channel: | |
| entete.append(f"canal <code>{esc(channel)}</code>") | |
| if commit: | |
| entete.append(f"commit <code>{esc(commit)}</code>") | |
| entete.append(f"répertoire <code>{esc(dest_dir.name)}</code>") | |
| total = code_total + ext_total + autres_total | |
| compte = code_count + ext_count + len(autres_rows) | |
| def tableau(titre: str, compte: str, colonnes: t.List[str], rows: t.List[str]) -> str: | |
| if not rows: | |
| return "" | |
| entetes = "".join(f"<th>{esc(c)}</th>" for c in colonnes) | |
| return ( | |
| f"<h2>{esc(titre)}<span class=compte>{esc(compte)}</span></h2>\n" | |
| f"<table>\n<thead><tr>{entetes}</tr></thead>\n<tbody>\n" + "\n".join(rows) + "\n</tbody>\n</table>" | |
| ) | |
| corps = [ | |
| tableau( | |
| "Visual Studio Code", | |
| f"{code_count} fichier{pluriel(code_count)} · {human_size(code_total)}", | |
| ["Fichier", "Produit", "Cible", "Taille", "Date"], | |
| code_rows, | |
| ), | |
| tableau( | |
| "Extensions", | |
| f"{ext_count} fichier{pluriel(ext_count)} · {human_size(ext_total)}", | |
| ["Extension", "Version", "Plateforme", "Moteur", "Sections", "Taille", "Date"], | |
| ext_rows, | |
| ), | |
| tableau( | |
| "Autres fichiers", | |
| f"{len(autres_rows)} fichier{pluriel(len(autres_rows))} · {human_size(autres_total)}", | |
| ["Fichier", "Taille", "Date"], | |
| autres_rows, | |
| ), | |
| ] | |
| page = f"""\ | |
| <!DOCTYPE html> | |
| <html lang="fr"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>{esc(titre)}</title> | |
| <style> | |
| {INDEX_CSS}</style> | |
| </head> | |
| <body> | |
| <main> | |
| <h1>{esc(titre)}</h1> | |
| <p class=entete>{" · ".join(entete)}</p> | |
| <p class=entete>{compte} fichier{pluriel(compte)} · {esc(human_size(total))}</p> | |
| {chr(10).join(bloc for bloc in corps if bloc)} | |
| <footer>Page produite le {esc(datetime.now().strftime("%Y-%m-%d %H:%M"))} par <code>vscode-offline.py</code>.</footer> | |
| </main> | |
| </body> | |
| </html> | |
| """ | |
| index = dest_dir / "index.html" | |
| index.write_text(page, encoding="utf-8") | |
| print(f"page écrite: {index}") | |
| def write_code_assets(assets_file: Path, assets: t.Dict[str, str]): | |
| if assets_file.is_file(): | |
| config = assets_file.read_text() | |
| else: | |
| config = "" | |
| new_config = [] | |
| for k, v in assets.items(): | |
| new_config.append(f"{k}={v}") | |
| for line in config.splitlines(): | |
| for k in assets.keys(): | |
| if line.lstrip().startswith(f"{k}="): | |
| break | |
| else: | |
| new_config.append(line) | |
| config = "\n".join(new_config) + "\n" | |
| # old = config_file.with_suffix(".old") | |
| # if old.is_file(): | |
| # old.unlink() | |
| # if config_file.is_file(): | |
| # config_file.rename(old) | |
| assets_file.write_text(config) | |
| def get_version_dest_dir(engine: t.Optional[str], dest_dir: t.Optional[Path]) -> t.Tuple[CodeVersion, Path]: | |
| if dest_dir and (dest_dir / "files").is_file(): | |
| version = read_code_version(dest_dir / "files") | |
| if not engine or engine == version.version: | |
| print(f"Using Visual Studio Code {BRIGHT_GREEN}{version.version}{RESET} (from {dest_dir})") | |
| return version, dest_dir | |
| if not engine or engine == "latest": | |
| version = get_code_version("latest") | |
| print(f"Using Visual Studio Code {BRIGHT_GREEN}{version.version}{RESET} (latest)") | |
| elif re.match(r"\d+\.\d+\.\d+", engine): | |
| version = get_code_version(engine) | |
| print(f"Using Visual Studio Code {BRIGHT_GREEN}{version.version}{RESET} (requested)") | |
| else: | |
| logging.error(f"Unknown version {engine}") | |
| sys.exit(1) | |
| if not dest_dir: | |
| if Path.cwd().name == "vscode": | |
| dest_dir = Path(f"dist-{version.version}") | |
| else: | |
| dest_dir = Path(f"code-{version.version}") | |
| print(f"Using dest_dir {BRIGHT_GREEN}{dest_dir}{RESET}") | |
| dest_dir.mkdir(exist_ok=True, parents=True) | |
| return version, dest_dir | |
| def download_code(dest_dir: Path, url: str) -> str: | |
| r = session.head(url) | |
| real_url = r.headers.get("location") | |
| if real_url is None: | |
| print("no Location header:", url, r) | |
| sys.exit(1) | |
| filename = Path(real_url).name | |
| file = dest_dir / filename | |
| if file.is_file(): | |
| digest = hashlib.sha256(file.read_bytes()).hexdigest() | |
| if digest != r.headers["X-SHA256"]: | |
| file.unlink() | |
| if not file.is_file(): | |
| download_file(real_url, file) | |
| else: | |
| print(f"already downloaded: {filename}") | |
| return filename | |
| def download_code_assets(code: CodeVersion, dest_dir: Path) -> t.Dict[str, str]: | |
| assets = dict() | |
| assets["version"] = code.version | |
| assets["commit"] = code.commit_id | |
| assets["channel"] = code.channel | |
| # the following mess is found here: | |
| # https://github.com/microsoft/vscode/blob/master/cli/src/update_service.rs#L224 | |
| # https://code.visualstudio.com/docs/supporting/FAQ | |
| urls = { | |
| # archive for Windows and Linux | |
| "code_win32": f"https://update.code.visualstudio.com/{code.version}/win32-x64-archive/{code.channel}", | |
| "code_tar": f"https://update.code.visualstudio.com/{code.version}/linux-x64/{code.channel}", | |
| "code_deb": f"https://update.code.visualstudio.com/{code.version}/linux-deb-x64/{code.channel}", | |
| # headless (server) for Linux (glibc) | |
| "server_linux": f"https://update.code.visualstudio.com/{code.version}/server-linux-x64/{code.channel}", | |
| # headless (server) for Alpine Linux (musl-libc) | |
| # "server_linux_alpine": f"https://update.code.visualstudio.com/{version}/server-linux-alpine/{channel}", | |
| # cli for Linux | |
| "cli_linux": f"https://update.code.visualstudio.com/{code.version}/cli-linux-x64/{code.channel}", | |
| # cli for Alpine | |
| # "cli_linux_alpine": f"https://update.code.visualstudio.com/{version}/cli-alpine-x64/{channel}", | |
| } | |
| for name, url in urls.items(): | |
| assets[name] = download_code(dest_dir, url) | |
| return assets | |
| def set_verbosity(verbose: bool): | |
| format = f"{GREEN}%(asctime)s{RESET}{FADE} - %(levelname)s - %(message)s{RESET}" | |
| datefmt = None # "%H:%M:%S" | |
| if verbose: | |
| logging.basicConfig(format=format, datefmt=datefmt, level=logging.DEBUG) | |
| else: | |
| logging.basicConfig(format=format, datefmt=datefmt, level=logging.INFO) | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("-v", "--verbose", help="verbose and debug info", action="store_true") | |
| parser.add_argument("-d", "--dest-dir", help="output dir", type=Path) | |
| parser.add_argument("-e", "--version", help="vscode version") | |
| parser.add_argument("-E", "--extensions-only", help="download only extensions", action="store_true") | |
| parser.add_argument("-c", "--config", help="configuration file", type=Path) | |
| parser.add_argument("--local", help="get extensions from local Code", action="store_true") | |
| parser.add_argument("--compare-local", action="store_true") | |
| parser.add_argument("-p", "--prune", help="prune old and unwanted extensions", action="store_true") | |
| parser.add_argument("ID", help="extension identifier", nargs="*") | |
| args = parser.parse_args() | |
| set_verbosity(args.verbose) | |
| # get the version and destination | |
| version, dest_dir = get_version_dest_dir(args.version, args.dest_dir) | |
| if not args.config: | |
| args.config = Path("vscode-offline.conf") | |
| if not args.config.is_file(): | |
| args.config = dest_dir / "files" | |
| config = Config(args.config, args.ID, args.local) | |
| # comparse list of extensions with installed ones | |
| if args.compare_local: | |
| exit(compare_local(config.all_extensions)) | |
| # code and code-server | |
| assets = {} | |
| if not args.extensions_only: | |
| assets = download_code_assets(version, dest_dir) | |
| write_code_assets(dest_dir / "files", assets) | |
| # extensions | |
| exts = Extensions(version.version, dest_dir, args.verbose) | |
| exts.run(config.all_extensions) | |
| if args.prune: | |
| exts.prune() | |
| write_extension_assets(dest_dir / "files", config, exts.assets()) | |
| # catalogue JSON, lu par la version Go de cll | |
| catalog = write_json_catalog(dest_dir, assets, config, exts.assets()) | |
| # page d'index, qui liste ce qui a été téléchargé | |
| write_index_html(dest_dir, catalog, config, exts.assets()) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment