diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e2bce1..d7772af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,4 +35,4 @@ jobs: uv sync --extra dev - name: Run Tests run: | - uv run python -m pytest tests/ -v --tb=short -n auto + uv run python -m pytest tests/ -v --tb=short -n auto -m "not slow" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 58a9159..a23d46a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,4 +32,7 @@ jobs: run: uv run ruff check . - name: Run ruff format check - run: uv run ruff format --check . \ No newline at end of file + run: uv run ruff format --check . + + - name: Run mypy type checking + run: uv run mypy . --config-file pyproject.toml \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index bc55426..f0792a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,8 @@ dev = [ "ruff>=0.1.0", "mypy>=1.0.0", "types-setuptools", + "types-requests", + "types-urllib3", # Development tools "pre-commit>=3.5.0", ] @@ -113,4 +115,27 @@ markers = [ "linux: marks tests to run only on Linux", "macos: marks tests to run only on macOS", "windows: marks tests to run only on Windows", + "slow: marks tests as slow running", ] + +[tool.mypy] +python_version = "3.9" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +# Additional strict checks for maximum safety +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true +extra_checks = true +# Error on missing imports (don't allow implicit Any) +disallow_any_unimported = true + +[tool.uv] +python-preference = "only-managed" diff --git a/run_tests.py b/run_tests.py index ce8bab5..1fada27 100755 --- a/run_tests.py +++ b/run_tests.py @@ -10,7 +10,7 @@ import sys -def main(): +def main() -> int: """Run the test suite.""" # Basic pytest command cmd = [sys.executable, "-m", "pytest", "tests/", "-v"] diff --git a/solc_select/__main__.py b/solc_select/__main__.py index 417fa09..eafb1d4 100644 --- a/solc_select/__main__.py +++ b/solc_select/__main__.py @@ -1,115 +1,176 @@ import argparse -import subprocess import sys +from typing import List from .constants import ( - ARTIFACTS_DIR, - INSTALL_VERSIONS, - SHOW_VERSIONS, - UPGRADE, - USE_VERSION, + INSTALL_COMMAND, + UPGRADE_COMMAND, + USE_COMMAND, + VERSIONS_COMMAND, ) -from .solc_select import ( - current_version, - get_emulation_prefix, - get_installable_versions, - halt_incompatible_system, - halt_old_architecture, - install_artifacts, - installed_versions, - switch_global_version, - upgrade_architecture, - valid_install_arg, - valid_version, +from .exceptions import ( + ChecksumMismatchError, + InstallationError, + NetworkError, + NoVersionSetError, + PlatformNotSupportedError, + SolcSelectError, + VersionNotFoundError, + VersionNotInstalledError, + VersionResolutionError, ) +from .services.solc_service import SolcService from .utils import sort_versions -# pylint: disable=too-many-branches -def solc_select() -> None: +def solc_select_install(service: SolcService, versions: List[str]) -> None: + """Handle the install command.""" + if not versions: + print("Available versions to install:") + installable = service.get_installable_versions() + for version in installable: + print(str(version)) + else: + success = service.install_versions(versions) + sys.exit(0 if success else 1) + + +def solc_select_use(service: SolcService, version: str, always_install: bool) -> None: + """Handle the use command.""" + service.switch_global_version(version, always_install, silent=False) + + +def solc_select_versions(service: SolcService) -> None: + """Handle the versions command.""" + installed = service.get_installed_versions() + if installed: + try: + current_version, source = service.get_current_version() + except (NoVersionSetError, VersionNotInstalledError): + # No version is currently set or not installed, that's ok for the versions command + current_version = None + + installed_strs = [str(v) for v in installed] + for version_str in sort_versions(installed_strs): + if current_version and version_str == str(current_version): + print(f"{version_str} (current, set by {source})") + else: + print(version_str) + else: + print("No solc version installed. Run `solc-select install --help` for more information") + + +def solc_select_upgrade(service: SolcService) -> None: + """Handle the upgrade command.""" + service.upgrade_architecture() + + +def create_parser() -> argparse.ArgumentParser: + """Create and configure the argument parser.""" parser = argparse.ArgumentParser() subparsers = parser.add_subparsers( help="Allows users to install and quickly switch between Solidity compiler versions", dest="command", ) + + # Install command parser_install = subparsers.add_parser( - "install", help="list and install available solc versions" + INSTALL_COMMAND, help="list and install available solc versions" ) parser_install.add_argument( - INSTALL_VERSIONS, + "versions", help='specific versions you want to install "0.4.25", "all" or "latest"', nargs="*", default=[], - type=valid_install_arg, ) - parser_use = subparsers.add_parser("use", help="change the version of global solc compiler") - parser_use.add_argument( - USE_VERSION, help="solc version you want to use (eg: 0.4.25)", type=valid_version, nargs="?" + + # Use command + parser_use = subparsers.add_parser( + USE_COMMAND, help="change the version of global solc compiler" ) + parser_use.add_argument("version", help="solc version you want to use (eg: 0.4.25)", nargs="?") parser_use.add_argument("--always-install", action="store_true") - parser_use = subparsers.add_parser("versions", help="prints out all installed solc versions") - parser_use.add_argument(SHOW_VERSIONS, nargs="*", help=argparse.SUPPRESS) - parser_use = subparsers.add_parser("upgrade", help="upgrades solc-select") - parser_use.add_argument(UPGRADE, nargs="*", help=argparse.SUPPRESS) + # Versions command + parser_versions = subparsers.add_parser( + VERSIONS_COMMAND, help="prints out all installed solc versions" + ) + parser_versions.add_argument("versions", nargs="*", help=argparse.SUPPRESS) + + # Upgrade command + parser_upgrade = subparsers.add_parser(UPGRADE_COMMAND, help="upgrades solc-select") + parser_upgrade.add_argument("upgrade", nargs="*", help=argparse.SUPPRESS) + + return parser + + +def solc_select() -> None: + parser = create_parser() args = parser.parse_args() - if args.command == "install": - versions = args.INSTALL_VERSIONS - if not versions: - print("Available versions to install:") - for version in get_installable_versions(): - print(version) - else: - status = install_artifacts(versions) - sys.exit(0 if status else 1) - - elif args.command == "use": - switch_global_version(args.USE_VERSION, args.always_install, silent=False) - - elif args.command == "versions": - versions_installed = installed_versions() - if versions_installed: - (current_ver, source) = (None, None) - try: - res = current_version() - if res: - (current_ver, source) = res - except argparse.ArgumentTypeError: - # No version is currently set, that's ok - res = None - for version in sort_versions(versions_installed): - if res and version == current_ver: - print(f"{version} (current, set by {source})") - else: - print(version) + # Create service instance + service = SolcService() + + try: + if args.command == INSTALL_COMMAND: + solc_select_install(service, args.versions) + + elif args.command == USE_COMMAND: + if not args.version: + parser.error("the following arguments are required: version") + solc_select_use(service, args.version, args.always_install) + + elif args.command == VERSIONS_COMMAND: + solc_select_versions(service) + + elif args.command == UPGRADE_COMMAND: + solc_select_upgrade(service) + else: - print( - "No solc version installed. Run `solc-select install --help` for more information" - ) - elif args.command == "upgrade": - upgrade_architecture() - else: - parser.parse_args(["--help"]) - sys.exit(0) + parser.parse_args(["--help"]) + sys.exit(0) + + except VersionNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) + if e.available_versions: + print("Hint: Run 'solc-select install' to see all available versions", file=sys.stderr) + sys.exit(1) + except ChecksumMismatchError as e: + print(f"Error: {e}", file=sys.stderr) + print("Hint: Try downloading again or report this issue if it persists", file=sys.stderr) + sys.exit(1) + except (InstallationError, NetworkError) as e: + print(f"Error: {e}", file=sys.stderr) + print("Hint: Check your network connection and try again", file=sys.stderr) + sys.exit(1) + except PlatformNotSupportedError as e: + print(f"Error: {e}", file=sys.stderr) + print("Hint: Use a newer version that supports your platform", file=sys.stderr) + sys.exit(1) + except VersionResolutionError as e: + print(f"Error: {e}", file=sys.stderr) + print("Hint: Check your network connection or specify a specific version", file=sys.stderr) + sys.exit(1) + except SolcSelectError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + print("\nOperation cancelled by user", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Unexpected error: {e}", file=sys.stderr) + sys.exit(1) def solc() -> None: - if not installed_versions(): - switch_global_version(version="latest", always_install=True, silent=True) - res = current_version() - if res: - (version, _) = res - path = ARTIFACTS_DIR.joinpath(f"solc-{version}", f"solc-{version}") - halt_old_architecture(path) - halt_incompatible_system(path) - - # Use emulation if needed for ARM64 systems - cmd = get_emulation_prefix() + [str(path)] + sys.argv[1:] + """CLI entry point for solc executable.""" + service = SolcService() - try: - subprocess.run(cmd, check=True) - except subprocess.CalledProcessError as e: - sys.exit(e.returncode) - else: + try: + service.execute_solc(sys.argv[1:]) + except KeyboardInterrupt: + print("\nOperation cancelled by user", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error executing solc: {e}", file=sys.stderr) sys.exit(1) diff --git a/solc_select/constants.py b/solc_select/constants.py index 7f52271..6e1c417 100644 --- a/solc_select/constants.py +++ b/solc_select/constants.py @@ -9,27 +9,30 @@ SOLC_SELECT_DIR = HOME_DIR.joinpath(".solc-select") ARTIFACTS_DIR = SOLC_SELECT_DIR.joinpath("artifacts") -# CLI Flags -INSTALL_VERSIONS = "INSTALL_VERSIONS" -USE_VERSION = "USE_VERSION" -SHOW_VERSIONS = "SHOW_VERSIONS" -UPGRADE = "UPGRADE" +# CLI Commands +INSTALL_COMMAND = "install" +USE_COMMAND = "use" +VERSIONS_COMMAND = "versions" +UPGRADE_COMMAND = "upgrade" +# soliditylang.org platform strings LINUX_AMD64 = "linux-amd64" MACOSX_AMD64 = "macosx-amd64" WINDOWS_AMD64 = "windows-amd64" +# earliest releases supported in each platform EARLIEST_RELEASE = {"macosx-amd64": "0.3.6", "linux-amd64": "0.4.0", "windows-amd64": "0.4.5"} -# URL +# crytic/solc repo URLs CRYTIC_SOLC_ARTIFACTS = "https://raw.githubusercontent.com/crytic/solc/master/linux/amd64/" CRYTIC_SOLC_JSON = ( "https://raw.githubusercontent.com/crytic/solc/new-list-json/linux/amd64/list.json" ) +# alloy-rs/solc-builds repo URLs ALLOY_SOLC_ARTIFACTS = "https://raw.githubusercontent.com/alloy-rs/solc-builds/203ef20a24a6c2cb763e1c8c4c1836e85db2512d/macosx/aarch64/" ALLOY_SOLC_JSON = "https://raw.githubusercontent.com/alloy-rs/solc-builds/203ef20a24a6c2cb763e1c8c4c1836e85db2512d/macosx/aarch64/list.json" -# Alloy ARM64 version range (0.8.24+ are universal binaries) +# Alloy ARM64 repo version range (0.8.24+ are universal binaries on soliditylang.org) ALLOY_ARM64_MIN_VERSION = "0.8.5" ALLOY_ARM64_MAX_VERSION = "0.8.23" diff --git a/solc_select/exceptions.py b/solc_select/exceptions.py new file mode 100644 index 0000000..9721bf3 --- /dev/null +++ b/solc_select/exceptions.py @@ -0,0 +1,159 @@ +""" +Custom exception classes for solc-select. + +This module provides a structured exception hierarchy for better error handling +and more informative error messages throughout the application. +""" + +from typing import List, Optional + + +class SolcSelectError(Exception): + """Base exception for all solc-select errors.""" + + pass + + +class VersionNotFoundError(SolcSelectError): + """Raised when requested version doesn't exist or isn't available.""" + + def __init__( + self, + version: str, + available_versions: Optional[List[str]] = None, + suggestion: Optional[str] = None, + ): + self.version = version + self.available_versions = available_versions or [] + self.suggestion = suggestion + + message = f"Version '{version}' not found" + + if available_versions: + if len(available_versions) <= 5: + message += f". Available versions: {', '.join(available_versions)}" + else: + message += f". {len(available_versions)} versions available. Run 'solc-select install' to see all options" + + if suggestion: + message += f". {suggestion}" + + super().__init__(message) + + +class VersionNotInstalledError(SolcSelectError): + """Raised when trying to use a version that isn't installed.""" + + def __init__( + self, + version: str, + installed_versions: Optional[List[str]] = None, + source: Optional[str] = None, + ): + self.version = version + self.installed_versions = installed_versions or [] + self.source = source + + message = f"Version '{version}' is not installed" + + if source: + message += f" (set by {source})" + + message += f". Run `solc-select install {version}` to install it" + + if installed_versions: + if len(installed_versions) <= 3: + message += f", or use one of: {', '.join(installed_versions)}" + else: + message += f". You have {len(installed_versions)} versions installed. Run 'solc-select versions' to see them" + + super().__init__(message) + + +class PlatformNotSupportedError(SolcSelectError): + """Raised when platform is not supported for a specific version.""" + + def __init__(self, version: str, platform: str, min_version: Optional[str] = None): + self.version = version + self.platform = platform + self.min_version = min_version + + message = f"Version '{version}' is not supported on {platform}" + + if min_version: + message += f". Minimum supported version is '{min_version}'" + + super().__init__(message) + + +class ChecksumMismatchError(SolcSelectError): + """Raised when downloaded file checksum doesn't match expected value.""" + + def __init__(self, expected: str, actual: str, algorithm: str = "SHA256"): + self.expected = expected + self.actual = actual + self.algorithm = algorithm + + super().__init__( + f"{algorithm} checksum verification failed. " + f"Expected: {expected}, Got: {actual}. " + f"This could indicate a corrupted download or security issue." + ) + + +class InstallationError(SolcSelectError): + """Raised when installation fails.""" + + def __init__(self, version: str, reason: str): + self.version = version + self.reason = reason + + super().__init__(f"Failed to install version '{version}': {reason}") + + +class NetworkError(SolcSelectError): + """Raised when network operations fail.""" + + def __init__( + self, operation: str, url: Optional[str] = None, original_error: Optional[Exception] = None + ): + self.operation = operation + self.url = url + self.original_error = original_error + + message = f"Network error during {operation}" + + if url: + message += f" from {url}" + + if original_error: + message += f": {original_error!s}" + + super().__init__(message) + + +class VersionResolutionError(SolcSelectError): + """Raised when version resolution fails (e.g., 'latest' can't be determined).""" + + def __init__(self, requested: str, reason: str): + self.requested = requested + self.reason = reason + + super().__init__(f"Could not resolve version '{requested}': {reason}") + + +class NoVersionSetError(SolcSelectError): + """Raised when no solc version is currently set.""" + + def __init__(self) -> None: + super().__init__( + "No solc version set. Run `solc-select use VERSION` or set SOLC_VERSION environment variable." + ) + + +class ArchitectureUpgradeError(SolcSelectError): + """Raised when architecture upgrade fails.""" + + def __init__(self, reason: str): + self.reason = reason + super().__init__(f"Failed to upgrade solc-select architecture: {reason}") diff --git a/solc_select/infrastructure/__init__.py b/solc_select/infrastructure/__init__.py new file mode 100644 index 0000000..9d8aead --- /dev/null +++ b/solc_select/infrastructure/__init__.py @@ -0,0 +1,6 @@ +""" +Infrastructure layer for solc-select. + +This package contains low-level infrastructure concerns like +filesystem operations and HTTP client configuration. +""" diff --git a/solc_select/infrastructure/filesystem.py b/solc_select/infrastructure/filesystem.py new file mode 100644 index 0000000..03f60e5 --- /dev/null +++ b/solc_select/infrastructure/filesystem.py @@ -0,0 +1,118 @@ +""" +Filesystem operations for solc-select. + +This module handles file system operations including version storage, +configuration management, and directory operations. +""" + +import os +import shutil +from pathlib import Path +from typing import Optional + +from ..constants import ARTIFACTS_DIR, SOLC_SELECT_DIR +from ..models import SolcVersion + + +class FilesystemManager: + """Manages filesystem operations for solc-select.""" + + def __init__(self) -> None: + self.artifacts_dir = ARTIFACTS_DIR + self.config_dir = SOLC_SELECT_DIR + self._ensure_directories() + + def _ensure_directories(self) -> None: + """Ensure required directories exist.""" + self.artifacts_dir.mkdir(parents=True, exist_ok=True) + self.config_dir.mkdir(parents=True, exist_ok=True) + + def get_current_version(self) -> Optional[SolcVersion]: + """Get the currently selected version. + + Returns: + Currently selected version or None if not set + """ + # Check environment variable first + env_version = os.environ.get("SOLC_VERSION") + if env_version: + try: + return SolcVersion.parse(env_version) + except ValueError: + return None + + # Check global version file + global_version_file = self.config_dir / "global-version" + if global_version_file.exists(): + try: + with open(global_version_file, encoding="utf-8") as f: + version_str = f.read().strip() + return SolcVersion.parse(version_str) + except (OSError, ValueError): + return None + + return None + + def set_global_version(self, version: SolcVersion) -> None: + """Set the global version. + + Args: + version: Version to set as global + """ + global_version_file = self.config_dir / "global-version" + with open(global_version_file, "w", encoding="utf-8") as f: + f.write(str(version)) + + def get_version_source(self) -> str: + """Get the source of the current version setting. + + Returns: + Source description (environment variable or file path) + """ + if os.environ.get("SOLC_VERSION"): + return "SOLC_VERSION" + + global_version_file = self.config_dir / "global-version" + return global_version_file.as_posix() + + def get_artifact_directory(self, version: SolcVersion) -> Path: + """Get the directory for a version's artifacts. + + Args: + version: Version to get directory for + + Returns: + Path to the artifact directory + """ + return self.artifacts_dir / f"solc-{version}" + + def get_binary_path(self, version: SolcVersion) -> Path: + """Get the path to a version's binary. + + Args: + version: Version to get binary path for + + Returns: + Path to the binary executable + """ + artifact_dir = self.get_artifact_directory(version) + return artifact_dir / f"solc-{version}" + + def cleanup_artifacts_directory(self) -> None: + """Remove the entire artifacts directory for upgrades.""" + if self.artifacts_dir.exists(): + shutil.rmtree(self.artifacts_dir) + self.artifacts_dir.mkdir(parents=True, exist_ok=True) + + def is_legacy_installation(self, version: SolcVersion) -> bool: + """Check if a version uses the old installation format. + + Args: + version: Version to check + + Returns: + True if using legacy format, False otherwise + """ + # Legacy format: artifacts/solc-{version} (file instead of directory) + legacy_path = self.artifacts_dir / f"solc-{version}" + return legacy_path.exists() and legacy_path.is_file() diff --git a/solc_select/infrastructure/http_client.py b/solc_select/infrastructure/http_client.py new file mode 100644 index 0000000..bc40c35 --- /dev/null +++ b/solc_select/infrastructure/http_client.py @@ -0,0 +1,51 @@ +""" +HTTP client configuration for solc-select. + +This module provides centralized HTTP client configuration with +retry logic and proper timeout handling. +""" + +from typing import Any, Mapping, Optional, Tuple, Union + +import requests +from requests.adapters import HTTPAdapter +from requests.models import PreparedRequest, Response +from urllib3.util.retry import Retry + + +class TimeoutHTTPAdapter(HTTPAdapter): + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.timeout = kwargs.pop("timeout", None) + super().__init__(*args, **kwargs) + + def send( + self, + request: PreparedRequest, + stream: bool = False, + timeout: Union[float, Tuple[float, float], Tuple[float, None], None] = None, + verify: Union[bool, str] = True, + cert: Union[bytes, str, Tuple[Union[bytes, str], Union[bytes, str]], None] = None, + proxies: Optional[Mapping[str, str]] = None, + ) -> Response: + timeout = timeout or self.timeout + return super().send( + request, stream=stream, timeout=timeout, verify=verify, cert=cert, proxies=proxies + ) + + +def create_http_session() -> requests.Session: + """Create a new HTTP session with retry logic for rate limits and server errors.""" + session = requests.Session() + + # Configure retry strategy for 429s and server errors + retry_strategy = Retry( + total=5, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + ) + + adapter = TimeoutHTTPAdapter(timeout=(10, 30), max_retries=retry_strategy) + session.mount("http://", adapter) + session.mount("https://", adapter) + + return session diff --git a/solc_select/models.py b/solc_select/models.py new file mode 100644 index 0000000..f190044 --- /dev/null +++ b/solc_select/models.py @@ -0,0 +1,285 @@ +""" +Domain models for solc-select. + +This module contains the core business entities and value objects that represent +the domain concepts of Solidity compiler version management. +""" + +import platform +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from packaging.version import Version + +from .constants import ( + EARLIEST_RELEASE, + LINUX_AMD64, + MACOSX_AMD64, + WINDOWS_AMD64, +) + + +class SolcVersion(Version): + """Represents a Solidity compiler version, inheriting from packaging.Version.""" + + @classmethod + def parse(cls, version_str: str) -> "SolcVersion": + """Parse a version string into a SolcVersion instance. + + Args: + version_str: Version string like '0.8.19' + + Returns: + SolcVersion instance + + Raises: + ValueError: If version string format is invalid + """ + if version_str == "latest": + raise ValueError("Cannot parse 'latest' - resolve to actual version first") + + # Let packaging.Version handle the parsing and validation + return cls(version_str) + + def is_compatible_with_platform(self, platform: "Platform") -> bool: + """Check if this version is compatible with the given platform. + + Args: + platform: The target platform + + Returns: + True if compatible, False otherwise + """ + platform_key = platform.get_soliditylang_key() + if platform_key not in EARLIEST_RELEASE: + return False + + earliest = Version(EARLIEST_RELEASE[platform_key]) + return self >= earliest + + +@dataclass(frozen=True) +class Platform: + """Represents a target platform for Solidity compilation.""" + + os_type: str # 'linux', 'darwin', 'windows' + architecture: str # 'amd64', 'arm64' + + def __post_init__(self) -> None: + """Validate platform components.""" + valid_os = {"linux", "darwin", "windows"} + valid_arch = {"amd64", "arm64", "386"} + + if self.os_type not in valid_os: + raise ValueError(f"Invalid OS type: {self.os_type}") + if self.architecture not in valid_arch: + raise ValueError(f"Invalid architecture: {self.architecture}") + + # ======================================== + # CORE PLATFORM DETECTION + # ======================================== + + @classmethod + def current(cls) -> "Platform": + """Get the current system platform.""" + if sys.platform == "linux": + os_type = "linux" + elif sys.platform == "darwin": + os_type = "darwin" + elif sys.platform in ["win32", "cygwin"]: + os_type = "windows" + else: + raise ValueError(f"Unsupported platform: {sys.platform}") + + architecture = cls._get_arch() + return cls(os_type=os_type, architecture=architecture) + + @staticmethod + def _get_arch() -> str: + """Get the current system architecture.""" + machine = platform.machine().lower() + if machine in ["x86_64", "amd64"]: + return "amd64" + elif machine in ["aarch64", "arm64"]: + return "arm64" + elif machine in ["i386", "i686"]: + return "386" + return machine + + def get_soliditylang_key(self) -> str: + """Get the platform key used by binaries.soliditylang.org.""" + if self.os_type == "linux" and self.architecture == "amd64": + return LINUX_AMD64 + elif self.os_type == "darwin" and self.architecture in ["amd64", "arm64"]: + # soliditylang.org uses macosx-amd64 for both Intel and ARM (with Rosetta) + return MACOSX_AMD64 + elif self.os_type == "windows" and self.architecture == "amd64": + return WINDOWS_AMD64 + else: + raise ValueError( + f"Unsupported platform combination: {self.os_type}-{self.architecture}" + ) + + # ======================================== + # EMULATION CAPABILITIES + # ======================================== + + def has_rosetta(self) -> bool: + """Check if Rosetta 2 is available on macOS ARM64.""" + if self.os_type != "darwin" or self.architecture != "arm64": + return False + + # Check if oahd (Rosetta daemon) is running + try: + result = subprocess.run(["pgrep", "-q", "oahd"], capture_output=True, check=False) + return result.returncode == 0 + except (FileNotFoundError, OSError): + return False + + def has_qemu(self) -> bool: + """Check if qemu-x86_64 is available on Linux ARM64.""" + if self.os_type != "linux" or self.architecture != "arm64": + return False + + try: + result = subprocess.run( + ["which", "qemu-x86_64"], capture_output=True, text=True, check=False + ) + return result.returncode == 0 + except (FileNotFoundError, OSError): + return False + + def can_run_x86_binaries(self) -> bool: + """Check if this platform can run x86_64 binaries (natively or via emulation).""" + # Native x86_64 platforms can always run x86 binaries + if self.architecture == "amd64": + return True + + # ARM64 platforms need emulation + if self.architecture == "arm64": + if self.os_type == "darwin": + return self.has_rosetta() + elif self.os_type == "linux": + return self.has_qemu() + + return False + + # ======================================== + # BINARY COMPATIBILITY + # ======================================== + + def can_run_binary(self, binary_path: Path) -> bool: + """Check if we can run a binary on this platform. + + Args: + binary_path: Path to the binary + + Returns: + True if binary can be executed, False otherwise + """ + if not binary_path.exists(): + return False + + # Native architecture can always run + if self.architecture == "amd64": + return True + + # ARM64 platforms need special handling + if self.architecture == "arm64": + if self.os_type == "darwin": + return self._can_run_darwin_binary(binary_path) + else: + # Other ARM64 platforms need x86 emulation + return self.can_run_x86_binaries() + + return True + + def _can_run_darwin_binary(self, binary_path: Path) -> bool: + """Check if we can run a binary on macOS ARM64. + + Handles universal binaries, native ARM64 binaries, and Rosetta emulation. + """ + # If Rosetta is available, we can run anything + if self.has_rosetta(): + return True + + # Check if it's a universal binary (works natively) + if self._mac_binary_is_universal(binary_path): + return True + + # Check if it's native ARM64 + return self._mac_binary_is_native(binary_path) + + # ======================================== + # PRIVATE HELPERS + # ======================================== + + def _mac_binary_is_universal(self, path: Path) -> bool: + """Check if the Mac binary is Universal or not.""" + if self.os_type != "darwin": + return False + + try: + result = subprocess.run(["/usr/bin/file", str(path)], capture_output=True, check=False) + if result.returncode != 0: + return False + + output = result.stdout.decode() + return all(text in output for text in ("Mach-O universal binary", "x86_64", "arm64")) + except (FileNotFoundError, OSError): + return False + + def _mac_binary_is_native(self, path: Path) -> bool: + """Check if the Mac binary matches the current system architecture.""" + if self.os_type != "darwin": + return False + + try: + result = subprocess.run(["/usr/bin/file", str(path)], capture_output=True, check=False) + if result.returncode != 0: + return False + + output = result.stdout.decode() + arch_in_file = "arm64" if self.architecture == "arm64" else "x86_64" + return "Mach-O" in output and arch_in_file in output + except (FileNotFoundError, OSError): + return False + + +@dataclass +class SolcArtifact: + """Represents a downloadable Solidity compiler artifact.""" + + version: SolcVersion + platform: Platform + download_url: str + checksum_sha256: str + checksum_keccak256: Optional[str] + file_path: Path + + def __post_init__(self) -> None: + """Validate artifact properties.""" + if not self.download_url: + raise ValueError("Download URL cannot be empty") + if not self.checksum_sha256: + raise ValueError("SHA256 checksum cannot be empty") + if self.checksum_sha256.startswith("0x"): + # Normalize by removing 0x prefix + object.__setattr__(self, "checksum_sha256", self.checksum_sha256[2:]) + if self.checksum_keccak256 and self.checksum_keccak256.startswith("0x"): + # Normalize by removing 0x prefix + object.__setattr__(self, "checksum_keccak256", self.checksum_keccak256[2:]) + + @property + def is_zip_archive(self) -> bool: + """Check if this artifact is a ZIP archive (older Windows versions).""" + return self.platform.os_type == "windows" and self.version <= Version("0.7.1") + + def get_binary_name_in_zip(self) -> str: + """Get the binary name inside ZIP archives.""" + if not self.is_zip_archive: + raise ValueError("Not a ZIP archive") + return "solc.exe" diff --git a/solc_select/repositories.py b/solc_select/repositories.py new file mode 100644 index 0000000..537853c --- /dev/null +++ b/solc_select/repositories.py @@ -0,0 +1,276 @@ +""" +Repository pattern implementations for solc-select. + +This module provides abstractions for fetching Solidity compiler version information +and artifacts from different sources (soliditylang.org, crytic, alloy, etc.). +""" + +from abc import ABC, abstractmethod +from functools import lru_cache +from typing import Any, Dict, List, Optional, Tuple + +import requests +from packaging.version import Version + +from .constants import ( + ALLOY_ARM64_MAX_VERSION, + ALLOY_ARM64_MIN_VERSION, + ALLOY_SOLC_ARTIFACTS, + ALLOY_SOLC_JSON, + CRYTIC_SOLC_ARTIFACTS, + CRYTIC_SOLC_JSON, + EARLIEST_RELEASE, + LINUX_AMD64, +) +from .models import Platform, SolcVersion + + +class AbstractSolcRepository(ABC): + """Abstract base class for Solidity compiler repositories.""" + + def __init__(self, session: requests.Session) -> None: + self.session = session + + @property + @abstractmethod + def base_url(self) -> str: + """Get the base URL for downloading artifacts.""" + pass + + @property + @abstractmethod + def list_url(self) -> str: + """Get the URL for the list.json file containing version information.""" + pass + + @lru_cache(maxsize=5) # noqa: B019 + def _fetch_list_json(self) -> Dict[str, Any]: + """Fetch and cache the list.json data from the repository. + + Returns: + The parsed JSON data from the list.json endpoint + """ + response = self.session.get(self.list_url) + response.raise_for_status() + return response.json() # type: ignore[no-any-return] + + @property + @lru_cache(maxsize=5) # noqa: B019 + def available_versions(self) -> Dict[str, str]: + """Get available versions as a dict of version -> artifact_filename.""" + list_data = self._fetch_list_json() + all_releases = list_data["releases"] + return self._filter_versions(all_releases) + + @property + @lru_cache(maxsize=5) # noqa: B019 + def latest_version(self) -> SolcVersion: + """Get the latest available version. + + Default implementation parses from available versions. + Subclasses can override this for more efficient implementations. + """ + versions = self.available_versions + if not versions: + raise ValueError("No versions available") + + version_objs = [SolcVersion.parse(v) for v in versions] + return max(version_objs) + + def _filter_versions(self, releases: Dict[str, str]) -> Dict[str, str]: + """Filter versions based on repository-specific criteria. + + Override this method to apply custom filtering logic. + Default implementation returns all versions. + """ + return releases + + def get_download_url(self, version: SolcVersion, artifact_filename: str) -> str: + """Get the download URL for a specific version.""" + return f"{self.base_url}{artifact_filename}" + + def get_checksums(self, version: SolcVersion) -> Tuple[str, Optional[str]]: + """Get SHA256 and optional Keccak256 checksums for a version.""" + list_data = self._fetch_list_json() + builds = list_data["builds"] + + version_str = str(version) + matches = [b for b in builds if b["version"] == version_str] + + if not matches or not matches[0]["sha256"]: + raise ValueError(f"Unable to retrieve checksum for {version}") + + sha256_hash = matches[0]["sha256"] + keccak256_hash = matches[0].get("keccak256") + + # Normalize checksums by removing 0x prefix if present + if sha256_hash and sha256_hash.startswith("0x"): + sha256_hash = sha256_hash[2:] + if keccak256_hash and keccak256_hash.startswith("0x"): + keccak256_hash = keccak256_hash[2:] + + return sha256_hash, keccak256_hash + + @abstractmethod + def supports_version(self, version: SolcVersion, platform: Platform) -> bool: + """Check if this repository supports the given version on the platform.""" + pass + + +class SoliditylangRepository(AbstractSolcRepository): + """Repository for binaries.soliditylang.org - the main Solidity releases.""" + + def __init__(self, platform: Platform, session: requests.Session) -> None: + super().__init__(session) + self.platform = platform + platform_key = platform.get_soliditylang_key() + self._base_url = f"https://binaries.soliditylang.org/{platform_key}/" + self._list_url = f"https://binaries.soliditylang.org/{platform_key}/list.json" + + @property + def base_url(self) -> str: + return self._base_url + + @property + def list_url(self) -> str: + return self._list_url + + def supports_version(self, version: SolcVersion, platform: Platform) -> bool: + """Check if this repository supports the version on the platform.""" + return version.is_compatible_with_platform(platform) + + @property + @lru_cache(maxsize=5) # noqa: B019 + def latest_version(self) -> SolcVersion: + """Get the latest available version.""" + list_data = self._fetch_list_json() + latest_str = list_data["latestRelease"] + return SolcVersion.parse(latest_str) + + +class CryticRepository(AbstractSolcRepository): + """Repository for crytic/solc - provides additional Linux versions.""" + + def __init__(self, session: requests.Session) -> None: + super().__init__(session) + + @property + def base_url(self) -> str: + return CRYTIC_SOLC_ARTIFACTS + + @property + def list_url(self) -> str: + return CRYTIC_SOLC_JSON + + def supports_version(self, version: SolcVersion, platform: Platform) -> bool: + """Check if this repository supports the version.""" + # Crytic repo is for Linux AMD64 only + if platform.get_soliditylang_key() != LINUX_AMD64: + return False + + # Special case: version 0.8.18 is supported + if version == Version("0.8.18"): + return True + + # General case: versions <= 0.4.10 for Linux + earliest = Version(EARLIEST_RELEASE[LINUX_AMD64]) + return version <= Version("0.4.10") and version >= earliest + + +class AlloyRepository(AbstractSolcRepository): + """Repository for alloy-rs/solc-builds - provides native ARM64 Darwin binaries.""" + + def __init__(self, session: requests.Session) -> None: + super().__init__(session) + + @property + def base_url(self) -> str: + return ALLOY_SOLC_ARTIFACTS + + @property + def list_url(self) -> str: + return ALLOY_SOLC_JSON + + def _filter_versions(self, releases: Dict[str, str]) -> Dict[str, str]: + """Filter to only include versions in the supported ARM64 range.""" + min_version = Version(ALLOY_ARM64_MIN_VERSION) + max_version = Version(ALLOY_ARM64_MAX_VERSION) + + return { + version: release + for version, release in releases.items() + if min_version <= Version(version) <= max_version + } + + def supports_version(self, version: SolcVersion, platform: Platform) -> bool: + """Check if this repository supports the version.""" + # Only for Darwin ARM64 + if not (platform.os_type == "darwin" and platform.architecture == "arm64"): + return False + + min_version = Version(ALLOY_ARM64_MIN_VERSION) + max_version = Version(ALLOY_ARM64_MAX_VERSION) + + return min_version <= version <= max_version + + +class CompositeRepository: + """Composite repository that manages multiple underlying repositories.""" + + def __init__(self, platform: Platform, session: requests.Session): + self.platform = platform + self.repositories: List[AbstractSolcRepository] = [] + + # Always include the main soliditylang repository + self.repositories.append(SoliditylangRepository(platform, session)) + + # Add platform-specific repositories + if platform.get_soliditylang_key() == LINUX_AMD64: + self.repositories.append(CryticRepository(session)) + + if platform.os_type == "darwin" and platform.architecture == "arm64": + self.repositories.append(AlloyRepository(session)) + + @property + @lru_cache(maxsize=5) # noqa: B019 + def available_versions(self) -> Dict[str, str]: + """Get all available versions from all repositories.""" + all_versions = {} + + for repo in self.repositories: + try: + versions = repo.available_versions + all_versions.update(versions) + except requests.RequestException: + # Continue if one repository fails + continue + + return all_versions + + @property + @lru_cache(maxsize=5) # noqa: B019 + def latest_version(self) -> SolcVersion: + """Get the latest version across all repositories.""" + latest_versions = [] + + for repo in self.repositories: + try: + latest_versions.append(repo.latest_version) + except (ValueError, requests.RequestException): + # Continue if one repository fails + continue + + if not latest_versions: + raise ValueError("No versions available from any repository") + + return max(latest_versions) + + def get_repository_for_version(self, version: SolcVersion) -> AbstractSolcRepository: + """Get the appropriate repository for a specific version.""" + # Check for platform-specific repositories + for repo in reversed(self.repositories): # Check specialized repos first + if repo.supports_version(version, self.platform): + return repo + + # Fallback to main soliditylang repository + return self.repositories[0] diff --git a/solc_select/services/__init__.py b/solc_select/services/__init__.py new file mode 100644 index 0000000..5697709 --- /dev/null +++ b/solc_select/services/__init__.py @@ -0,0 +1,6 @@ +""" +Service layer for solc-select. + +This package contains business logic services that orchestrate between +the domain models and infrastructure layers. +""" diff --git a/solc_select/services/artifact_manager.py b/solc_select/services/artifact_manager.py new file mode 100644 index 0000000..69f5fe0 --- /dev/null +++ b/solc_select/services/artifact_manager.py @@ -0,0 +1,347 @@ +""" +Artifact management service for solc-select. + +This module handles downloading, verification, installation, and management +of Solidity compiler artifacts. +""" + +import hashlib +import os +from concurrent.futures import ThreadPoolExecutor, as_completed +from functools import partial +from io import BufferedRandom +from pathlib import Path +from typing import List +from zipfile import ZipFile + +import requests +from Crypto.Hash import keccak + +from ..constants import ARTIFACTS_DIR +from ..exceptions import ChecksumMismatchError, SolcSelectError +from ..models import Platform, SolcArtifact, SolcVersion +from ..repositories import CompositeRepository + + +class ArtifactManager: + """Service for managing Solidity compiler artifacts.""" + + def __init__( + self, repository: CompositeRepository, platform: Platform, session: requests.Session + ): + self.repository = repository + self.platform = platform + self.session = session + + def get_installed_versions(self) -> List[SolcVersion]: + """Get list of installed versions. + + Returns: + List of installed SolcVersion objects + """ + if not ARTIFACTS_DIR.exists(): + return [] + + installed = [] + for item in ARTIFACTS_DIR.iterdir(): + if item.is_dir() and item.name.startswith("solc-"): + version_str = item.name.replace("solc-", "") + try: + version = SolcVersion.parse(version_str) + # Verify the binary exists + binary_path = item / f"solc-{version_str}" + if binary_path.exists(): + installed.append(version) + except ValueError: + # Skip invalid version directories + continue + + installed.sort() + return installed + + def is_installed(self, version: SolcVersion) -> bool: + """Check if a version is installed. + + Args: + version: Version to check + + Returns: + True if installed, False otherwise + """ + artifact_dir = self.get_artifact_directory(version) + binary_path = artifact_dir / f"solc-{version}" + return binary_path.exists() + + def get_artifact_directory(self, version: SolcVersion) -> Path: + """Get the directory where a version's artifacts are stored. + + Args: + version: Version to get directory for + + Returns: + Path to the artifact directory + """ + return ARTIFACTS_DIR / f"solc-{version}" + + def get_binary_path(self, version: SolcVersion) -> Path: + """Get the path to a version's binary. + + Args: + version: Version to get binary path for + + Returns: + Path to the binary + """ + artifact_dir = self.get_artifact_directory(version) + return artifact_dir / f"solc-{version}" + + def create_artifact_metadata(self, version: SolcVersion) -> SolcArtifact: + """Create artifact metadata for a version. + + Args: + version: Version to create metadata for + + Returns: + SolcArtifact with download information + + Raises: + ValueError: If version is not available + """ + # Get the appropriate repository for this version + repo = self.repository.get_repository_for_version(version) + + # Get available versions to find the artifact filename + available = repo.available_versions + version_str = str(version) + + if version_str not in available: + raise ValueError(f"Version {version} is not available") + + artifact_filename = available[version_str] + download_url = repo.get_download_url(version, artifact_filename) + sha256_hash, keccak256_hash = repo.get_checksums(version) + + binary_path = self.get_binary_path(version) + + return SolcArtifact( + version=version, + platform=self.platform, + download_url=download_url, + checksum_sha256=sha256_hash, + checksum_keccak256=keccak256_hash, + file_path=binary_path, + ) + + def verify_checksum(self, artifact: SolcArtifact, file_handle: BufferedRandom) -> None: + """Verify the checksums of a downloaded artifact. + + Args: + artifact: Artifact metadata with expected checksums + file_handle: Open file handle to verify + + Raises: + ChecksumMismatchError: If checksums don't match + """ + sha256_factory = hashlib.sha256() + keccak_factory = keccak.new(digest_bits=256) + + # Calculate checksums + file_handle.seek(0) + for chunk in iter(lambda: file_handle.read(1024000), b""): # 1MB chunks + sha256_factory.update(chunk) + keccak_factory.update(chunk) + + local_sha256 = sha256_factory.hexdigest() + local_keccak256 = keccak_factory.hexdigest() + + # Verify SHA256 + if artifact.checksum_sha256 != local_sha256: + raise ChecksumMismatchError(artifact.checksum_sha256, local_sha256, "SHA256") + + # Verify Keccak256 if available + if artifact.checksum_keccak256 and artifact.checksum_keccak256 != local_keccak256: + raise ChecksumMismatchError(artifact.checksum_keccak256, local_keccak256, "Keccak256") + + def download_and_install(self, version: SolcVersion, silent: bool = False) -> bool: + """Download and install a Solidity compiler version. + + Args: + version: Version to install + silent: Whether to suppress output messages + + Returns: + True if successful, False otherwise + + Raises: + InstallationError: If installation fails + ChecksumMismatchError: If checksum verification fails + """ + if self.is_installed(version): + if not silent: + print(f"Version '{version}' is already installed, skipping...") + return True + + if not silent: + print(f"Installing solc '{version}'...") + + try: + artifact = self.create_artifact_metadata(version) + except ValueError as e: + if not silent: + print(f"Error: {e}") + return False + + # Create artifact directory + artifact_dir = self.get_artifact_directory(version) + artifact_dir.mkdir(parents=True, exist_ok=True) + + try: + # Download the file + response = self.session.get(artifact.download_url, stream=True) + response.raise_for_status() + + # Write and verify the file + with open(artifact.file_path, "w+b", opener=partial(os.open, mode=0o664)) as f: + try: + for chunk in response.iter_content(chunk_size=8192): + if chunk: # Filter out keep-alive chunks + f.write(chunk) + except KeyboardInterrupt: + # Clean up partially downloaded file on interrupt + if artifact.file_path.exists(): + artifact.file_path.unlink(missing_ok=True) + raise + + # Verify checksums + self.verify_checksum(artifact, f) + + # Handle ZIP archives (older Windows versions) + if artifact.is_zip_archive: + self._extract_zip_archive(artifact) + else: + # Make binary executable + artifact.file_path.chmod(0o775) + + if not silent: + print(f"Version '{version}' installed.") + + return True + + except Exception as e: + # Clean up on failure + if artifact.file_path.exists(): + artifact.file_path.unlink() + + if isinstance(e, ChecksumMismatchError): + raise e + else: + if not silent: + print(f"Error installing {version}: {e}") + return False + + def _extract_zip_archive(self, artifact: SolcArtifact) -> None: + """Extract a ZIP archive and rename the binary. + + Args: + artifact: Artifact metadata for the ZIP file + """ + artifact_dir = artifact.file_path.parent + + with ZipFile(artifact.file_path, "r") as zip_ref: + zip_ref.extractall(path=artifact_dir) + + # Remove the ZIP file + artifact.file_path.unlink() + + # Rename the extracted binary + extracted_binary = artifact_dir / artifact.get_binary_name_in_zip() + extracted_binary.rename(artifact.file_path) + + # Make executable + artifact.file_path.chmod(0o775) + + def install_versions(self, versions: List[SolcVersion], silent: bool = False) -> bool: + """Install multiple versions concurrently. + + Args: + versions: List of versions to install + silent: Whether to suppress output messages + + Returns: + True if all installations succeeded, False otherwise + """ + if not versions: + return True + + # For single version, use sequential approach + if len(versions) == 1: + try: + return self.download_and_install(versions[0], silent) + except SolcSelectError as e: + if not silent: + print(f"Error: {e}") + return False + + # For multiple versions, use parallel approach + if not silent: + print(f"Installing {len(versions)} versions concurrently...") + + success_count = 0 + total_count = len(versions) + + # Use ThreadPoolExecutor with max 5 concurrent downloads + executor = ThreadPoolExecutor(max_workers=5) + future_to_version = {} + + try: + # Submit all download jobs + future_to_version = { + executor.submit(self.download_and_install, version, True): version + for version in versions + } + + # Process completed downloads + for future in as_completed(future_to_version): + version = future_to_version[future] + try: + result = future.result() + if result: + success_count += 1 + if not silent: + print( + f"[OK] Version '{version}' installed ({success_count}/{total_count})" + ) + elif not silent: + print( + f"[FAIL] Version '{version}' failed to install ({success_count}/{total_count})" + ) + except SolcSelectError as e: + if not silent: + print( + f"[FAIL] Version '{version}' failed: {e} ({success_count}/{total_count})" + ) + + except KeyboardInterrupt: + if not silent: + print(f"\nCancelling installation... ({success_count}/{total_count} completed)") + + # Cancel all pending futures + for future in future_to_version: + future.cancel() + + # Shutdown executor immediately without waiting for running tasks + executor.shutdown(wait=False) + raise + + finally: + # Clean shutdown for normal completion + if not executor._shutdown: + executor.shutdown(wait=True) + + if not silent: + if success_count == total_count: + print(f"All {total_count} versions installed successfully!") + else: + print(f"{success_count}/{total_count} versions installed successfully") + + return success_count == total_count diff --git a/solc_select/services/platform_service.py b/solc_select/services/platform_service.py new file mode 100644 index 0000000..14d0638 --- /dev/null +++ b/solc_select/services/platform_service.py @@ -0,0 +1,151 @@ +""" +Platform service for solc-select. + +This module handles platform-specific operations including emulation support, +compatibility checks, and ARM64 warnings. +""" + +import contextlib +import sys +from pathlib import Path +from typing import List + +from ..constants import SOLC_SELECT_DIR +from ..models import Platform, SolcVersion + + +class PlatformService: + """Service for platform-specific operations.""" + + def __init__(self, platform: Platform): + self.platform = platform + + def get_emulation_prefix(self) -> List[str]: + """Get the command prefix for emulation if needed. + + Returns: + List of command components to prepend for emulation + """ + if self.platform.architecture != "arm64": + return [] + + # On macOS, let Rosetta handle it automatically + if self.platform.os_type == "darwin": + return [] + + # On Linux, use qemu if available + if self.platform.os_type == "linux" and self.platform.has_qemu(): + return ["qemu-x86_64"] + + return [] + + def can_run_binary(self, binary_path: Path, version: SolcVersion) -> bool: + """Check if we can run a binary on this platform. + + Args: + binary_path: Path to the binary + version: Version of the binary + + Returns: + True if binary can be executed, False otherwise + """ + # Delegate to platform's binary compatibility check + return self.platform.can_run_binary(binary_path) + + def validate_binary_compatibility(self, binary_path: Path, version: SolcVersion) -> None: + """Validate that a binary can be executed on this platform. + + Args: + binary_path: Path to the binary to validate + version: Version of the binary + + Raises: + RuntimeError: If binary cannot be executed + """ + if not binary_path.exists(): + raise RuntimeError("solc-select is out of date. Please run `solc-select upgrade`") + + if not self.can_run_binary(binary_path, version): + if self.platform.os_type == "darwin" and self.platform.architecture == "arm64": + raise RuntimeError( + "solc binaries previous to 0.8.5 for macOS are Intel-only. " + "Please install Rosetta on your Mac to continue. " + "Refer to the solc-select README for instructions." + ) + else: + raise RuntimeError( + f"Cannot execute solc binary for version {version} on {self.platform.os_type}-{self.platform.architecture}" + ) + + def warn_about_arm64_compatibility(self, force: bool = False) -> None: + """Warn ARM64 users about compatibility and suggest solutions. + + Args: + force: Whether to show warning even if already shown before + """ + if self.platform.architecture != "arm64": + return + + # Check if we've already warned + warning_file = SOLC_SELECT_DIR.joinpath(".arm64_warning_shown") + if not force and warning_file.exists(): + return + + print("\n⚠️ WARNING: ARM64 Architecture Detected", file=sys.stderr) + print("=" * 50, file=sys.stderr) + + show_remediation = False + + if self.platform.os_type == "darwin": + print("✓ Native ARM64 binaries available for versions 0.8.5-0.8.23", file=sys.stderr) + print("✓ Universal binaries available for versions 0.8.24+", file=sys.stderr) + + if self.platform.has_rosetta(): + print( + "✓ Rosetta 2 detected - will use emulation for older versions", file=sys.stderr + ) + print(" Note: Performance will be slower for emulated versions", file=sys.stderr) + else: + print( + "⚠ Rosetta 2 not available - versions prior to 0.8.5 are x86_64 only and will not work", + file=sys.stderr, + ) + show_remediation = True + + elif self.platform.os_type == "linux": + if self.platform.has_qemu(): + print( + "✓ qemu-x86_64 detected - will use emulation for x86 binaries", file=sys.stderr + ) + print(" Note: Performance will be slower than native execution", file=sys.stderr) + else: + print("✗ solc binaries are x86_64 only, and qemu is not installed", file=sys.stderr) + show_remediation = True + else: + show_remediation = True + + if show_remediation: + print("\nTo use solc-select on ARM64, you can:", file=sys.stderr) + print(" 1. Install software for x86_64 emulation:", file=sys.stderr) + + if self.platform.os_type == "linux": + print( + " sudo apt-get install qemu-user-static # Debian/Ubuntu", file=sys.stderr + ) + print(" sudo dnf install qemu-user-static # Fedora", file=sys.stderr) + print(" sudo pacman -S qemu-user-static # Arch", file=sys.stderr) + elif self.platform.os_type == "darwin": + print( + " Use Rosetta 2 (installed automatically on Apple Silicon)", file=sys.stderr + ) + + print(" 2. Use an x86_64 Docker container", file=sys.stderr) + print(" 3. Use a cloud-based development environment", file=sys.stderr) + + print("=" * 50, file=sys.stderr) + print(file=sys.stderr) + + # Mark that we've shown the warning + SOLC_SELECT_DIR.mkdir(parents=True, exist_ok=True) + with contextlib.suppress(OSError): + warning_file.touch() diff --git a/solc_select/services/solc_service.py b/solc_select/services/solc_service.py new file mode 100644 index 0000000..bb0d772 --- /dev/null +++ b/solc_select/services/solc_service.py @@ -0,0 +1,226 @@ +""" +Main service facade for solc-select. + +This module provides a high-level interface that coordinates between +all the other services and provides the main business logic operations. +""" + +import subprocess +import sys +from typing import List, Optional, Tuple + +from ..exceptions import ( + ArchitectureUpgradeError, + InstallationError, + NoVersionSetError, + SolcSelectError, + VersionNotFoundError, + VersionNotInstalledError, +) +from ..infrastructure.filesystem import FilesystemManager +from ..infrastructure.http_client import create_http_session +from ..models import Platform, SolcVersion +from ..repositories import CompositeRepository +from .artifact_manager import ArtifactManager +from .platform_service import PlatformService +from .version_manager import VersionManager + + +class SolcService: + """Main service facade for solc-select operations.""" + + def __init__(self, platform: Optional[Platform] = None): + if platform is None: + platform = Platform.current() + + self.platform = platform + self.filesystem = FilesystemManager() + self.session = create_http_session() + self.repository = CompositeRepository(platform, self.session) + self.version_manager = VersionManager(self.repository, platform) + self.artifact_manager = ArtifactManager(self.repository, platform, self.session) + self.platform_service = PlatformService(platform) + + def get_current_version(self) -> Tuple[Optional[SolcVersion], str]: + """Get the current version and its source. + + Returns: + Tuple of (version, source) where source is the setting origin + + Raises: + NoVersionSetError: If no version is currently set + VersionNotInstalledError: If version is set but not installed + """ + version = self.filesystem.get_current_version() + source = self.filesystem.get_version_source() + + if version is None: + raise NoVersionSetError() + + # Check if version is actually installed + installed_versions = self.artifact_manager.get_installed_versions() + if version not in installed_versions: + installed_strs = [str(v) for v in installed_versions] + raise VersionNotInstalledError(str(version), installed_strs, source) + + return version, source + + def get_installed_versions(self) -> List[SolcVersion]: + """Get list of installed versions.""" + return self.artifact_manager.get_installed_versions() + + def get_installable_versions(self) -> List[SolcVersion]: + """Get versions that can be installed.""" + installed = self.get_installed_versions() + return self.version_manager.get_installable_versions(installed) + + def install_versions(self, version_strings: List[str], silent: bool = False) -> bool: + """Install one or more versions. + + Args: + version_strings: List of version strings to install + silent: Whether to suppress output messages + + Returns: + True if all installations succeeded, False otherwise + """ + # Warn ARM64 users about compatibility on first install + if self.platform.architecture == "arm64" and not silent: + self.platform_service.warn_about_arm64_compatibility() + + if not version_strings: + return True + + try: + # Resolve version strings to actual versions + versions = self.version_manager.resolve_version_strings(version_strings) + + # Check for unavailable versions + available_versions = self.version_manager.get_available_versions() + not_available = [v for v in versions if v not in available_versions] + + if not_available: + not_available_strs = [str(v) for v in not_available] + print(f"{', '.join(not_available_strs)} solc versions are not available.") + return False + + # Install versions + return self.artifact_manager.install_versions(versions, silent) + + except SolcSelectError as e: + if not silent: + print(f"Error: {e}") + return False + + def switch_global_version( + self, version_str: str, always_install: bool = False, silent: bool = False + ) -> None: + """Switch to a different global version. + + Args: + version_str: Version string to switch to + always_install: Whether to install the version if not present + silent: Whether to suppress output messages + + Raises: + VersionNotFoundError: If version is invalid or not available + VersionNotInstalledError: If version is not installed + InstallationError: If installation fails + """ + # Resolve "latest" to actual version + if version_str == "latest": + version = self.version_manager.get_latest_version() + else: + version = self.version_manager.validate_version(version_str) + + # Check if version is installed + if self.artifact_manager.is_installed(version): + self.filesystem.set_global_version(version) + if not silent: + print(f"Switched global version to {version}") + elif always_install: + # Install the version first + if self.install_versions([str(version)], silent): + self.switch_global_version(str(version), always_install=False, silent=silent) + else: + raise InstallationError(str(version), "Installation failed") + else: + available_versions = self.version_manager.get_available_versions() + if version in available_versions: + raise VersionNotInstalledError(str(version)) + else: + available_strs = [str(v) for v in available_versions[:5]] # Show first 5 + raise VersionNotFoundError(str(version), available_strs) + + def upgrade_architecture(self) -> None: + """Upgrade from old architecture to new directory structure. + + Raises: + ArchitectureUpgradeError: If upgrade fails or no versions to upgrade + """ + currently_installed = self.get_installed_versions() + + if not currently_installed: + raise ArchitectureUpgradeError( + "No installed versions found. Run `solc-select install --help` for more information" + ) + + # Check if we actually have old-format installations + has_legacy = any(self.filesystem.is_legacy_installation(v) for v in currently_installed) + + if has_legacy: + # Clean artifacts directory and reinstall + self.filesystem.cleanup_artifacts_directory() + version_strs = [str(v) for v in currently_installed] + + if self.install_versions(version_strs, silent=True): + print("solc-select is now up to date! 🎉") + else: + raise ArchitectureUpgradeError("Failed to reinstall existing versions") + else: + print("solc-select is already up to date") + + def execute_solc(self, args: List[str]) -> None: + """Execute solc with the current version. + + Args: + args: Command line arguments to pass to solc + + Raises: + SystemExit: With solc's exit code + """ + # Auto-install latest if no versions installed + if not self.get_installed_versions(): + self.switch_global_version("latest", always_install=True, silent=True) + + try: + version, _ = self.get_current_version() + except SolcSelectError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + if version is None: + sys.exit(1) + + binary_path = self.filesystem.get_binary_path(version) + + # Validate binary compatibility + try: + self.platform_service.validate_binary_compatibility(binary_path, version) + except RuntimeError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + # Get emulation prefix if needed + emulation_prefix = self.platform_service.get_emulation_prefix() + + # Execute solc + cmd = emulation_prefix + [str(binary_path)] + args + + try: + subprocess.run(cmd, check=True) + except subprocess.CalledProcessError as e: + sys.exit(e.returncode) + except FileNotFoundError: + print(f"Error: Could not execute solc binary at {binary_path}", file=sys.stderr) + sys.exit(1) diff --git a/solc_select/services/version_manager.py b/solc_select/services/version_manager.py new file mode 100644 index 0000000..f0f98bc --- /dev/null +++ b/solc_select/services/version_manager.py @@ -0,0 +1,142 @@ +""" +Version management service for solc-select. + +This module handles validation, resolution, and management of Solidity compiler versions. +""" + +from typing import List + +from ..constants import EARLIEST_RELEASE +from ..exceptions import ( + PlatformNotSupportedError, + VersionNotFoundError, + VersionResolutionError, +) +from ..models import Platform, SolcVersion +from ..repositories import CompositeRepository + + +class VersionManager: + """Service for managing Solidity compiler versions.""" + + def __init__(self, repository: CompositeRepository, platform: Platform): + self.repository = repository + self.platform = platform + + def get_available_versions(self) -> List[SolcVersion]: + """Get all available versions that can be installed. + + Returns: + List of available versions sorted by version number + """ + releases = self.repository.available_versions + versions = [] + + for version_str in releases: + try: + version = SolcVersion.parse(version_str) + if version.is_compatible_with_platform(self.platform): + versions.append(version) + except ValueError: + # Skip invalid version strings + continue + + # Sort versions + versions.sort() + return versions + + def get_latest_version(self) -> SolcVersion: + """Get the latest available version. + + Returns: + The latest SolcVersion + + Raises: + ValueError: If no versions are available + """ + return self.repository.latest_version + + def validate_version(self, version_str: str) -> SolcVersion: + """Validate and parse a version string. + + Args: + version_str: Version string to validate (e.g., "0.8.19", "latest") + + Returns: + Validated SolcVersion + + Raises: + VersionResolutionError: If 'latest' version cannot be resolved + VersionNotFoundError: If version is invalid or not available + PlatformNotSupportedError: If version is not supported on current platform + """ + if version_str == "latest": + try: + return self.get_latest_version() + except Exception as e: + raise VersionResolutionError("latest", str(e)) from e + + try: + version = SolcVersion.parse(version_str) + except ValueError as e: + available_versions = self.get_available_versions() + available_strs = [str(v) for v in available_versions[:5]] # Show first 5 + raise VersionNotFoundError( + version_str, available_strs, "Check the version format (e.g., '0.8.19')" + ) from e + + # Check minimum version for platform + if not version.is_compatible_with_platform(self.platform): + platform_key = self.platform.get_soliditylang_key() + earliest = EARLIEST_RELEASE.get(platform_key, "0.0.0") + raise PlatformNotSupportedError( + str(version), self.platform.get_soliditylang_key(), earliest + ) + + # Check if version exists in available releases + available_versions = self.get_available_versions() + if version not in available_versions: + latest = self.get_latest_version() + if version > latest: + raise VersionNotFoundError( + str(version), [str(latest)], f"'{latest}' is the latest available version" + ) + else: + available_strs = [str(v) for v in available_versions[:5]] # Show first 5 + raise VersionNotFoundError(str(version), available_strs) + + return version + + def resolve_version_strings(self, version_strings: List[str]) -> List[SolcVersion]: + """Resolve a list of version strings to SolcVersion objects. + + Args: + version_strings: List of version strings (may contain "latest", "all") + + Returns: + List of resolved SolcVersion objects + """ + if "all" in version_strings: + return self.get_available_versions() + + versions = [] + for version_str in version_strings: + if version_str == "latest": + versions.append(self.get_latest_version()) + else: + versions.append(self.validate_version(version_str)) + + return versions + + def get_installable_versions(self, installed_versions: List[SolcVersion]) -> List[SolcVersion]: + """Get versions that can be installed (not already installed). + + Args: + installed_versions: List of currently installed versions + + Returns: + List of installable versions + """ + available = self.get_available_versions() + installable = [v for v in available if v not in installed_versions] + return installable diff --git a/solc_select/solc_select.py b/solc_select/solc_select.py index 8ce99bc..3280785 100644 --- a/solc_select/solc_select.py +++ b/solc_select/solc_select.py @@ -1,472 +1,54 @@ -import argparse -import contextlib -import hashlib -import os -import re -import shutil -import subprocess -import sys -from pathlib import Path -from typing import Dict, List, Optional, Tuple -from zipfile import ZipFile - -from Crypto.Hash import keccak -from packaging.version import Version - -from .constants import ( - ALLOY_ARM64_MAX_VERSION, - ALLOY_ARM64_MIN_VERSION, - ALLOY_SOLC_ARTIFACTS, - ALLOY_SOLC_JSON, - ARTIFACTS_DIR, - CRYTIC_SOLC_ARTIFACTS, - CRYTIC_SOLC_JSON, - EARLIEST_RELEASE, - LINUX_AMD64, - MACOSX_AMD64, - SOLC_SELECT_DIR, - WINDOWS_AMD64, -) -from .utils import ( - create_http_session, - get_arch, - mac_binary_is_native, - mac_binary_is_universal, - mac_can_run_intel_binaries, -) - -Path.mkdir(ARTIFACTS_DIR, parents=True, exist_ok=True) - - -def check_emulation_available() -> bool: - """Check if x86_64 emulation is available.""" - if get_arch() != "arm64": - return False - - # On macOS, check for Rosetta 2 - if sys.platform == "darwin": - return mac_can_run_intel_binaries() - - # On Linux, check for qemu-x86_64 - try: - result = subprocess.run( - ["which", "qemu-x86_64"], capture_output=True, text=True, check=False - ) - return result.returncode == 0 - except (FileNotFoundError, OSError): - return False - - -def get_emulation_prefix() -> List[str]: - """Get the command prefix for emulation if needed.""" - if get_arch() != "arm64": - return [] - - # On macOS, let Rosetta handle it automatically - if sys.platform == "darwin": - return [] - - # On Linux, use qemu - if sys.platform.startswith("linux") and check_emulation_available(): - return ["qemu-x86_64"] - - return [] - - -def warn_about_arm64(force: bool = False) -> None: - """Warn ARM64 users about compatibility and suggest solutions.""" - if get_arch() != "arm64": - return - - # Check if we've already warned - warning_file = SOLC_SELECT_DIR.joinpath(".arm64_warning_shown") - if not force and warning_file.exists(): - return - - print("\n⚠️ WARNING: ARM64 Architecture Detected", file=sys.stderr) - print("=" * 50, file=sys.stderr) - - show_remediation = False - - if sys.platform == "darwin": - print("✓ Native ARM64 binaries available for versions 0.8.5-0.8.23", file=sys.stderr) - print("✓ Universal binaries available for versions 0.8.24+", file=sys.stderr) - if check_emulation_available(): - print("✓ Rosetta 2 detected - will use emulation for older versions", file=sys.stderr) - print(" Note: Performance will be slower for emulated versions", file=sys.stderr) - else: - print( - "⚠ Rosetta 2 not available - versions prior to 0.8.5 are x86_64 only and will not work", - file=sys.stderr, - ) - show_remediation = True - elif sys.platform == "linux": - if check_emulation_available(): - print("✓ qemu-x86_64 detected - will use emulation for x86 binaries", file=sys.stderr) - print(" Note: Performance will be slower than native execution", file=sys.stderr) - else: - print("✗ solc binaries are x86_64 only, and qemu is not installed", file=sys.stderr) - show_remediation = True - else: - show_remediation = True - - if show_remediation: - print("\nTo use solc-select on ARM64, you can:", file=sys.stderr) - print(" 1. Install software for x86_64 emulation:", file=sys.stderr) - if sys.platform == "linux": - print(" sudo apt-get install qemu-user-static # Debian/Ubuntu", file=sys.stderr) - print(" sudo dnf install qemu-user-static # Fedora", file=sys.stderr) - print(" sudo pacman -S qemu-user-static # Arch", file=sys.stderr) - elif sys.platform == "darwin": - print(" Use Rosetta 2 (installed automatically on Apple Silicon)", file=sys.stderr) - print(" 2. Use an x86_64 Docker container", file=sys.stderr) - print(" 3. Use a cloud-based development environment", file=sys.stderr) - print("=" * 50, file=sys.stderr) - print(file=sys.stderr) - - # Mark that we've shown the warning - with contextlib.suppress(OSError): - warning_file.touch() +""" +Backward compatibility wrappers for solc-select. +This module provides compatibility functions for code that previously imported +from solc_select.solc_select, such as crytic-compile. These functions wrap +the new refactored service architecture to maintain the same API. +""" -def halt_old_architecture(path: Path) -> None: - if not Path.is_file(path): - raise argparse.ArgumentTypeError( - "solc-select is out of date. Please run `solc-select upgrade`" - ) - - -def halt_incompatible_system(path: Path) -> None: - if soliditylang_platform() == MACOSX_AMD64: - # If Rosetta is available, we can run all solc versions - if mac_can_run_intel_binaries(): - return - - # If this is a newer universal solc (>=0.8.24) we can always run it - # https://github.com/ethereum/solidity/issues/12291#issuecomment-2223328961 - if mac_binary_is_universal(path): - return - - # If the binary is native to this architecture, we can run it - if mac_binary_is_native(path): - return - - raise argparse.ArgumentTypeError( - "solc binaries previous to 0.8.5 for macOS are Intel-only. Please install Rosetta on your Mac to continue. Refer to the solc-select README for instructions." - ) - # TODO: check for Linux aarch64 (e.g. RPi), presence of QEMU+binfmt - - -def upgrade_architecture() -> None: - currently_installed = installed_versions() - if len(currently_installed) > 0: - if Path.is_file(ARTIFACTS_DIR.joinpath(f"solc-{currently_installed[0]}")): - shutil.rmtree(ARTIFACTS_DIR) - Path.mkdir(ARTIFACTS_DIR, exist_ok=True) - install_artifacts(currently_installed) - print("solc-select is now up to date! 🎉") - else: - print("solc-select is already up to date") - else: - raise argparse.ArgumentTypeError("Run `solc-select install --help` for more information") - - -def current_version() -> Tuple[str, str]: - source = "SOLC_VERSION" - version = os.environ.get(source) - if not version: - source_path = SOLC_SELECT_DIR.joinpath("global-version") - source = source_path.as_posix() - if Path.is_file(source_path): - with open(source_path, encoding="utf-8") as f: - version = f.read().strip() - else: - raise argparse.ArgumentTypeError( - "No solc version set. Run `solc-select use VERSION` or set SOLC_VERSION environment variable." - ) - versions = installed_versions() - if version not in versions: - raise argparse.ArgumentTypeError( - f"\nVersion '{version}' not installed (set by {source})." - f"\nRun `solc-select install {version}`." - f"\nOr use one of the following versions: {versions}" - ) - return version, source - - -def installed_versions() -> List[str]: - return [ - f.replace("solc-", "") for f in sorted(os.listdir(ARTIFACTS_DIR)) if f.startswith("solc-") - ] +from pathlib import Path +from typing import List - -def artifact_path(version: str) -> Path: - return ARTIFACTS_DIR.joinpath(f"solc-{version}", f"solc-{version}") +from .constants import ARTIFACTS_DIR +from .services.solc_service import SolcService def install_artifacts(versions: List[str], silent: bool = False) -> bool: - # Warn ARM64 users about compatibility on first install - if get_arch() == "arm64" and not silent: - warn_about_arm64() - - releases = get_available_versions() - versions = [get_latest_release() if ver == "latest" else ver for ver in versions] - - if "all" not in versions: - not_available_versions = list(set(versions).difference([*releases])) - if not_available_versions: - print(f"{', '.join(not_available_versions)} solc versions are not available.") - return False - - already_installed = installed_versions() - for version, artifact in releases.items(): - if "all" not in versions: - if versions and version not in versions: - continue - - artifact_file_dir = ARTIFACTS_DIR.joinpath(f"solc-{version}") - - if version in already_installed: - if os.listdir(artifact_file_dir): - if not silent: - print(f"Version '{version}' is already installed, skipping...") - continue - - (url, _) = get_url(version, artifact) + """Install solc versions (backward compatibility wrapper). - if is_linux_0818(version): - url = CRYTIC_SOLC_ARTIFACTS + artifact - print(url) + Args: + versions: List of version strings to install (e.g., ["0.8.19", "latest", "all"]) + silent: Whether to suppress output messages - Path.mkdir(artifact_file_dir, parents=True, exist_ok=True) - if not silent: - print(f"Installing solc '{version}'...") - session = create_http_session() - response = session.get(url) - response.raise_for_status() + Returns: + True if all installations succeeded, False otherwise + """ + service = SolcService() + return service.install_versions(versions, silent) - with open(artifact_file_dir.joinpath(f"solc-{version}"), "wb") as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) - verify_checksum(version) - - if is_older_windows(version): - with ZipFile(artifact_file_dir.joinpath(f"solc-{version}"), "r") as zip_ref: - zip_ref.extractall(path=artifact_file_dir) - zip_ref.close() - Path.unlink(artifact_file_dir.joinpath(f"solc-{version}")) - Path(artifact_file_dir.joinpath("solc.exe")).rename( - Path(artifact_file_dir.joinpath(f"solc-{version}")), - ) - else: - Path.chmod(artifact_file_dir.joinpath(f"solc-{version}"), 0o775) - if not silent: - print(f"Version '{version}' installed.") - return True - - -def is_older_linux(version: str) -> bool: - return soliditylang_platform() == LINUX_AMD64 and Version(version) <= Version("0.4.10") - - -def is_linux_0818(version: str) -> bool: - return soliditylang_platform() == LINUX_AMD64 and Version(version) == Version("0.8.18") - - -def is_older_windows(version: str) -> bool: - return soliditylang_platform() == WINDOWS_AMD64 and Version(version) <= Version("0.7.1") - - -def is_alloy_aarch64_version(version: str) -> bool: - return ( - sys.platform == "darwin" - and get_arch() == "arm64" - and Version(ALLOY_ARM64_MIN_VERSION) <= Version(version) <= Version(ALLOY_ARM64_MAX_VERSION) - ) - - -def verify_checksum(version: str) -> None: - (sha256_hash, keccak256_hash) = get_soliditylang_checksums(version) - - # calculate sha256 and keccak256 checksum of the local file - with open(ARTIFACTS_DIR.joinpath(f"solc-{version}", f"solc-{version}"), "rb") as f: - sha256_factory = hashlib.sha256() - keccak_factory = keccak.new(digest_bits=256) - - # 1024000(~1MB chunk) - for chunk in iter(lambda: f.read(1024000), b""): - sha256_factory.update(chunk) - keccak_factory.update(chunk) - - local_sha256_file_hash = sha256_factory.hexdigest() - local_keccak256_file_hash = keccak_factory.hexdigest() - - if sha256_hash != local_sha256_file_hash: - raise argparse.ArgumentTypeError( - f"Error: SHA256 checksum mismatch {soliditylang_platform()} - {version}" - ) - - if keccak256_hash is not None and keccak256_hash != local_keccak256_file_hash: - raise argparse.ArgumentTypeError( - f"Error: Keccak256 checksum mismatch {soliditylang_platform()} - {version}" - ) - - -def get_soliditylang_checksums(version: str) -> Tuple[str, Optional[str]]: - (_, list_url) = get_url(version=version) - session = create_http_session() - response = session.get(list_url) - response.raise_for_status() - builds = response.json()["builds"] - matches = list(filter(lambda b: b["version"] == version, builds)) - - if not matches or not matches[0]["sha256"]: - raise argparse.ArgumentTypeError( - f"Error: Unable to retrieve checksum for {soliditylang_platform()} - {version}" - ) - - sha256_hash = matches[0]["sha256"] - keccak256_hash = matches[0].get("keccak256") - - # Normalize checksums by removing 0x prefix if present - if sha256_hash and sha256_hash.startswith("0x"): - sha256_hash = sha256_hash[2:] - if keccak256_hash and keccak256_hash.startswith("0x"): - keccak256_hash = keccak256_hash[2:] - - return sha256_hash, keccak256_hash - - -def get_url(version: str = "", artifact: str = "") -> Tuple[str, str]: - if soliditylang_platform() == LINUX_AMD64: - if version != "" and is_older_linux(version): - return ( - CRYTIC_SOLC_ARTIFACTS + artifact, - CRYTIC_SOLC_JSON, - ) - elif sys.platform == "darwin" and get_arch() == "arm64": - if version != "" and is_alloy_aarch64_version(version): - return ( - ALLOY_SOLC_ARTIFACTS + artifact, - ALLOY_SOLC_JSON, - ) - else: - # Fall back to Intel binaries for versions outside supported range - return ( - f"https://binaries.soliditylang.org/{MACOSX_AMD64}/{artifact}", - f"https://binaries.soliditylang.org/{MACOSX_AMD64}/list.json", - ) - return ( - f"https://binaries.soliditylang.org/{soliditylang_platform()}/{artifact}", - f"https://binaries.soliditylang.org/{soliditylang_platform()}/list.json", - ) - - -def switch_global_version(version: str, always_install: bool, silent: bool = False) -> None: - if version == "latest": - version = get_latest_release() - - # Check version against platform minimum even if installed - if version in installed_versions(): - with open(f"{SOLC_SELECT_DIR}/global-version", "w", encoding="utf-8") as f: - f.write(version) - if not silent: - print("Switched global version to", version) - elif version in get_available_versions(): - if always_install: - install_artifacts([version], silent) - switch_global_version(version, always_install, silent) - else: - raise argparse.ArgumentTypeError(f"'{version}' must be installed prior to use.") - else: - raise argparse.ArgumentTypeError(f"Unknown version '{version}'") - - -def valid_version(version: str) -> str: - if version in installed_versions(): - return version - latest_release = get_latest_release() - if version == "latest": - return latest_release - match = re.search(r"^(\d+)\.(\d+)\.(\d+)$", version) - - if match is None: - raise argparse.ArgumentTypeError(f"Invalid version '{version}'.") - - if Version(version) < Version(EARLIEST_RELEASE[soliditylang_platform()]): - raise argparse.ArgumentTypeError( - f"Invalid version - only solc versions above '{EARLIEST_RELEASE[soliditylang_platform()]}' are available" - ) - - # pylint: disable=consider-using-with - if Version(version) > Version(latest_release): - raise argparse.ArgumentTypeError( - f"Invalid version '{latest_release}' is the latest available version" - ) - - return version - - -def valid_install_arg(arg: str) -> str: - if arg == "all": - return arg - return valid_version(arg) - - -def get_installable_versions() -> List[str]: - installable = list(set(get_available_versions()) - set(installed_versions())) - installable.sort(key=Version) - return installable - - -def get_available_versions() -> Dict[str, str]: - session = create_http_session() - (_, list_url) = get_url() - response = session.get(list_url) - response.raise_for_status() - available_releases = response.json()["releases"] +def installed_versions() -> List[str]: + """Get list of installed version strings (backward compatibility wrapper). - if soliditylang_platform() == LINUX_AMD64: - (_, list_url) = get_url(version=EARLIEST_RELEASE[LINUX_AMD64]) - response = session.get(list_url) - response.raise_for_status() - additional_linux_versions = response.json()["releases"] - available_releases.update(additional_linux_versions) - elif sys.platform == "darwin" and get_arch() == "arm64": - # Fetch Alloy versions for ARM64 Darwin - response = session.get(ALLOY_SOLC_JSON) - response.raise_for_status() - alloy_releases = response.json()["releases"] - # Filter to only include versions in the supported range (0.8.24+ are already universal) - filtered_alloy_releases = { - version: release - for version, release in alloy_releases.items() - if Version(ALLOY_ARM64_MIN_VERSION) - <= Version(version) - <= Version(ALLOY_ARM64_MAX_VERSION) - } - available_releases.update(filtered_alloy_releases) + Returns: + List of installed version strings sorted by version number + """ + service = SolcService() + versions = service.get_installed_versions() + return [str(version) for version in versions] - return available_releases +def artifact_path(version: str) -> Path: + """Get the path to a version's binary (backward compatibility wrapper). -def soliditylang_platform() -> str: - if sys.platform.startswith("linux"): - platform = LINUX_AMD64 - elif sys.platform == "darwin": - platform = MACOSX_AMD64 - elif sys.platform in ["win32", "cygwin"]: - platform = WINDOWS_AMD64 - else: - raise argparse.ArgumentTypeError("Unsupported platform") - return platform + Args: + version: Version string (e.g., "0.8.19") + Returns: + Path to the solc binary for the given version -def get_latest_release() -> str: - session = create_http_session() - (_, list_url) = get_url() - response = session.get(list_url) - response.raise_for_status() - latest_release = response.json()["latestRelease"] - return latest_release + Note: + This function returns the expected path regardless of whether + the version is actually installed, matching the original behavior. + """ + return ARTIFACTS_DIR / f"solc-{version}" / f"solc-{version}" diff --git a/solc_select/utils.py b/solc_select/utils.py index cd1febb..07a2a63 100644 --- a/solc_select/utils.py +++ b/solc_select/utils.py @@ -1,79 +1,6 @@ -import platform -import subprocess -import sys -from pathlib import Path from typing import List -import requests from packaging.version import Version -from requests.adapters import HTTPAdapter -from requests.packages.urllib3.util.retry import Retry - - -def create_http_session() -> requests.Session: - """Create a new HTTP session with retry logic for rate limits and server errors.""" - session = requests.Session() - - # Configure retry strategy for 429s and server errors - retry_strategy = Retry( - total=5, - backoff_factor=1, - status_forcelist=[429, 500, 502, 503, 504], - ) - - adapter = HTTPAdapter(max_retries=retry_strategy) - session.mount("http://", adapter) - session.mount("https://", adapter) - - # Set standard timeouts (connect_timeout, read_timeout) - session.timeout = (10, 60) # 10s connection, 60s read for downloads - - return session - - -def get_arch() -> str: - """Get the current system architecture.""" - machine = platform.machine().lower() - if machine in ["x86_64", "amd64"]: - return "amd64" - elif machine in ["aarch64", "arm64"]: - return "arm64" - elif machine in ["i386", "i686"]: - return "386" - return machine - - -def mac_binary_is_universal(path: Path) -> bool: - """Check if the Mac binary is Universal or not. Will throw an exception if run on non-macOS.""" - assert sys.platform == "darwin" - result = subprocess.run(["/usr/bin/file", str(path)], capture_output=True, check=False) - is_universal = all( - text in result.stdout.decode() for text in ("Mach-O universal binary", "x86_64", "arm64") - ) - return result.returncode == 0 and is_universal - - -def mac_binary_is_native(path: Path): - """Check if the Mac binary matches the current system architecture. Will throw an exception if run on non-macOS.""" - assert sys.platform == "darwin" - result = subprocess.run(["/usr/bin/file", str(path)], capture_output=True, check=False) - output = result.stdout.decode() - - arch_in_file = "arm64" if get_arch() == "arm64" else "x86_64" - is_native = "Mach-O" in output and arch_in_file in output - return result.returncode == 0 and is_native - - -def mac_can_run_intel_binaries() -> bool: - """Check if the Mac is Intel or M1 with available Rosetta. Will throw an exception if run on non-macOS.""" - assert sys.platform == "darwin" - if platform.machine() == "arm64": - # M1/M2 Mac - result = subprocess.run(["/usr/bin/pgrep", "-q", "oahd"], capture_output=True, check=False) - return result.returncode == 0 - - # Intel Mac - return True def sort_versions(versions: List[str]) -> List[str]: diff --git a/tests/conftest.py b/tests/conftest.py index 69078e3..9e54205 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,12 +9,15 @@ import subprocess import sys from pathlib import Path +from typing import Any, Dict, Generator import pytest @pytest.fixture(scope="function") -def isolated_solc_data(tmp_path, monkeypatch): +def isolated_solc_data( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Generator[Path, None, None]: """ Create isolated solc-select data environment for each test. @@ -31,7 +34,7 @@ def isolated_solc_data(tmp_path, monkeypatch): @pytest.fixture(scope="function") -def isolated_python_env(tmp_path): +def isolated_python_env(tmp_path: Path) -> Generator[Dict[str, Any], None, None]: """ Create completely isolated Python environment for tests that install/uninstall solc-select. @@ -74,14 +77,15 @@ def test_contracts_dir() -> Path: # Platform markers for conditional test execution -def pytest_configure(config): +def pytest_configure(config: pytest.Config) -> None: """Register custom markers.""" config.addinivalue_line("markers", "linux: mark test to run only on Linux") config.addinivalue_line("markers", "macos: mark test to run only on macOS") config.addinivalue_line("markers", "windows: mark test to run only on Windows") + config.addinivalue_line("markers", "slow: mark test as slow running") -def pytest_runtest_setup(item): +def pytest_runtest_setup(item: pytest.Item) -> None: """Skip tests based on platform markers.""" if "linux" in item.keywords and sys.platform != "linux": pytest.skip("Test requires Linux") diff --git a/tests/test_compiler_versions.py b/tests/test_compiler_versions.py index f4edabf..53b00e0 100644 --- a/tests/test_compiler_versions.py +++ b/tests/test_compiler_versions.py @@ -4,13 +4,15 @@ This module tests compilation with different Solidity versions. """ +from typing import Any + from .utils import run_command class TestCompilerVersions: """Test compilation with different Solidity compiler versions.""" - def test_solc_045(self, test_contracts_dir, isolated_solc_data): + def test_solc_045(self, test_contracts_dir: Any, isolated_solc_data: Any) -> None: """Test Solidity 0.4.5 compilation behavior.""" # Switch to 0.4.5 result = run_command("solc-select use 0.4.5 --always-install", check=False) @@ -27,7 +29,7 @@ def test_solc_045(self, test_contracts_dir, isolated_solc_data): f"solc045_fail_compile did not fail as expected. Output: {result.stdout}" ) - def test_solc_050(self, test_contracts_dir, isolated_solc_data): + def test_solc_050(self, test_contracts_dir: Any, isolated_solc_data: Any) -> None: """Test Solidity 0.5.0 compilation behavior.""" # Switch to 0.5.0 result = run_command("solc-select use 0.5.0 --always-install", check=False) @@ -45,7 +47,7 @@ def test_solc_050(self, test_contracts_dir, isolated_solc_data): in result.stdout ), f"solc050_fail_compile did not fail as expected. Output: {result.stdout}" - def test_solc_060(self, test_contracts_dir, isolated_solc_data): + def test_solc_060(self, test_contracts_dir: Any, isolated_solc_data: Any) -> None: """Test Solidity 0.6.0 compilation behavior.""" # Switch to 0.6.0 result = run_command("solc-select use 0.6.0 --always-install", check=False) @@ -59,7 +61,7 @@ def test_solc_060(self, test_contracts_dir, isolated_solc_data): result = run_command(f"solc {test_contracts_dir}/solc060_success_receive.sol", check=False) assert result.returncode == 0, f"solc060_success_receive failed with: {result.stdout}" - def test_solc_070(self, test_contracts_dir, isolated_solc_data): + def test_solc_070(self, test_contracts_dir: Any, isolated_solc_data: Any) -> None: """Test Solidity 0.7.0 compilation behavior.""" # Switch to 0.7.0 result = run_command("solc-select use 0.7.0 --always-install", check=False) @@ -76,7 +78,7 @@ def test_solc_070(self, test_contracts_dir, isolated_solc_data): result = run_command(f"solc {test_contracts_dir}/solc070_success.sol", check=False) assert result.returncode == 0, f"solc070_success failed with: {result.stdout}" - def test_solc_080(self, test_contracts_dir, isolated_solc_data): + def test_solc_080(self, test_contracts_dir: Any, isolated_solc_data: Any) -> None: """Test Solidity 0.8.0 compilation behavior.""" # Switch to 0.8.0 result = run_command("solc-select use 0.8.0 --always-install", check=False) @@ -105,7 +107,7 @@ def test_solc_080(self, test_contracts_dir, isolated_solc_data): class TestVersionSwitching: """Test version switching functionality.""" - def test_always_install_flag(self, isolated_solc_data): + def test_always_install_flag(self, isolated_solc_data: Any) -> None: """Test --always-install flag functionality.""" # In isolated environment, 0.8.9 won't be installed initially # No need for complex path validation or manual cleanup @@ -117,7 +119,7 @@ def test_always_install_flag(self, isolated_solc_data): f"Failed to switch with --always-install. Output: {result.stdout}" ) - def test_use_without_install(self, isolated_solc_data): + def test_use_without_install(self, isolated_solc_data: Any) -> None: """Test that 'use' fails when version is not installed.""" # In isolated environment, 0.8.1 won't be installed initially # No need for complex cleanup logic @@ -125,6 +127,6 @@ def test_use_without_install(self, isolated_solc_data): # Use without install should fail result = run_command("solc-select use 0.8.1", check=False) assert result.returncode != 0 - assert "'0.8.1' must be installed prior to use" in result.stdout, ( + assert "Version '0.8.1' is not installed" in result.stdout, ( f"Did not fail as expected when version not installed. Output: {result.stdout}" ) diff --git a/tests/test_platform_specific.py b/tests/test_platform_specific.py index 40a8328..3441151 100644 --- a/tests/test_platform_specific.py +++ b/tests/test_platform_specific.py @@ -4,6 +4,8 @@ This module contains tests that are specific to Linux, macOS, and Windows. """ +from typing import Any, Dict + import pytest import requests @@ -42,7 +44,9 @@ class TestPlatformSpecific: # pylint: disable=too-few-public-methods ), ], ) - def test_version_boundaries(self, platform, config, isolated_solc_data): + def test_version_boundaries( + self, platform: str, config: Dict[str, Any], isolated_solc_data: Any + ) -> None: """Test version boundaries and constraints for all platforms.""" min_version = config["min_version"] @@ -74,13 +78,13 @@ def test_version_boundaries(self, platform, config, isolated_solc_data): result = run_command("solc-select use 0.2.0", check=False) assert result.returncode != 0 assert ( - f"Invalid version - only solc versions above '{min_version}' are available" - in result.stdout + "Version '0.2.0' is not supported on" in result.stdout + and f"Minimum supported version is '{min_version}'" in result.stdout ), f"Did not fail for version too low on {platform}. Output: {result.stdout}" # Test version too high result = run_command("solc-select use 0.100.8", check=False) assert result.returncode != 0 - assert ( - f"Invalid version '{latest_release}' is the latest available version" in result.stdout - ), f"Did not fail for version too high on {platform}. Output: {result.stdout}" + assert f"'{latest_release}' is the latest available version" in result.stdout, ( + f"Did not fail for version too high on {platform}. Output: {result.stdout}" + ) diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index e8d604c..32ae6a2 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -6,6 +6,7 @@ """ from pathlib import Path +from typing import Any from .utils import run_in_venv @@ -13,7 +14,7 @@ class TestUpgrade: # pylint: disable=too-few-public-methods """Test solc-select upgrade behavior.""" - def test_upgrade_preserves_versions(self, isolated_python_env): + def test_upgrade_preserves_versions(self, isolated_python_env: Any) -> None: """ Test that upgrading solc-select preserves installed versions. @@ -67,7 +68,7 @@ def test_upgrade_preserves_versions(self, isolated_python_env): f"Installed versions changed during upgrade.\nOld: {old_versions}\nNew: {new_versions}" ) - def test_cache_already_installed(self, isolated_python_env): + def test_cache_already_installed(self, isolated_python_env: Any) -> None: venv = isolated_python_env project_root = Path(__file__).parent.parent diff --git a/tests/test_version_verification.py b/tests/test_version_verification.py new file mode 100644 index 0000000..47bccda --- /dev/null +++ b/tests/test_version_verification.py @@ -0,0 +1,68 @@ +""" +Test solc-select version verification functionality. + +This module tests that all installed Solidity compiler versions +work correctly and return expected version information. +""" + +from typing import Any + +import pytest + +from .utils import run_command + + +class TestVersionVerification: # pylint: disable=too-few-public-methods + """Test solc-select version verification behavior.""" + + @pytest.mark.parametrize("test_mode", [pytest.param("all", marks=pytest.mark.slow), "some"]) + def test_all_versions_work_correctly(self, isolated_solc_data: Any, test_mode: str) -> None: + """ + Test that installed Solidity versions work correctly. + + This test installs Solidity versions using solc-select (either all or some specific ones), + then verifies each version by running `solc --version` and checking + that the output contains "solidity compiler" and the correct version number. + """ + if test_mode == "all": + # Install all available versions + run_command("solc-select install all", check=True) + else: # test_mode == "some" + # Install specific versions in one call + specific_versions = ["0.4.11", "0.7.3", "0.8.10", "0.8.30"] + run_command(f"solc-select install {' '.join(specific_versions)}", check=True) + + # Get list of all installed versions + result = run_command("solc-select versions", check=True) + versions = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()] + + assert versions, "No versions found - installation may have failed" + + # Test each version + for version in versions: + # Run solc --version with the specific version set + result = run_command("solc --version", check=True, env={"SOLC_VERSION": version}) + output = result.stdout.lower() + + # Check that output contains "solidity compiler" and the version + assert "solidity compiler" in output, ( + f"Version {version}: Missing 'solidity compiler' in output" + ) + assert version in output, f"Version {version}: Version number not found in output" + + def test_no_global_version_selected_error(self, isolated_solc_data: Any) -> None: + """ + Test that running solc without a global version selected throws an error. + """ + # Install at least one version but don't select it globally + run_command("solc-select install 0.8.10", check=True) + + # Try to run solc without setting a global version - this should fail + result = run_command("solc --version", check=False) + + # Should have non-zero exit code and error message about no version selected + assert result.returncode != 0, "Expected solc to fail when no version is selected" + error_output = result.stdout.lower() + assert "no solc version set" in error_output, ( + f"Expected error about no version selected, got: {result.stdout}" + ) diff --git a/tests/utils.py b/tests/utils.py index 2851165..6ea14e7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,14 +5,16 @@ but are used across multiple test files. """ +from __future__ import annotations + import os import subprocess -from typing import Dict +from typing import Any def run_in_venv( - venv_info: Dict, cmd: str, check: bool = True, **kwargs -) -> subprocess.CompletedProcess: + venv_info: dict[str, Any], cmd: str, check: bool = True, **kwargs: Any +) -> subprocess.CompletedProcess[str]: """ Run a command in an isolated virtual environment. @@ -42,8 +44,8 @@ def run_in_venv( def run_command( - cmd: str, check: bool = True, capture_stderr: bool = True -) -> subprocess.CompletedProcess: + cmd: str, check: bool = True, capture_stderr: bool = True, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: """ Execute shell commands and return output. @@ -54,19 +56,33 @@ def run_command( cmd: Command to run check: Whether to raise on non-zero exit code capture_stderr: Whether to capture stderr + env: Additional environment variables to set Returns: CompletedProcess instance with stdout, stderr, and returncode """ stderr_setting = subprocess.STDOUT if capture_stderr else subprocess.PIPE - result = subprocess.run( - cmd, - shell=True, - capture_output=False, - stdout=subprocess.PIPE, - stderr=stderr_setting, - text=True, - check=check, - ) - return result + # Merge environment variables + process_env = os.environ.copy() + if env: + process_env.update(env) + + try: + return subprocess.run( + cmd, + shell=True, + capture_output=False, + stdout=subprocess.PIPE, + stderr=stderr_setting, + text=True, + check=check, + env=process_env, + ) + except subprocess.CalledProcessError as e: + print("Command failed with CalledProcessError.") + print("Exit code:", e.returncode) + print("Command:", e.cmd) + print("Stdout:", e.stdout) + print("Stderr:", e.stderr) + raise diff --git a/uv.lock b/uv.lock index 48bba17..78dfcca 100644 --- a/uv.lock +++ b/uv.lock @@ -936,8 +936,11 @@ dev = [ { name = "pytest-xdist", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest-xdist", version = "3.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "ruff" }, + { name = "types-requests", version = "2.32.0.20241016", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "types-requests", version = "2.32.4.20250809", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "types-setuptools", version = "75.8.0.20250110", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "types-setuptools", version = "80.9.0.20250822", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "types-urllib3" }, ] [package.metadata] @@ -951,7 +954,9 @@ requires-dist = [ { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.0.0" }, { name = "requests", specifier = ">=2.32.4" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "types-requests", marker = "extra == 'dev'" }, { name = "types-setuptools", marker = "extra == 'dev'" }, + { name = "types-urllib3", marker = "extra == 'dev'" }, ] provides-extras = ["dev"] @@ -994,6 +999,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "types-requests" +version = "2.32.0.20241016" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "urllib3", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/3c/4f2a430c01a22abd49a583b6b944173e39e7d01b688190a5618bd59a2e22/types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95", size = 18065, upload-time = "2024-10-16T02:46:10.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/01/485b3026ff90e5190b5e24f1711522e06c79f4a56c8f4b95848ac072e20f/types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747", size = 15836, upload-time = "2024-10-16T02:46:09.734Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20250809" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +dependencies = [ + { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/b0/9355adb86ec84d057fea765e4c49cce592aaf3d5117ce5609a95a7fc3dac/types_requests-2.32.4.20250809.tar.gz", hash = "sha256:d8060de1c8ee599311f56ff58010fb4902f462a1470802cf9f6ed27bc46c4df3", size = 23027, upload-time = "2025-08-09T03:17:10.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/6f/ec0012be842b1d888d46884ac5558fd62aeae1f0ec4f7a581433d890d4b5/types_requests-2.32.4.20250809-py3-none-any.whl", hash = "sha256:f73d1832fb519ece02c85b1f09d5f0dd3108938e7d47e7f94bbfa18a6782b163", size = 20644, upload-time = "2025-08-09T03:17:09.716Z" }, +] + [[package]] name = "types-setuptools" version = "75.8.0.20250110" @@ -1018,6 +1053,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/2d/475bf15c1cdc172e7a0d665b6e373ebfb1e9bf734d3f2f543d668b07a142/types_setuptools-80.9.0.20250822-py3-none-any.whl", hash = "sha256:53bf881cb9d7e46ed12c76ef76c0aaf28cfe6211d3fab12e0b83620b1a8642c3", size = 63179, upload-time = "2025-08-22T03:02:07.643Z" }, ] +[[package]] +name = "types-urllib3" +version = "1.26.25.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/de/b9d7a68ad39092368fb21dd6194b362b98a1daeea5dcfef5e1adb5031c7e/types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f", size = 11239, upload-time = "2023-07-20T15:19:31.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/7b/3fc711b2efea5e85a7a0bbfe269ea944aa767bbba5ec52f9ee45d362ccf3/types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e", size = 15377, upload-time = "2023-07-20T15:19:30.379Z" }, +] + [[package]] name = "typing-extensions" version = "4.13.2"